From 16c8ccf8b7680257db2139fcb329b035a07f565e Mon Sep 17 00:00:00 2001 From: woodsonl <65194841+woodsonl@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:34:06 -0500 Subject: [PATCH] fix: harden cluster-manager pairing signals and PIN handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes to the pairing flow, all on the plaintext pre-commit HTTP path: - Terminal pairing signals (cancel/decline/expire/fail) are now authenticated. A bystander who only observed an inviteId on the wire could previously drive the inviter or joiner to tear down a pending invite. Each signal now carries an HMAC (signalTag) keyed by the session's ephemeral Key Exchange secret, which only the two parties that ran the Initial Exchange hold; handlePairing verifies it before dispatching the phase. eap-noob exposes that secret via Server/Peer.EphemeralKey. - A repeated Key Exchange on a session that already holds its ephemeral secret is refused. The EAP peer's onKeyExchangeRequest has no state guard, so without this an attacker who learned the inviteId could POST a fresh Type-3, overwrite the session key, and then forge the signal MAC the change above relies on. - The Completion Exchange is rate-limited per invite. The PIN is six digits, so each attempt materially reduces the guesses needed; N attempts tolerate honest typos, and beyond that the invite and its EAP session are torn down. The kickoff POST is not counted. The endpoint is unauthenticated and the inviteId is on the wire, so an on-path attacker can still burn the budget — that is the documented active-MITM boundary, noted in the README. - The PIN Noob is derived through PBKDF2 with a fresh per-invite salt (carried inside the MAC-covered ServerInfo transcript, so it cannot be swapped) instead of a single unsalted hash. This raises the cost of checking PIN candidates against a captured NoobId from milliseconds to ~10^6 x iteration cost. Empty salt keeps the legacy derivation so an older peer still pairs. - handleCancelInvite no longer unlocks sess.mu on a path that still held it, fixing a double-unlock/leak when the session had no Key Exchange secret. Signed-off-by: woodsonl <65194841+woodsonl@users.noreply.github.com> --- services/eap-noob/ephemeral_key_test.go | 54 +++ services/eap-noob/export.go | 16 + services/nvpair-cluster-manager/README.md | 18 + services/nvpair-cluster-manager/cancel.go | 28 +- .../nvpair-cluster-manager/cm_unit_test.go | 35 +- services/nvpair-cluster-manager/httpserver.go | 60 +++- services/nvpair-cluster-manager/invite.go | 78 ++++- .../nvpair-cluster-manager/invite_expiry.go | 3 +- services/nvpair-cluster-manager/pairing.go | 170 ++++++++- .../pairing_signal_gate_test.go | 325 ++++++++++++++++++ services/nvpair-cluster-manager/respond.go | 43 ++- 11 files changed, 792 insertions(+), 38 deletions(-) create mode 100644 services/eap-noob/ephemeral_key_test.go create mode 100644 services/nvpair-cluster-manager/pairing_signal_gate_test.go diff --git a/services/eap-noob/ephemeral_key_test.go b/services/eap-noob/ephemeral_key_test.go new file mode 100644 index 00000000..08440d97 --- /dev/null +++ b/services/eap-noob/ephemeral_key_test.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package eapnoob + +import ( + "bytes" + "testing" +) + +// TestEphemeralKeyLifecycle covers the export the cluster manager's signal MAC +// derives from: it must error before a Key Exchange ran, agree between server +// and peer after the Initial Exchange, and hand out a copy so a caller cannot +// corrupt the live secret. +func TestEphemeralKeyLifecycle(t *testing.T) { + srv := NewServer(ServerConfig{Dirs: 2, ServerInfo: map[string]any{"role": "server"}}, nil) + peer := NewPeer(PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"role": "peer"}}, nil) + + if _, err := srv.EphemeralKey(); err == nil { + t.Fatal("server EphemeralKey succeeded before any Key Exchange") + } + if _, err := peer.EphemeralKey(); err == nil { + t.Fatal("peer EphemeralKey succeeded before any Key Exchange") + } + + driveConversation(t, srv, peer) + if srv.State() != StateWaiting || peer.State() != StateWaiting { + t.Fatalf("after Initial: server=%s peer=%s, want waiting on both", srv.State(), peer.State()) + } + + srvKey, err := srv.EphemeralKey() + if err != nil { + t.Fatalf("server EphemeralKey after Initial Exchange: %v", err) + } + peerKey, err := peer.EphemeralKey() + if err != nil { + t.Fatalf("peer EphemeralKey after Initial Exchange: %v", err) + } + if len(srvKey) == 0 { + t.Fatal("server key is empty after a Key Exchange") + } + if !bytes.Equal(srvKey, peerKey) { + t.Fatal("server and peer ephemeral keys differ after the same Initial Exchange") + } + + srvKey[0] ^= 0xFF + again, err := srv.EphemeralKey() + if err != nil { + t.Fatalf("server EphemeralKey re-read: %v", err) + } + if !bytes.Equal(again, peerKey) { + t.Fatal("mutating a returned key changed the server's secret; EphemeralKey must return a copy") + } +} diff --git a/services/eap-noob/export.go b/services/eap-noob/export.go index 7b670820..b21467bb 100644 --- a/services/eap-noob/export.go +++ b/services/eap-noob/export.go @@ -14,6 +14,22 @@ import ( // successful Completion Exchange has established the association key Kz. var ErrNotRegistered = errors.New("eapnoob: no registered association") +// EphemeralKey returns the raw ECDH shared secret (z) established by the +// Key Exchange of the in-flight method execution, before any PIN is involved. +// Both sides hold the identical value once the Initial Exchange completes; +// a passive observer of the plaintext transport cannot compute it. Callers +// use it to authenticate transport-level session signals (cancel/decline/ +// expire) that arrive outside the EAP-NOOB message stream. It returns an +// error when no Key Exchange has run yet. +// +// Promoted to Server.EphemeralKey and Peer.EphemeralKey via embedding. +func (ms *methodState) EphemeralKey() ([]byte, error) { + if len(ms.z) == 0 { + return nil, errors.New("eapnoob: no key exchange performed") + } + return append([]byte(nil), ms.z...), nil +} + // Export derives a shared secret of arbitrary length from the established // association. Both the peer and the server, having reached the Registered // state with the same Kz, derive identical bytes for the same label and diff --git a/services/nvpair-cluster-manager/README.md b/services/nvpair-cluster-manager/README.md index 10ca9388..dce7afb9 100644 --- a/services/nvpair-cluster-manager/README.md +++ b/services/nvpair-cluster-manager/README.md @@ -382,6 +382,24 @@ active man-in-the-middle** on the LAN, and must be replaced by a high-entropy pairing code before cluster trust is relied on in production. The repository [`SECURITY.md`](../../SECURITY.md) records the same boundary. +Two hardening measures bound what a captured transcript or a learned invite +ID is worth: + +- **PIN stretching.** The PIN key is derived with PBKDF2-HMAC-SHA256 (50k + iterations, per-invite salt inside the EAP-MAC-covered ServerInfo), so an + eavesdropped NoobId cannot be offline-checked against the 10^6 PIN + keyspace — guessing must happen online, where it is rate-limited. +- **Authenticated terminal signals.** Cancel, decline, fail, and expire + require an HMAC tag computed over the invite ID and phase with the + session's ephemeral Key Exchange secret; a peer who only learned the + invite ID cannot kill a pairing. Completion attempts are capped (5 + non-kickoff messages per invite; the empty kickoff is not counted) with + invite teardown on exhaustion, so the online-guessing path stays online. + The completion endpoint itself is unauthenticated and the invite ID is on + the wire, so an on-path attacker who can send traffic can still burn an + invite's attempt budget and force teardown. That is the documented + active-MITM boundary, not a defense this cap provides. + Trust is also **transitive** now (see fan-out above): a compromised member can endorse certs the whole cluster will pin, widening the blast radius of one bad node. This deliberate trade-off is why pairing should occur only on diff --git a/services/nvpair-cluster-manager/cancel.go b/services/nvpair-cluster-manager/cancel.go index 9ad63e82..20f18553 100644 --- a/services/nvpair-cluster-manager/cancel.go +++ b/services/nvpair-cluster-manager/cancel.go @@ -73,6 +73,25 @@ func (m *Manager) handleCancelInvite(msg *Message) { } // Deleting the Server session invalidates the PIN; a later Completion POST // then hits handlePairingCompletion's unknown-invite / not-pending branches. + // The signal MAC is derived first: it needs the session's ephemeral Key + // Exchange secret, which dies with the session. (sess.mu is already held.) + signalKey, keyErr := sess.ephemeralKey() + if keyErr != nil { + // No Key Exchange secret (initial exchange never completed) — the joiner + // has no session to clear either, so skipping the notify is safe. The + // terminal write and session delete stay under sess.mu, matching the + // success path below and keeping this serialized against Completion. + m.finishInvite(p.InviteID, inviteStateCanceled) + m.deleteSession(p.InviteID) + sess.mu.Unlock() + m.maybeLeaveInviteCreatedClusterLocked() + m.inviteMu.Unlock() + log.Printf("invite %s: canceled by inviter (no joiner signal key; skipping notify: %v)", p.InviteID, keyErr) + m.codec.RespondErrorData(msg.ID, codeInvalidState, "invite is not pending", + map[string]any{"inviteId": p.InviteID, "state": inviteStateCanceled}) + return + } + signalTag := pairingSignalMAC(signalKey, p.InviteID, "cancel") m.finishInvite(p.InviteID, inviteStateCanceled) m.deleteSession(p.InviteID) joinerAddr := sess.joinerAddr @@ -82,7 +101,7 @@ func (m *Manager) handleCancelInvite(msg *Message) { // Best-effort: tell the joiner so its pending prompt clears. If the joiner is // unreachable, the teardown above still prevents the join. - go m.notifyJoinerCanceled(joinerAddr, p.InviteID) + go m.notifyJoinerCanceled(joinerAddr, p.InviteID, signalTag) log.Printf("invite %s: canceled by inviter", p.InviteID) m.respondInvite(msg, p.InviteID) @@ -90,13 +109,14 @@ func (m *Manager) handleCancelInvite(msg *Message) { // notifyJoinerCanceled POSTs a best-effort "cancel" pairing envelope to the // joiner so it can drop its pending-inbound invite and dismiss the PIN prompt. -// Failures are logged and ignored — the inviter-side teardown is authoritative. -func (m *Manager) notifyJoinerCanceled(joinerAddr, inviteID string) { +// signalTag authenticates the signal (see pairingSignalMAC). Failures are +// logged and ignored — the inviter-side teardown is authoritative. +func (m *Manager) notifyJoinerCanceled(joinerAddr, inviteID, signalTag string) { if joinerAddr == "" { return } client := &http.Client{Timeout: pairingHTTPTimeout} - if _, err := postPairingBlob(client, joinerAddr, inviteID, "cancel", nil); err != nil { + if _, err := postPairingBlobTagged(client, joinerAddr, inviteID, "cancel", nil, signalTag); err != nil { log.Printf("invite %s: notify joiner cancel: %v", inviteID, err) } } diff --git a/services/nvpair-cluster-manager/cm_unit_test.go b/services/nvpair-cluster-manager/cm_unit_test.go index 589177e9..5497a001 100644 --- a/services/nvpair-cluster-manager/cm_unit_test.go +++ b/services/nvpair-cluster-manager/cm_unit_test.go @@ -161,15 +161,38 @@ func TestPINNoobRoundTrip(t *testing.T) { t.Fatalf("noob decodes to %s, want %s", got, want.String()) } } +} + +func TestSaltedPINNoob(t *testing.T) { + salt, err := newPinSalt() + if err != nil { + t.Fatalf("newPinSalt: %v", err) + } + if len(salt) != 16 { + t.Fatalf("salt length %d, want 16", len(salt)) + } - gp, noob, err := generatePIN() + pin, noob, err := mintPIN(salt) if err != nil { - t.Fatalf("generatePIN: %v", err) + t.Fatalf("mintPIN: %v", err) } - if !pinPattern.MatchString(gp) { - t.Fatalf("generated PIN %q is not six digits", gp) + if !pinPattern.MatchString(pin) { + t.Fatalf("generated PIN %q is not six digits", pin) } - if string(noob) != string(noobFromPIN(gp)) { - t.Fatal("generatePIN's noob does not match noobFromPIN of its PIN") + + // Joiner reconstruction must match the inviter's Noob, and the same PIN + // under different salts must produce unrelated Noobs. + joinerNoob := deriveNoobFromPINAndSalt(pin, salt) + if string(joinerNoob) != string(noob) { + t.Fatal("joiner-derived noob does not match inviter noob") + } + otherNoob := pinNoob(pin, append([]byte("x"), salt...)) + if string(otherNoob) == string(noob) { + t.Fatal("different salt produced identical noob") + } + + // Legacy escape hatch: empty salt must equal the unsalted derivation. + if string(pinNoob(pin, nil)) != string(noobFromPIN(pin)) { + t.Fatal("empty salt must fall back to legacy noob derivation") } } diff --git a/services/nvpair-cluster-manager/httpserver.go b/services/nvpair-cluster-manager/httpserver.go index f010e58b..881dcbee 100644 --- a/services/nvpair-cluster-manager/httpserver.go +++ b/services/nvpair-cluster-manager/httpserver.go @@ -43,6 +43,11 @@ type pairingEnvelope struct { // state:"rejected" rather than state:"failed". Empty on normal messages. Rejected bool `json:"rejected,omitempty"` Reason string `json:"reason,omitempty"` + // SignalTag authenticates a terminal signal (cancel/decline/expire/fail) + // as an HMAC over the session's ephemeral Key Exchange secret — proof the + // sender ran the Initial Exchange, not a bystander who only observed the + // inviteId on the wire. Verified in handlePairing before the phase runs. + SignalTag string `json:"signalTag,omitempty"` } // runHTTP starts the inter-node listener on the configured port and blocks until @@ -99,8 +104,16 @@ func (m *Manager) handlePairing(w http.ResponseWriter, r *http.Request) { case "completion": m.handlePairingCompletion(w, &env, msg, hostOnly(r.RemoteAddr)) case "cancel": + if sess, ok := m.getSession(env.InviteID); !ok || !verifyPairingSignal(sess, &env, "cancel") { + http.Error(w, "unauthenticated signal", http.StatusUnauthorized) + return + } m.handlePairingCancel(w, &env) case "decline": + if sess, ok := m.getSession(env.InviteID); !ok || !verifyPairingSignal(sess, &env, "decline") { + http.Error(w, "unauthenticated signal", http.StatusUnauthorized) + return + } m.handlePairingDecline(w, &env) case "fail": callerUUID := "" @@ -111,6 +124,11 @@ func (m *Manager) handlePairing(w http.ResponseWriter, r *http.Request) { http.Error(w, "not a trusted peer", http.StatusForbidden) return } + } else if sess, ok := m.getSession(env.InviteID); !ok || !verifyPairingSignal(sess, &env, "fail") { + // Plain-HTTP pre-commit fail (the wrong-PIN mirror): authenticated + // with the session's ephemeral-key MAC like the other signals. + http.Error(w, "unauthenticated signal", http.StatusUnauthorized) + return } m.handlePairingFailedFrom(w, &env, callerUUID) case "ack": @@ -121,6 +139,10 @@ func (m *Manager) handlePairing(w http.ResponseWriter, r *http.Request) { } m.handlePairingAck(w, &env, callerUUID) case "expire": + if sess, ok := m.getSession(env.InviteID); !ok || !verifyPairingSignal(sess, &env, "expire") { + http.Error(w, "unauthenticated signal", http.StatusUnauthorized) + return + } m.handlePairingExpired(w, &env) default: http.Error(w, "unknown phase", http.StatusBadRequest) @@ -196,6 +218,18 @@ func (m *Manager) handlePairingInitial(w http.ResponseWriter, env *pairingEnvelo } sess.mu.Lock() + // Refuse a repeated Key Exchange on a session that already holds an + // ephemeral secret. The EAP peer's onKeyExchangeRequest has no state guard, + // so re-feeding it a fresh Type-3 would overwrite sess.z — letting anyone + // who learned the inviteId install their own Key Exchange secret and then + // forge the terminal-signal MAC that authenticates cancel/decline/expire. + // Later Initial-phase messages (Waiting/NoobID) are still allowed, so only + // a Type-3 message on a keyed session is refused. + if _, err := sess.ephemeralKey(); err == nil && pairingMsgType(msg) == 3 { + sess.mu.Unlock() + http.Error(w, "key exchange already completed", http.StatusConflict) + return + } out, err := sess.peer.Receive(msg) if err != nil { m.deleteSession(env.InviteID) @@ -259,6 +293,27 @@ func (m *Manager) handlePairingCompletion(w http.ResponseWriter, env *pairingEnv respondPairing(w, blob) return } + // Rate-limit the Completion Exchange before feeding the message to the EAP + // server: the PIN is six digits, so each additional attempt materially + // reduces the work a guessed PIN needs. The NoobId derived from a PIN Noob + // is offline-checkable against a captured transcript (documented PIN Noob + // debt, §4), and the exchange is driven over plaintext HTTP — without a + // cap an on-path attacker can exhaust the keyspace within one invite TTL. + // N attempts tolerate honest typos; beyond that the invite (and the EAP + // session holding its Noob) is torn down, invalidating the transcript. + sess.completionAttempts++ + if sess.completionAttempts > maxCompletionAttempts { + // Tear down the invite AND the EAP session: dropping the session + // invalidates the Noob the attacker is testing against, so a resumed + // attack would need a fresh invite (and fresh user cooperation). + // inviteMu/sess.mu stay held until return — the defers above unlock + // them, and the teardown helpers take only memMu/sessMu. + m.finishInviteReason(env.InviteID, inviteStateFailed, reasonIncorrectPIN) + m.deleteSession(env.InviteID) + m.emitNodesChanged() + http.Error(w, "too many pairing attempts", http.StatusTooManyRequests) + return + } out, err := sess.server.Receive(msg) if err != nil { http.Error(w, "eap-noob: "+err.Error(), http.StatusBadRequest) @@ -520,6 +575,7 @@ func (m *Manager) prepareJoinerInitialComplete(inviteID string, sess *pairingSes type supersededInboundInvite struct { invite *Invite inviterAddr string + signalTag string } // onJoinerInitialComplete publishes the newest pending invite from a sender and @@ -556,7 +612,7 @@ func (m *Manager) onJoinerInitialComplete(inv *Invite, sess *pairingSession) { if err := m.codec.Notify("cluster:invite-canceled", old.invite); err != nil { log.Printf("emit cluster:invite-canceled: %v", err) } - go m.notifyInviterTerminal(old.inviterAddr, old.invite.InviteID, "decline", "") + go m.notifyInviterTerminal(old.inviterAddr, old.invite.InviteID, "decline", "", old.signalTag) log.Printf("invite %s: superseded by newer invite %s from %s", old.invite.InviteID, inv.InviteID, inv.FromNodeUUID) } @@ -590,6 +646,7 @@ func (m *Manager) supersedePendingInboundLocked(fromNodeUUID, keepInviteID strin sess.mu.Unlock() continue } + signalTag := sessionSignalTag(sess, id, "decline") m.finishInvite(id, inviteStateCanceled) m.deleteSession(id) inviterAddr := sess.addr @@ -603,6 +660,7 @@ func (m *Manager) supersedePendingInboundLocked(fromNodeUUID, keepInviteID strin superseded = append(superseded, supersededInboundInvite{ invite: canceled, inviterAddr: inviterAddr, + signalTag: signalTag, }) } return superseded diff --git a/services/nvpair-cluster-manager/invite.go b/services/nvpair-cluster-manager/invite.go index 388acef1..cf24901c 100644 --- a/services/nvpair-cluster-manager/invite.go +++ b/services/nvpair-cluster-manager/invite.go @@ -257,17 +257,29 @@ var errPairingAbandoned = errors.New("pairing abandoned: invite is no longer pen // its liveness. func (m *Manager) runInitialExchange(inviteID, target, cid0 string, sessGen0 uint64) (*pairingSession, string, error) { myAddr := net.JoinHostPort(outboundIP(target), strconv.Itoa(m.port)) - info, err := m.localPairingInfo(myAddr).toMap() + // The PIN stretching salt must exist before the Initial Exchange so it can + // ride in ServerInfo, which the EAP-NOOB transcript MACs (integrity- + // protected path to the joiner). + salt, err := newPinSalt() + if err != nil { + return nil, "", fmt.Errorf("generate pin salt: %w", err) + } + info, err := m.localPairingInfoWithPinSalt(myAddr, salt) + if err != nil { + return nil, "", fmt.Errorf("build pairing info: %w", err) + } + infoMap, err := info.toMap() if err != nil { return nil, "", fmt.Errorf("build pairing info: %w", err) } - server := newPairingServer(info) + server := newPairingServer(infoMap) // Scope the session to the cluster we are inviting into (cid0, captured by // handleInviteNode) so a Completion that lands after we leave/are removed is // discarded by commitPairing's epoch recheck. sess := &pairingSession{ inviteID: inviteID, role: roleInviter, createdAt: time.Now().UnixMilli(), server: server, addr: myAddr, clusterID: cid0, joinerAddr: target, + pinSalt: salt, } // Register under the teardown boundary: if the cluster was torn down (or // rejoined, bumping the session generation) since the invite began, abandon @@ -309,7 +321,7 @@ func (m *Manager) runInitialExchange(inviteID, target, cid0 string, sessGen0 uin if server.State() != eapnoob.StateWaiting { return sess, "", fmt.Errorf("initial exchange ended in state %s, want Waiting", server.State()) } - pin, noob, err := generatePIN() + pin, noob, err := mintPIN(sess.pinSalt) if err != nil { return sess, "", fmt.Errorf("generate pin: %w", err) } @@ -360,18 +372,20 @@ func postPairingBlob(client *http.Client, target, inviteID, phase string, blob [ return base64.StdEncoding.DecodeString(out.Msg) } -// postPairingSignal POSTs a best-effort terminal pairing signal (phase +// postPairingSignalScheme POSTs a best-effort terminal pairing signal (phase // "decline" or "fail") to the inviter so it can tear down its pending outbound -// invite. Unlike postPairingBlob it carries no EAP blob (the pairing is already -// terminal) but does carry the Reason so the inviter can surface a specific -// cause (e.g. "incorrect-pin"). Returns an error only on transport / non-200 so -// the caller can retry. -func postPairingSignal(client *http.Client, target, inviteID, phase, reason string) error { - return postPairingSignalScheme(client, "http", target, inviteID, phase, reason) +// invite. It carries no EAP blob (the pairing is already terminal) and no +// reason; it sends no authentication tag — a hardened inviter rejects it and +// falls back to its TTL (see postPairingSignalTagged). +func postPairingSignalScheme(client *http.Client, scheme, target, inviteID, phase, reason string) error { + return postPairingSignalTagged(client, scheme, target, inviteID, phase, reason, "") } -func postPairingSignalScheme(client *http.Client, scheme, target, inviteID, phase, reason string) error { - env := pairingEnvelope{InviteID: inviteID, Phase: phase, Reason: reason} +// postPairingSignalTagged is postPairingSignalScheme with an optional +// authentication tag for the signal (see pairingSignalMAC). An empty tag is +// sent unauthenticated and will be rejected by hardened inviters. +func postPairingSignalTagged(client *http.Client, scheme, target, inviteID, phase, reason, signalTag string) error { + env := pairingEnvelope{InviteID: inviteID, Phase: phase, Reason: reason, SignalTag: signalTag} body, err := json.Marshal(env) if err != nil { return err @@ -388,6 +402,46 @@ func postPairingSignalScheme(client *http.Client, scheme, target, inviteID, phas return nil } +// postPairingBlobTagged is postPairingBlob with an optional authentication tag +// for transport-level signals that carry no EAP payload (e.g. "cancel"). +func postPairingBlobTagged(client *http.Client, target, inviteID, phase string, blob []byte, signalTag string) ([]byte, error) { + env := pairingEnvelope{InviteID: inviteID, Phase: phase, SignalTag: signalTag} + if len(blob) > 0 { + env.Msg = base64.StdEncoding.EncodeToString(blob) + } + body, err := json.Marshal(env) + if err != nil { + return nil, err + } + resp, err := client.Post("http://"+target+pairingPath, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if resp.StatusCode == http.StatusConflict { + // A 409 may be an explicit pairing refusal (rejected envelope with a + // reason) rather than a protocol error. Decode it so the inviter can + // report state:"rejected"; a plain-text 409 (session/phase mismatch) + // won't set Rejected and falls through to the generic error below. + var out pairingEnvelope + if json.Unmarshal(rb, &out) == nil && out.Rejected { + return nil, &pairingRejectedError{reason: out.Reason} + } + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("pairing %s: status %d: %s", phase, resp.StatusCode, string(rb)) + } + var out pairingEnvelope + if err := json.Unmarshal(rb, &out); err != nil { + return nil, fmt.Errorf("decode pairing response: %w", err) + } + if out.Msg == "" { + return nil, nil + } + return base64.StdEncoding.DecodeString(out.Msg) +} + // reachableEndpointFirst moves the first endpoint whose pairing port accepts a // connection to the front, keeping the others behind it in their published order. // diff --git a/services/nvpair-cluster-manager/invite_expiry.go b/services/nvpair-cluster-manager/invite_expiry.go index 756c072b..611cbb31 100644 --- a/services/nvpair-cluster-manager/invite_expiry.go +++ b/services/nvpair-cluster-manager/invite_expiry.go @@ -239,11 +239,12 @@ func (m *Manager) expireInboundInvitesLocked(ids []string) { membersChanged = true } inviterAddr := sess.addr + signalTag := sessionSignalTag(sess, id, "expire") m.deleteSession(id) sess.mu.Unlock() m.emitInviteExpired(id) - go m.notifyInviterTerminal(inviterAddr, id, "expire", "") + go m.notifyInviterTerminal(inviterAddr, id, "expire", "", signalTag) log.Printf("invite %s: expired inbound (TTL elapsed with no local response)", id) } if membersChanged { diff --git a/services/nvpair-cluster-manager/pairing.go b/services/nvpair-cluster-manager/pairing.go index 218d1467..0a580b9b 100644 --- a/services/nvpair-cluster-manager/pairing.go +++ b/services/nvpair-cluster-manager/pairing.go @@ -4,10 +4,15 @@ package main import ( + "crypto/hmac" + "crypto/pbkdf2" "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "math/big" "regexp" @@ -42,6 +47,11 @@ type PairingInfo struct { ClusterFriendlyName string `json:"clusterFriendlyName"` Addr string `json:"addr,omitempty"` Cert string `json:"cert"` + // PinSalt is the inviter's per-invite PBKDF2 salt for stretching the PIN + // into the OOB Noob (see pinNoob), base64-encoded. It rides inside the + // EAP-NOOB transcript (ServerInfo is MAC-covered), so tampering with it + // breaks the transcript handshake rather than substituting a chosen Noob. + PinSalt string `json:"pinSalt,omitempty"` } // localPairingInfo builds this node's PairingInfo. addr is the inviter's @@ -68,6 +78,15 @@ func (m *Manager) localPairingInfo(addr string, admissionEpoch ...uint64) *Pairi } } +// localPairingInfoWithPinSalt is localPairingInfo for inviter pairing sessions, +// additionally carrying the PIN stretching salt so the joiner can reconstruct +// the Noob from the user's typed PIN. +func (m *Manager) localPairingInfoWithPinSalt(addr string, salt []byte) (*PairingInfo, error) { + pi := m.localPairingInfo(addr) + pi.PinSalt = base64.StdEncoding.EncodeToString(salt) + return pi, nil +} + // toMap renders the PairingInfo as the map[string]any the eap-noob config // expects for ServerInfo/PeerInfo. func (pi *PairingInfo) toMap() (map[string]any, error) { @@ -115,20 +134,68 @@ func parsePairingInfo(raw []byte) (*PairingInfo, *x509.Certificate, error) { return &pi, cert, nil } -// generatePIN returns a fresh random six-digit PIN and its 16-byte Noob -// encoding. -func generatePIN() (string, []byte, error) { +// pinPBKDF2Iterations is the stretching work factor applied when deriving the +// PIN Noob. The NoobId the joiner transmits is H("NoobId", Noob) with no key, +// so a passive observer of the plaintext pairing channel could otherwise check +// all 10^6 PIN candidates against a captured NoobId in milliseconds. A PBKDF2 +// pass per candidate makes offline enumeration ~10^6 × iteration cost instead, +// and the online cap in handlePairingCompletion complements it for active +// guessing. Tune upward with hardware. +const pinPBKDF2Iterations = 50000 + +// newPinSalt returns a fresh per-invite PBKDF2 salt for stretching the PIN +// into the OOB Noob. It is generated before the Initial Exchange so it can ride +// inside the inviter's ServerInfo — which the EAP-NOOB transcript MACs — +// giving the joiner an integrity-protected copy without a second channel. +func newPinSalt() ([]byte, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("generate pin salt: %w", err) + } + return salt, nil +} + +// mintPIN draws the random six-digit PIN and derives its Noob under the +// session's salt. Identical PINs across invites produce unrelated Noobs, and a +// captured NoobId cannot be attacked offline without redoing the full stretched +// search over all 10^6 candidates. +func mintPIN(salt []byte) (string, []byte, error) { n, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { - return "", nil, err + return "", nil, fmt.Errorf("generate pin: %w", err) } pin := fmt.Sprintf("%06d", n.Int64()) - return pin, noobFromPIN(pin), nil + return pin, pinNoob(pin, salt), nil +} + +// pinNoob derives the 16-byte Noob from a PIN and its per-invite salt: +// PBKDF2-HMAC-SHA256(PIN, salt), first 16 bytes. Stretching is the +// compensating control for the low-entropy PIN Noob (documented security +// debt, §4). An empty salt falls back to the legacy big-endian encoding so a +// pre-salt inviter remains pairable; the salt itself is not secret (it is +// MAC-covered against tampering), the work factor is the point. +func pinNoob(pin string, salt []byte) []byte { + if len(salt) == 0 { + return noobFromPIN(pin) + } + k, err := pbkdf2.Key(sha256.New, pin, salt, pinPBKDF2Iterations, 16) + if err != nil { + // sha256.New never fails to construct; an error here is a programming + // fault, so fail closed rather than pairing on a weak Noob. + panic(fmt.Sprintf("pbkdf2: %v", err)) + } + return k +} + +// deriveNoobFromPINAndSalt reconstructs the Noob on the joiner side from the +// PIN the user typed and the salt from the inviter's MAC-covered ServerInfo. +func deriveNoobFromPINAndSalt(pin string, salt []byte) []byte { + return pinNoob(pin, salt) } // noobFromPIN encodes a six-digit PIN as a 16-byte big-endian Noob. This is the -// low-entropy, temporary stand-in for a real OOB nonce (documented security -// debt, §4). +// legacy unsalted derivation, kept only for tests asserting the migration +// reference; production flows use pinNoob with a per-invite salt. func noobFromPIN(pin string) []byte { v := new(big.Int) v.SetString(pin, 10) @@ -157,6 +224,65 @@ const ( roleJoiner // EAP-NOOB Peer ) +// pairingSignalLabel domain-separates the terminal-signal MAC derived from the +// session's ephemeral Key Exchange secret. +const pairingSignalLabel = "nvpair-pairing-signal-v1" + +// pairingSignalMAC derives the authentication tag for a terminal pairing +// signal (cancel/decline/expire/fail) from the session's EphemeralKey — the +// ECDH secret both sides already share after the Initial Exchange. A passive +// on-path observer of the plaintext pairing channel can read the inviteId but +// cannot compute this tag, so a bystander who only learned the inviteId can no +// longer forge a terminal signal. (The Initial Exchange is unauthenticated, so +// an active on-path attacker who completes a Key Exchange can derive the key — +// active MITM is out of scope, see the README threat notes.) The tag is +// base64-encoded for transport; verification decodes before comparing. +// pairingMsgType returns the EAP-NOOB message Type of a raw wire message, or 0 +// when it cannot be read. Used to spot a repeated Key Exchange (type 3) on a +// session that already holds its ephemeral secret. +func pairingMsgType(raw []byte) int { + var probe struct { + Type *int `json:"Type"` + } + if json.Unmarshal(raw, &probe) != nil || probe.Type == nil { + return 0 + } + return *probe.Type +} + +func pairingSignalMAC(key []byte, inviteID, phase string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(pairingSignalLabel)) + mac.Write([]byte{0}) + mac.Write([]byte(inviteID)) + mac.Write([]byte{0}) + mac.Write([]byte(phase)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// verifyPairingSignal reports whether env carries a valid tag for the given +// phase under the session's ephemeral key. Unknown/absent keys verify nothing. +func verifyPairingSignal(sess *pairingSession, env *pairingEnvelope, phase string) bool { + if sess == nil || env.SignalTag == "" { + return false + } + sess.mu.Lock() + defer sess.mu.Unlock() + key, err := sess.ephemeralKey() + if err != nil { + return false + } + want, err := base64.StdEncoding.DecodeString(pairingSignalMAC(key, env.InviteID, phase)) + if err != nil { + return false + } + got, err := base64.StdEncoding.DecodeString(env.SignalTag) + if err != nil { + return false + } + return hmac.Equal(want, got) +} + // pairingSession holds the live EAP-NOOB crypto state for one in-flight pairing, // kept alive across the separate HTTP requests of the Initial and Completion // exchanges and the human PIN step in between (§7.2). Keyed by inviteId. @@ -200,10 +326,40 @@ type pairingSession struct { // retryable and lets a post-success joiner failure roll the inviter back. awaitingAck bool completionResponse []byte + // completionAttempts counts Completion Exchange messages fed to the EAP + // server, bounding online PIN guessing against the six-digit PIN Noob + // (see handlePairingCompletion). + completionAttempts int + // pinSalt is the per-invite PBKDF2 salt (inviter: generated with the PIN; + // joiner: learned from the inviter's authenticated ServerInfo) used to + // stretch the PIN into the OOB Noob. + pinSalt []byte mu sync.Mutex } +// maxCompletionAttempts bounds PIN-entry attempts per invite: enough for +// honest typos, far below the 10^6 PIN keyspace. +const maxCompletionAttempts = 5 + +// ephemeralKey returns this session's Key Exchange secret (see +// eapnoob.EphemeralKey). The caller must hold sess.mu (the eapnoob Server/Peer +// objects are not synchronized). Sessions whose EAP object was never created +// (or whose Initial Exchange never completed) report an error — callers treat +// the signal as unauthenticated/unsendable. +func (s *pairingSession) ephemeralKey() ([]byte, error) { + if s.role == roleInviter { + if s.server == nil { + return nil, errors.New("no inviter EAP session") + } + return s.server.EphemeralKey() + } + if s.peer == nil { + return nil, errors.New("no joiner EAP session") + } + return s.peer.EphemeralKey() +} + func (m *Manager) putSession(s *pairingSession) { m.sessMu.Lock() defer m.sessMu.Unlock() diff --git a/services/nvpair-cluster-manager/pairing_signal_gate_test.go b/services/nvpair-cluster-manager/pairing_signal_gate_test.go new file mode 100644 index 00000000..ace06c28 --- /dev/null +++ b/services/nvpair-cluster-manager/pairing_signal_gate_test.go @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "eapnoob" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// postPairing drives one plain-HTTP pairing envelope through handlePairing via +// a real server mux, so status-code gates are exercised the way a remote peer +// hits them. +func postPairing(t *testing.T, m *Manager, env pairingEnvelope) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc(pairingPath, m.handlePairing) + req := httptest.NewRequest(http.MethodPost, pairingPath, bytes.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +// signalTagFor computes the terminal-signal MAC the same way the joiner would. +func signalTagFor(t *testing.T, sess *pairingSession, inviteID, phase string) string { + t.Helper() + key, err := sess.ephemeralKey() + if err != nil { + t.Fatalf("session ephemeral key: %v", err) + } + return pairingSignalMAC(key, inviteID, phase) +} + +// putPendingInviterSessionWithEAP registers a pending outbound invite plus an +// inviter-role session carrying a live EAP-NOOB Server that has run a Key +// Exchange (so the ephemeral secret exists and signal MACs can be derived), +// mirroring the state runInitialExchange records once the joiner's Initial +// Exchange completes its Key Exchange round. +func putPendingInviterSessionWithEAP(t *testing.T, m *Manager, inviteID string) *pairingSession { + t.Helper() + info, err := m.localPairingInfo("127.0.0.1:1").toMap() + if err != nil { + t.Fatalf("pairing info: %v", err) + } + server := newPairingServer(info) + // Drive a real Initial Exchange against a Peer so the server holds the + // ECDH shared secret (z) the signal MAC derives from. PeerInfo mirrors a + // joiner's pre-adoption identity (no cluster yet). + peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"role": "peer"}}, nil) + msg, err := server.Start() + if err != nil { + t.Fatalf("server start: %v", err) + } + peerTurn := true + for { + var out eapnoob.Outcome + if peerTurn { + out, err = peer.Receive(msg) + } else { + out, err = server.Receive(msg) + } + if err != nil || out.Err != nil { + t.Fatalf("initial exchange round: err=%v protocolErr=%v", err, out.Err) + } + if len(out.Send) == 0 { + break + } + msg = out.Send + peerTurn = !peerTurn + } + if server.State() != eapnoob.StateWaiting { + t.Fatalf("server state %s after Initial Exchange, want waiting", server.State()) + } + sess := &pairingSession{ + inviteID: inviteID, + role: roleInviter, + createdAt: time.Now().UnixMilli(), + server: server, + } + m.putInvite(&Invite{ + InviteID: inviteID, + FromNodeUUID: m.identity.NodeUUID, + State: inviteStatePending, + CreatedAt: time.Now().UnixMilli(), + }) + m.putSession(sess) + return sess +} + +// TestPairingSignalGateRejectsUnauthenticated tests the 401 gate added in front +// of the cancel/decline/expire signal phases: a missing tag, a garbage tag, and +// a tag MACed for the wrong phase or wrong invite all get rejected, and none of +// them may tear down the invite or session they target. A correctly MACed tag +// passes the same gate. +func TestPairingSignalGateRejectsUnauthenticated(t *testing.T) { + for _, phase := range []string{"cancel", "decline", "expire"} { + t.Run(phase, func(t *testing.T) { + m := newTestManager(t) + m.addSelfMember() + + cases := []struct { + name string + tag func(freshSess *pairingSession) string + inviteID string + }{ + // Every tag is computed against the fresh session re-put for + // the case, so the wrong-phase and wrong-invite negatives + // fail on exactly the dimension under test — not on a stale + // key from an earlier session. + {"missing tag", func(*pairingSession) string { return "" }, "inv-gate"}, + {"garbage tag", func(*pairingSession) string { return "not-a-mac" }, "inv-gate"}, + {"wrong phase tag", func(s *pairingSession) string { return signalTagFor(t, s, "inv-gate", "fail") }, "inv-gate"}, + {"wrong invite tag", func(s *pairingSession) string { return signalTagFor(t, s, "inv-other", phase) }, "inv-gate"}, + {"valid tag", func(s *pairingSession) string { return signalTagFor(t, s, "inv-gate", phase) }, "inv-gate"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Re-put a pending invite/session for every case so a + // preceding teardown never masks the gate under test. + freshSess := putPendingInviterSessionWithEAP(t, m, "inv-gate") + rec := postPairing(t, m, pairingEnvelope{ + InviteID: tc.inviteID, Phase: phase, SignalTag: tc.tag(freshSess), + }) + // The valid tag is admitted past the 401 gate (the phase + // handler then answers with its own status); every other + // case must hit the gate and leave state untouched. + if tc.name == "valid tag" { + if rec.Code == http.StatusUnauthorized { + t.Fatalf("valid %s tag rejected with 401; gate is too strict", phase) + } + return + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s: status = %d, want 401", tc.name, rec.Code) + } + if inv, ok := m.getInvite("inv-gate"); !ok || inv.State != inviteStatePending { + t.Fatalf("%s: unauthenticated signal tore down the invite (state=%v, present=%v)", tc.name, inv.State, ok) + } + if _, ok := m.getSession("inv-gate"); !ok { + t.Fatalf("%s: unauthenticated signal dropped the session", tc.name) + } + }) + } + }) + } +} + +// TestCompletionAttemptsRateLimit verifies the PIN brute-force cap: attempts 1 +// through maxCompletionAttempts are not limited, the next one returns 429, and +// the over-limit attempt tears down both the invite (failed/incorrect-pin) and +// the EAP session so a resumed attack needs a fresh invite. +func TestCompletionAttemptsRateLimit(t *testing.T) { + m := newTestManager(t) + m.addSelfMember() + sess := putPendingInviterSessionWithEAP(t, m, "inv-rate") + if sess.server == nil { + t.Fatal("inviter session with EAP server missing") + } + + for i := 1; i <= maxCompletionAttempts; i++ { + // A non-empty (garbage) EAP message reaches the attempt counter; an + // empty msg is the kickoff POST and returns before it. + rec := postPairing(t, m, pairingEnvelope{InviteID: "inv-rate", Phase: "completion", Msg: base64.StdEncoding.EncodeToString([]byte("guess"))}) + if rec.Code == http.StatusTooManyRequests { + t.Fatalf("attempt %d (of %d allowed) was rate limited", i, maxCompletionAttempts) + } + if rec.Code >= 500 { + t.Fatalf("attempt %d: unexpected server error %d", i, rec.Code) + } + } + + rec := postPairing(t, m, pairingEnvelope{InviteID: "inv-rate", Phase: "completion", Msg: base64.StdEncoding.EncodeToString([]byte("guess"))}) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("over-limit completion status = %d, want %d", rec.Code, http.StatusTooManyRequests) + } + if inv, ok := m.getInvite("inv-rate"); !ok || inv.State != inviteStateFailed || inv.Reason != reasonIncorrectPIN { + t.Fatalf("invite after over-limit = %+v (present %v), want failed/incorrect-pin", inv, ok) + } + if _, ok := m.getSession("inv-rate"); ok { + t.Fatal("over-limit completion left the EAP session alive; a resumed attack keeps its transcript") + } +} + +// TestCancelInviteNoSignalKey exercises the cancel branch that runs before the +// Initial Exchange completed: the session has no ephemeral key, so the notify +// is skipped, but the invite must still be canceled and the session deleted, +// with the terminal write staying serialized under sess.mu. The codec writer is +// a bytes.Buffer, so the response frame is captured and its error payload +// asserted too. +func TestCancelInviteNoSignalKey(t *testing.T) { + var out bytes.Buffer + codec := NewCodec(struct { + io.Reader + io.Writer + }{strings.NewReader(""), &out}) + dir := t.TempDir() + mgr, err := NewManager(codec, dir, 14999) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := mgr.ensureAdmission("cluster-1"); err != nil { + t.Fatalf("establish admission: %v", err) + } + mgr.setClusterIdentity("cluster-1", "Lab") + m := mgr + m.addSelfMember() + + // A pending inviter session whose EAP server never started holds no Key + // Exchange secret — exactly the state a cancel racing the Initial Exchange + // observes. + m.putInvite(&Invite{ + InviteID: "inv-nokey", + FromNodeUUID: m.identity.NodeUUID, + State: inviteStatePending, + CreatedAt: time.Now().UnixMilli(), + }) + m.putSession(&pairingSession{inviteID: "inv-nokey", role: roleInviter}) + + id := json.RawMessage(`"cancel-nokey-1"`) + params, err := json.Marshal(map[string]string{"inviteId": "inv-nokey"}) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + m.handleCancelInvite(&Message{ID: &id, Method: "cluster:cancel-invite", Params: params}) + + if inv, ok := m.getInvite("inv-nokey"); !ok || inv.State != inviteStateCanceled { + t.Fatalf("invite state = %+v (present %v), want canceled", inv, ok) + } + if _, ok := m.getSession("inv-nokey"); ok { + t.Fatal("session survived a no-signal-key cancel") + } + resp, err := io.ReadAll(strings.NewReader(out.String())) + if err != nil { + t.Fatalf("read response: %v", err) + } + if !strings.Contains(string(resp), "invite is not pending") { + t.Fatalf("cancel response %q does not report the invalid-state error", string(resp)) + } + if !strings.Contains(string(resp), string(inviteStateCanceled)) { + t.Fatalf("cancel response %q does not carry the canceled state", string(resp)) + } +} + +// TestSecondInitialExchangeRejected guards against re-keying an established +// joiner session: the EAP peer's onKeyExchangeRequest has no state guard, so a +// repeated Key Exchange on a session that already holds its ephemeral secret +// would overwrite that secret and let the poster forge the terminal-signal MAC. +// A Key Exchange on a keyed session must be refused and the secret unchanged. +func TestSecondInitialExchangeRejected(t *testing.T) { + m := newTestManager(t) + m.addSelfMember() + + info, err := m.localPairingInfo("127.0.0.1:1").toMap() + if err != nil { + t.Fatalf("pairing info: %v", err) + } + peer := newPairingPeer(info) + // Drive the session's Peer to Waiting (Key Exchange complete) against a + // throwaway Server, so the session holds its ephemeral secret. + server := newPairingServer(map[string]any{"role": "inviter"}) + msg, err := server.Start() + if err != nil { + t.Fatalf("server start: %v", err) + } + for { + out, err := peer.Receive(msg) + if err != nil || out.Err != nil { + t.Fatalf("peer receive: err=%v protocolErr=%v", err, out.Err) + } + if len(out.Send) == 0 { + break + } + sout, err := server.Receive(out.Send) + if err != nil || sout.Err != nil { + t.Fatalf("server receive: err=%v protocolErr=%v", err, sout.Err) + } + if len(sout.Send) == 0 { + break + } + msg = sout.Send + } + before, err := peer.EphemeralKey() + if err != nil { + t.Fatalf("peer should hold a key after Key Exchange: %v", err) + } + + sess := &pairingSession{ + inviteID: "inv-rekey", + role: roleJoiner, + createdAt: time.Now().UnixMilli(), + peer: peer, + } + m.putSession(sess) + + // A fresh Key Exchange request (Type 3) on the keyed session is refused. + // The handler rejects on Type before the Peer ever parses it, so the + // payload only needs to be a valid envelope. + rekey := []byte(`{"Type":3}`) + rec := postPairing(t, m, pairingEnvelope{ + InviteID: "inv-rekey", Phase: "initial", + Msg: base64.StdEncoding.EncodeToString(rekey), + }) + if rec.Code != http.StatusConflict { + t.Fatalf("repeated key exchange status = %d, want 409", rec.Code) + } + after, err := peer.EphemeralKey() + if err != nil { + t.Fatalf("session key after refused re-key: %v", err) + } + if !bytes.Equal(before, after) { + t.Fatal("repeated key exchange overwrote the session key") + } +} diff --git a/services/nvpair-cluster-manager/respond.go b/services/nvpair-cluster-manager/respond.go index 33501b62..905dcbee 100644 --- a/services/nvpair-cluster-manager/respond.go +++ b/services/nvpair-cluster-manager/respond.go @@ -4,6 +4,7 @@ package main import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -94,6 +95,7 @@ func (m *Manager) handleRespondToInvite(msg *Message) { // teardown is authoritative even if the notify ultimately fails — the // inviter's invite TTL then expires the pending invite as a fallback. inviterAddr := sess.addr + signalTag := sessionSignalTag(sess, p.InviteID, "decline") m.finishInvite(p.InviteID, inviteStateDeclined) if sess.peerPairing != nil { m.removeMemberByUUID(sess.peerPairing.NodeUUID) @@ -101,7 +103,7 @@ func (m *Manager) handleRespondToInvite(msg *Message) { m.deleteSession(p.InviteID) sess.mu.Unlock() m.emitNodesChanged() - go m.notifyInviterTerminal(inviterAddr, p.InviteID, "decline", "") + go m.notifyInviterTerminal(inviterAddr, p.InviteID, "decline", "", signalTag) log.Printf("invite %s: declined by local user", p.InviteID) m.respondInvite(msg, p.InviteID) return @@ -152,6 +154,7 @@ func (m *Manager) handleRespondToInvite(msg *Message) { // after the session is dropped. Local teardown is authoritative even if // the notify fails — the inviter's invite TTL is the remaining backstop. inviterAddr := sess.addr + signalTag := sessionSignalTag(sess, p.InviteID, "fail") m.finishInviteReason(p.InviteID, inviteStateFailed, reason) if sess.peerPairing != nil { m.removeMemberByUUID(sess.peerPairing.NodeUUID) @@ -159,7 +162,7 @@ func (m *Manager) handleRespondToInvite(msg *Message) { m.deleteSession(p.InviteID) sess.mu.Unlock() m.emitNodesChanged() - go m.notifyInviterTerminal(inviterAddr, p.InviteID, "fail", reason) + go m.notifyInviterTerminal(inviterAddr, p.InviteID, "fail", reason, signalTag) m.respondInvite(msg, p.InviteID) return } @@ -249,7 +252,19 @@ func (m *Manager) runCompletionExchangeLocked(sess *pairingSession, pin string) if sess.addr == "" { return fmt.Errorf("inviter address unknown") } - if err := sess.peer.OOBInputNoob(noobFromPIN(pin)); err != nil { + // The salt arrived inside the inviter's MAC-covered ServerInfo (captured + // into sess.peerPairing at Initial completion), so the PIN Noob we derive + // matches what the inviter injected. Empty salt means a legacy pre-salt + // inviter: derive the legacy unsalted Noob (see pinNoob). + salt := sess.pinSalt + if len(salt) == 0 && sess.peerPairing != nil && sess.peerPairing.PinSalt != "" { + s, err := base64.StdEncoding.DecodeString(sess.peerPairing.PinSalt) + if err != nil { + return fmt.Errorf("decode pin salt: %w", err) + } + salt = s + } + if err := sess.peer.OOBInputNoob(deriveNoobFromPINAndSalt(pin, salt)); err != nil { return fmt.Errorf("feed pin: %w", err) } @@ -355,16 +370,18 @@ func (m *Manager) notifyInviterAuthenticated(inviterAddr string, inviterDER []by } // notifyInviterTerminal POSTs a pre-commit pairing outcome signal -// (decline/fail/expire). Retries a few times; remaining failures are logged and -// periodic reconciliation/TTL cleanup is the backstop. -func (m *Manager) notifyInviterTerminal(inviterAddr, inviteID, phase, reason string) { +// (decline/fail/expire), authenticated with signalTag over the session's +// ephemeral Key Exchange secret (see pairingSignalMAC). Retries a few times; +// remaining failures are logged and periodic reconciliation/TTL cleanup is the +// backstop. +func (m *Manager) notifyInviterTerminal(inviterAddr, inviteID, phase, reason, signalTag string) { if inviterAddr == "" { return } client := &http.Client{Timeout: pairingHTTPTimeout} var err error for attempt := 1; attempt <= inviterNotifyAttempts; attempt++ { - err = postPairingSignal(client, inviterAddr, inviteID, phase, reason) + err = postPairingSignalTagged(client, "http", inviterAddr, inviteID, phase, reason, signalTag) if err == nil { return } @@ -375,6 +392,18 @@ func (m *Manager) notifyInviterTerminal(inviterAddr, inviteID, phase, reason str } } +// sessionSignalTag computes the terminal-signal MAC for a live joiner session +// (caller holds sess.mu) and must be called before the session is deleted. +// An empty tag is returned when no Key Exchange secret exists yet — the signal +// is then sent unauthenticated and a hardened inviter falls back to its TTL. +func sessionSignalTag(sess *pairingSession, inviteID, phase string) string { + key, err := sess.ephemeralKey() + if err != nil { + return "" + } + return pairingSignalMAC(key, inviteID, phase) +} + // finishInvite transitions an invite to a terminal state and stamps respondedAt. func (m *Manager) finishInvite(inviteID string, state InviteState) { m.finishInviteReason(inviteID, state, "")