Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
26a8d38
wip: re-add mtls, start aligning responses with other clients
cblgh Jun 16, 2026
c4aac16
wip frontend mTLS: QR removed, starting to introduce changes into the…
cblgh Jun 16, 2026
07e0fde
deps: remove qr code, selectively bump versions to fix high severity …
cblgh Jun 17, 2026
3d6c7e7
per UX decision to go full manual connection, remove QR code logic
cblgh Jun 17, 2026
0570dc6
improve authorisation handling around register & sender hash verif.
cblgh Jun 17, 2026
4f92f7a
extract sender hash from requests and thread to frontend
cblgh Jun 17, 2026
247d5ed
mtls: on sender cert. hash displayed, prevent new hashes from being c…
cblgh Jun 18, 2026
48fc340
routes: transition to /api/v2 and handle {ping,register}@v1
cblgh Jun 18, 2026
66c8476
frontend: implement error dialog modal
cblgh Jun 18, 2026
e383d5c
readme: use v2 routes
cblgh Jun 18, 2026
6dea06a
v2/ping: respond with senderShowHash
cblgh Jun 18, 2026
c3a354a
fix: align sender cert hash calculation with that of mobile clients
cblgh Jun 18, 2026
fdc96cf
frontend(copy): login -> unlock
cblgh Jun 22, 2026
19c0f68
mtls: defer sending of ping response until receiver confirmation
cblgh Jun 22, 2026
43b87fa
guard against nil channel
cblgh Jun 22, 2026
8bff1fb
backend(registration): clear registration state on server (re)start
cblgh Jun 22, 2026
29e19e1
backend(ping): improve state handling around ping response
cblgh Jun 22, 2026
9a53aac
backend(register): comment out ResetPingResponse call on incorrect PIN
cblgh Jun 22, 2026
87d3bfb
frontend: cleanup fixed todos
cblgh Jun 22, 2026
a26e11b
frontend: remove unused handler, add comment about event to handle an…
cblgh Jun 22, 2026
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
108 changes: 96 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,69 @@ func NewHandler(service Service, ctx context.Context) *Handler {
}
}

func (h *Handler) ResetPingResponse() {
if h.pendingPingResponse != nil {
close(h.pendingPingResponse)
}
h.pendingPingResponse = nil
}

func (h *Handler) Reset() {
h.mu.Lock()
h.ResetPingResponse()
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) SendPingResponse() error {
// channel should never be nil here, but then again sometimes 'never' does happen :)
if h.pendingPingResponse != nil {
h.pendingPingResponse <- struct{}{}
}
return nil
}

func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
// 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 +135,34 @@ 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)
// TODO (2025-06-22): reset ping channel to allow for another attempt
// TODO (2026-06-22): maybe reset isn't actually needed - does mobile send another ping request if PIN is incorrect
// or does it simply send another register request?
//
// NOTE FOR REVIEWERS: this has been commented out until the team decides whether another connection attempt with
// a change PIN is possible with the current sender interfaces - seems like "discard & start over" renders making another attempt moot
// h.ResetPingResponse()
}
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 +177,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 +188,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