Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,16 @@ application.
The application implements the [Tella Nearby Sharing protocol](https://github.com/Horizontal-org/Tella-P2P-Protocol) with the following endpoints:

- Default Port: 53320
- `POST /api/v1/ping` - Initial handshake for manual connections
- `POST /api/v1/register` - Device registration with PIN authentication
- `POST /api/v1/prepare-upload` - Prepare file transfer session
- `PUT /api/v1/upload` - File upload with binary data
* `POST /api/v1/close-connection`
- `POST /api/v2/ping` - Initial handshake for manual connections
- `POST /api/v2/register` - Device registration with PIN authentication
- `POST /api/v2/prepare-upload` - Prepare file transfer session
- `PUT /api/v2/upload` - File upload with binary data
- `POST /api/v2/close-connection`

Legacy routes (transition to v2 is incompatible with v1):

- `POST /api/v1/ping` - returns 400 Client error
- `POST /api/v1/register` - returns 403 Rejected

## Platform-Specific Notes

Expand Down
9 changes: 9 additions & 0 deletions backend/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ func (a *App) ConfirmRegistration() error {
return a.registrationHandler.ConfirmRegistration()
}

// called as part of manual connection, when the receiver has confirmed the "receiver cert hash verification" by
// pressing button "confirm and continue"
func (a *App) ManualConfirmationReceiverForReceiver() error {
if a.registrationHandler == nil {
return errRegistrationNotInit
}
return a.registrationHandler.SendPingResponse()
}

func (a *App) RejectRegistration() error {
if a.registrationHandler == nil {
return errRegistrationNotInit
Expand Down
97 changes: 85 additions & 12 deletions backend/core/modules/registration/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Handler struct {
service Service
ctx context.Context
pendingRegistration *PendingRegistration
pendingPingResponse chan struct{}
mu sync.RWMutex
}

Expand All @@ -42,27 +43,65 @@ func NewHandler(service Service, ctx context.Context) *Handler {
}
}

func (h *Handler) Reset() {
h.mu.Lock()
if h.pendingPingResponse != nil {
close(h.pendingPingResponse)
}
h.pendingPingResponse = nil
h.pendingRegistration = nil
h.mu.Unlock()
}

func (h *Handler) HandlePing(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

runtime.EventsEmit(h.ctx, "ping-received", map[string]interface{}{
"timestamp": time.Now().Unix(),
"message": "Device attempting to connect",
"state": "waiting",
})
// we only react & respond to the first ping request we receive.
//
// TODO (2026-06-22): would like to tie the confirmation action to the ip doing the ip request, so that we
// can selectively only respond to that ping request. the current frontend structure makes this a bit difficult / fraught.
//
// barring that we decide to only react & respond to the first received ping request. this is also inline with the
// "single connection"-centric model of tella's p2p protocol. i.e. we only respond to ping if sender not
// registered/registration not in progress.
if h.pendingPingResponse == nil {
h.pendingPingResponse = make(chan struct{})
runtime.EventsEmit(h.ctx, "ping-received", map[string]interface{}{
"timestamp": time.Now().Unix(),
"message": "Device attempting to connect",
"state": "waiting",
})
}

// NOTE (2026-06-22): this style of solution only handles one ping response at a time: the first ping received
<-h.pendingPingResponse
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
}{
Status: "ok",
})
json.NewEncoder(w).Encode(map[string]bool{"senderShowHash": true})
}

func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
func (h *Handler) SendPingResponse() error {
// channel should never be nil here, but then again sometimes 'never' does happen :)
if h.pendingPingResponse != nil {
h.pendingPingResponse <- struct{}{}
}
return nil
}

// rememberSenderFingerprint pins the sender certificate hash. calling changes tls config of package server, making it stricter in terms
// of requiring tls client certs. the tls config change requires restarting the https server instance.
//
// lockAndGetSenderFingerprintCandidate locks the current candidate for sender fingerprint,
// preventing it from being changed without discarding the session and starting over.
// this locking behaviour is intended to limit the attack surface and prevent mismatches in what is presented to a user
// and what may be pinned.
//
// we lock after authorisation to limit attacks that are intended to sabotage the connection
// establishment by prematurely forcing the fingerprint to be locked and thus would prevent an authentic sender from
// having their fingerprint from being chosen.
func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request, rememberSenderFingerprint func (string) error, lockAndGetSenderFingerprintCandidate func() string) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
Expand Down Expand Up @@ -92,7 +131,27 @@ func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}

// Store the pending registration
if authorised, err := h.service.IsAuthorised(request.PIN, request.Nonce); !authorised {
if errors.Is(err, ErrPinInvalid) {
http.Error(w, "Invalid PIN", http.StatusUnauthorized)
}
if errors.Is(err, ErrTooManyAttempts) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
}
return
}

// do this after authorisation (PIN / nonce check) because getSenderFingerprintCandidate locks the current candidate,
// preventing it from being changed without discarding the session and starting over. this locking behaviour is to limit the attack
// surface of what is presented to a user and what may be pinned
//
// it's only a claimed sender until verification has been mutually confirmed
certificateHashClaimedSender := lockAndGetSenderFingerprintCandidate()
if len(certificateHashClaimedSender) != 64 {
http.Error(w, "Missing required parameters", http.StatusBadRequest)
return
}

h.mu.Lock()
h.pendingRegistration = &PendingRegistration{
PIN: request.PIN,
Expand All @@ -107,6 +166,7 @@ func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
"timestamp": time.Now().Unix(),
"message": "Sender is requesting to register",
"state": "confirm",
"senderCertificateHash": certificateHashClaimedSender,
})

// Wait for user confirmation or timeout
Expand All @@ -117,6 +177,19 @@ func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {

h.mu.Lock()
h.pendingRegistration = nil
// if we're sending a successful response it was because the pin was valid!
// since PIN was valid, we can now save the sender's certificate hash and restart the server
// note: since `rememberSenderFingerprint` requires a restart of the https server, we likely have to send the
// response before restarting?
err = rememberSenderFingerprint(certificateHashClaimedSender)
// TODO cblgh(2026-03-15): figure out something less catastrophic for the app than just panic here. but https
// handler is a hard place to recover from the death of the https server ^^'
//
// maybe emit some kind of event to frontend to signal catastrophic failure && need to restart?
// runtime.EventsEmit(h.ctx, "register-request-received", map[string]interface{}{
if err != nil {
panic(err)
}
h.mu.Unlock()

case err := <-h.pendingRegistration.Error:
Expand Down
1 change: 1 addition & 0 deletions backend/core/modules/registration/ports.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package registration

type Service interface {
IsAuthorised(pin, nonce string) (bool, error)
CreateSession(pin string, nonce string) (string, error)
SetPINCode(pinCode string)
ForgetSession(sessionID string)
Expand Down
16 changes: 13 additions & 3 deletions backend/core/modules/registration/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,26 @@ func NewService(ctx context.Context) Service {
}
}

func (s *service) CreateSession(pin, nonce string) (string, error) {
var ErrTooManyAttempts = errors.New("Too many invalid attempts")
var ErrPinInvalid = errors.New("Invalid PIN")

func (s *service) IsAuthorised(pin, nonce string) (bool, error) {
// TODO cblgh(2026-02-17): guard ratelimiter with mutex alt. use sync.Map to prevent crash from malicious behaviour?
// TODO cblgh(2026-03-11) change this rate limiter to not be on the nonce any more?
if s.rateLimiter[nonce] >= 3 { // check this with the team
return "", errors.New("too many invalid attempts")
return false, ErrTooManyAttempts
}

if pin != s.pinCode {
s.rateLimiter[nonce]++
return "", errors.New("Invalid pin")
return false, ErrPinInvalid
}
return true, nil
}

func (s *service) CreateSession(pin, nonce string) (string, error) {
if authorised, err := s.IsAuthorised(pin, nonce); !authorised {
return "", err
}

sessionID := uuid.New().String()
Expand Down
28 changes: 22 additions & 6 deletions backend/core/modules/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,26 @@ func NewHandler(
}
}

func (h *Handler) SetupRoutes() {
h.mux.HandleFunc("/api/v1/ping", h.registrationHandler.HandlePing)
h.mux.HandleFunc("/api/v1/register", h.registrationHandler.HandleRegister)
h.mux.HandleFunc("/api/v1/prepare-upload", h.transferHandler.HandlePrepare)
h.mux.HandleFunc("/api/v1/upload", h.transferHandler.HandleUpload)
h.mux.HandleFunc("/api/v1/close-connection", h.transferHandler.HandleCloseConnection)
// TODO (2026-06-15): keep /api/v1/ping /api/v1/register around and serve legacy responses to sent queries there
func (h *Handler) SetupRoutes(pinFingerprint func (string) error, getSenderFingerprintCandidate func () string) {
h.mux.HandleFunc("/api/v2/ping", h.registrationHandler.HandlePing)
h.mux.HandleFunc("/api/v2/register", func(res http.ResponseWriter, req *http.Request) {
h.registrationHandler.HandleRegister(res, req, pinFingerprint, getSenderFingerprintCandidate)
})
h.mux.HandleFunc("/api/v2/prepare-upload", h.transferHandler.HandlePrepare)
h.mux.HandleFunc("/api/v2/upload", h.transferHandler.HandleUpload)
h.mux.HandleFunc("/api/v2/close-connection", h.transferHandler.HandleCloseConnection)

// handle v1 legacy routes
h.mux.HandleFunc("/api/v1/ping", func (res http.ResponseWriter, req *http.Request) {
// TODO (2026-06-18): collectively decide what to respond to old v1-pings. Will 400 crash clients or is it OK?
http.Error(res, "Bad request", http.StatusBadRequest)
})
h.mux.HandleFunc("/api/v1/register", func(res http.ResponseWriter, req *http.Request) {
http.Error(res, "Rejected", http.StatusForbidden)
})

h.mux.HandleFunc("/", func (res http.ResponseWriter, req *http.Request) {
http.Error(res, "Not found", http.StatusNotFound)
})
}
Loading