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, "")