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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions services/eap-noob/ephemeral_key_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
16 changes: 16 additions & 0 deletions services/eap-noob/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions services/nvpair-cluster-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions services/nvpair-cluster-manager/cancel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -82,21 +101,22 @@ 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)
}

// 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)
}
}
35 changes: 29 additions & 6 deletions services/nvpair-cluster-manager/cm_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
60 changes: 59 additions & 1 deletion services/nvpair-cluster-manager/httpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 := ""
Expand All @@ -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":
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -603,6 +660,7 @@ func (m *Manager) supersedePendingInboundLocked(fromNodeUUID, keepInviteID strin
superseded = append(superseded, supersededInboundInvite{
invite: canceled,
inviterAddr: inviterAddr,
signalTag: signalTag,
})
}
return superseded
Expand Down
Loading