From 6a752f66f973d1731e6ba0fc40e272f70704de59 Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Tue, 27 Oct 2026 16:29:02 -0400 Subject: [PATCH 1/6] Remove unused pairing secret export and association storage Retain only Completion authentication keys and make PIN NOOB setup avoid discarded OOB material. Require verified Completion before peer success, update PAIR callers and PIN checks, and remove unused application-key persistence and export APIs. Signed-off-by: Kaylee Lubick --- services/eap-noob/README.md | 80 +++++++++------ services/eap-noob/association.go | 63 ------------ services/eap-noob/eapnoob.go | 17 ++-- services/eap-noob/eapnoob_test.go | 97 ++++++------------- services/eap-noob/example_test.go | 41 +++----- services/eap-noob/export.go | 65 ------------- services/eap-noob/helpers.go | 19 ---- services/eap-noob/kdf.go | 20 +--- services/eap-noob/peer.go | 47 +++------ services/eap-noob/pin_test.go | 37 +++---- services/eap-noob/server.go | 47 ++------- services/eap-noob/state_test.go | 60 ++++++++++++ .../nvpair-cluster-manager/cm_unit_test.go | 79 ++++++++++++--- services/nvpair-cluster-manager/invite.go | 2 +- services/nvpair-cluster-manager/pairing.go | 4 +- services/nvpair-cluster-manager/spec.md | 33 ++++--- 16 files changed, 289 insertions(+), 422 deletions(-) delete mode 100644 services/eap-noob/association.go delete mode 100644 services/eap-noob/export.go create mode 100644 services/eap-noob/state_test.go diff --git a/services/eap-noob/README.md b/services/eap-noob/README.md index 9513b288a..6444233c5 100644 --- a/services/eap-noob/README.md +++ b/services/eap-noob/README.md @@ -5,23 +5,25 @@ SPDX-License-Identifier: Apache-2.0 # eapnoob -A Go implementation of **EAP-NOOB** (RFC 9140, "Nimble Out-of-Band Authentication -for EAP"), exposed as a transport-agnostic library. Two consumers, a `Server` and -a `Peer`, perform an authenticated key exchange bootstrapped by a single -user-assisted out-of-band (OOB) message, and afterwards derive a **shared secret -of any length**. +PAIR's pairing component implements the Initial, Waiting, and Completion +Exchanges of **EAP-NOOB** (RFC 9140, "Nimble Out-of-Band Authentication for EAP"). +The cluster-manager drives a `Server` and a `Peer` to authenticate the node +identity and certificate information exchanged during pairing. ## What it does -EAP-NOOB performs an Ephemeral ECDH key exchange that is authenticated by a short -OOB message (`PeerId`, `Noob`, `Hoob`) carried over a user-assisted channel -(QR code, NFC, blinking LED, typed code, etc.). Once both sides confirm the -exchange they reach the `Registered` state and share the association key `Kz`, -from which `Export` derives arbitrary-length secrets. +EAP-NOOB authenticates an ephemeral ECDH exchange using an out-of-band (OOB) +secret, `Noob`. PAIR currently obtains that secret from a six-digit PIN carried +by the user from the inviter to the joiner. The Completion MACs bind the secret +to the exchanged `ServerInfo` and `PeerInfo`, including both nodes' certificates. +After successful Completion, both roles reach `Registered` and the +cluster-manager pins the authenticated certificates in its own trusted-node +store. -The library is **byte-in / byte-out**: it produces and consumes the EAP-NOOB JSON -messages but does not implement EAP/RADIUS framing or networking. You wire up the -in-band transport and relay the OOB message yourself. +The component consumes and produces EAP-NOOB JSON messages. The cluster-manager +owns the transport, PIN validation and encoding, durable trust, and membership. +This module serves PAIR; it is not a general-purpose authentication or +application-secret export library. ## Scope @@ -31,32 +33,38 @@ Implemented: Exchange, Completion Exchange. - Cryptosuite 1 (Curve25519 / SHA-256, mandatory) and cryptosuite 2 (NIST P-256 / SHA-256, recommended). -- NIST SP 800-56C one-step KDF and the `MSK`/`EMSK`/`AMSK`/`Kms`/`Kmp`/`Kz` - outputs (RFC 9140, Table 5). -- Persistent association store (`Store` interface + in-memory implementation). -- Arbitrary-length secret export via HKDF-SHA256 over `Kz`. +- NIST SP 800-56C one-step KDF, retaining the Completion MAC keys `Kms` and + `Kmp` at their RFC 9140 Table 5 offsets. +- Caller-supplied `Noob` for PAIR's PIN flow, plus the full server-to-peer + `OOBMessage` path reserved for PAIR's planned high-entropy pairing codes. - Error notifications (Type=0) and the RFC error codes. -Not implemented (reserved for a future phase): the Reconnect / rekeying exchange -(message types 7-9, KeyingMode 1-3) and AAA roaming. +Reconnect / rekeying exchanges (message types 7-9, KeyingMode 1-3) and AAA +roaming are not implemented. Completed associations and application keys are +not persisted by this module. ## Usage ```go -srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 1}, nil) -peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 1}, nil) +srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: serverInfo}) +peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: peerInfo}) // 1. Initial Exchange: relay each side's Outcome.Send to the other until done. -// (server.Start() produces the first message.) +// (srv.Start() produces the first message.) -// 2. OOB Step: relay the OOB message over a user-assisted channel. -oob, _ := peer.OOBOutput() -_ = srv.OOBInput(oob) +// 2. The cluster-manager validates and encodes the user-carried PIN as the +// same 16-byte noob on both sides. No OOBMessage is sent in the PIN flow. +if err := srv.OOBOutputWith(noob); err != nil { + return err +} +if err := peer.OOBInputNoob(noob); err != nil { + return err +} // 3. Completion Exchange: relay messages again until both reach Registered. -// 4. Derive a shared secret of any length. -secret, _ := srv.Export("my-application", nil, 48) // == peer.Export(...) +// 4. After successful Completion, the cluster-manager reads srv.PeerInfo() +// and peer.ServerInfo() and pins the authenticated certificates. ``` See [`example_test.go`](example_test.go) for a complete, runnable driver loop. @@ -84,12 +92,24 @@ conversation for the Completion Exchange (call `srv.Start()` again to begin it). server-to-peer). The sending side calls `OOBOutput()` and the receiving side calls `OOBInput()`. +PAIR uses direction 2. Its PIN flow calls `Server.OOBOutputWith(noob)` to +register the inviter's secret while staying in `Waiting`, and +`Peer.OOBInputNoob(noob)` to move the joiner to `OOBReceived`. Neither method +constructs or verifies a Hoob fingerprint; transcript agreement is proved by +the Completion MACs. + +`Server.OOBOutput()` and `Peer.OOBInput(OOBMessage)` retain random `Noob` +generation and Hoob verification for the planned high-entropy OOB flow. That +flow carries the full `OOBMessage{PeerId, Noob, Hoob}` through the user-assisted +channel; it is not used by today's PIN pairing. + ## Testing ```sh go test ./... ``` -The suite runs full pairings for both cryptosuites and both OOB directions, -verifies that the server and peer derive identical exported secrets, and checks -the KDF, JWK round-trip, OOB fingerprint rejection, and MAC verification paths. +The suite exercises both cryptosuites, PAIR's caller-supplied `Noob` flow, +Completion authentication, KDF outputs, JWK round-trips, and OOB fingerprint +rejection. The cluster-manager tests cover PIN encoding and the durable trust +boundary. diff --git a/services/eap-noob/association.go b/services/eap-noob/association.go deleted file mode 100644 index 714754c03..000000000 --- a/services/eap-noob/association.go +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package eapnoob - -import "sync" - -// Association is the persistent EAP-NOOB association created by a successful -// Completion Exchange (RFC 9140, Section 3.4.1). It holds the shared key Kz -// from which application secrets are exported. -type Association struct { - PeerId string - NAI string - Cryptosuitep int - Kz []byte -} - -// Store persists registered associations. Implementations must be safe for -// concurrent use if shared between goroutines. -type Store interface { - Load(peerId string) (*Association, bool, error) - Save(a *Association) error - Delete(peerId string) error -} - -// MemoryStore is an in-memory Store suitable for tests and single-process use. -type MemoryStore struct { - mu sync.RWMutex - data map[string]*Association -} - -// NewMemoryStore returns an empty in-memory association store. -func NewMemoryStore() *MemoryStore { - return &MemoryStore{data: make(map[string]*Association)} -} - -func (m *MemoryStore) Load(peerId string) (*Association, bool, error) { - m.mu.RLock() - defer m.mu.RUnlock() - a, ok := m.data[peerId] - if !ok { - return nil, false, nil - } - cp := *a - cp.Kz = append([]byte(nil), a.Kz...) - return &cp, true, nil -} - -func (m *MemoryStore) Save(a *Association) error { - m.mu.Lock() - defer m.mu.Unlock() - cp := *a - cp.Kz = append([]byte(nil), a.Kz...) - m.data[a.PeerId] = &cp - return nil -} - -func (m *MemoryStore) Delete(peerId string) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.data, peerId) - return nil -} diff --git a/services/eap-noob/eapnoob.go b/services/eap-noob/eapnoob.go index 820dbce38..34a9a26c5 100644 --- a/services/eap-noob/eapnoob.go +++ b/services/eap-noob/eapnoob.go @@ -1,18 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Package eapnoob implements the EAP-NOOB authentication and key-derivation -// method defined in RFC 9140 ("Nimble Out-of-Band Authentication for EAP"). +// Package eapnoob implements PAIR's certificate-authenticated pairing using +// the Initial, Waiting, and Completion exchanges from EAP-NOOB (RFC 9140). // // The implementation is transport-agnostic: it consumes and produces the // EAP-NOOB message bytes (JSON objects) but does not perform EAP/RADIUS framing // or networking. A caller drives a Server and a Peer by relaying each side's -// outbound bytes to the other, and relays the single out-of-band message -// (PeerId, Noob, Hoob) through whatever user-assisted channel it chooses. +// outbound bytes to the other. PAIR supplies the out-of-band Noob from its +// user-carried PIN; a full OOBMessage is also supported for high-entropy codes. // -// Once the Completion Exchange succeeds, both sides reach the Registered state -// and share the association key Kz, from which Export derives a shared secret -// of any requested length. +// Once Completion succeeds, both sides reach Registered and the exchanged +// ServerInfo and PeerInfo are authenticated. The cluster manager owns the +// resulting certificate pins and durable membership. package eapnoob import ( @@ -92,8 +92,7 @@ type methodState struct { noobB64 json.RawMessage noobID []byte // raw 16-byte NoobId - keyM keyMaterial - registered *Association + keyM keyMaterial } func (ms *methodState) deriveCompletionKeys() { diff --git a/services/eap-noob/eapnoob_test.go b/services/eap-noob/eapnoob_test.go index a323f9371..6e679fe6b 100644 --- a/services/eap-noob/eapnoob_test.go +++ b/services/eap-noob/eapnoob_test.go @@ -4,7 +4,6 @@ package eapnoob import ( - "bytes" "testing" "github.com/stretchr/testify/assert" @@ -44,11 +43,11 @@ func pair(t *testing.T, suite, dir int) (*Server, *Peer) { Cryptosuites: []int{suite}, Dirs: dir, ServerInfo: map[string]any{"ServerName": "test-server"}, - }, nil) + }) peer := NewPeer(PeerConfig{ PreferDir: dir, PeerInfo: map[string]any{"PeerName": "test-peer"}, - }, nil) + }) // Initial Exchange. driveConversation(t, srv, peer) @@ -76,57 +75,31 @@ func pair(t *testing.T, suite, dir int) (*Server, *Peer) { return srv, peer } -func TestPairing_DerivesMatchingSecretsAcrossSuitesAndDirections(t *testing.T) { - for _, suite := range []int{1, 2} { - for _, dir := range []int{1, 2} { - suite, dir := suite, dir - t.Run("", func(t *testing.T) { - srv, peer := pair(t, suite, dir) - - assert.Equal(t, suite, srv.Association().Cryptosuitep, "server negotiated cryptosuite %d, want %d", srv.Association().Cryptosuitep, suite) - assert.Equal(t, peer.Association().Kz, srv.Association().Kz, "Kz mismatch between server and peer") - assert.Equal(t, peer.Association().PeerId, srv.Association().PeerId, "PeerId mismatch: %q vs %q", srv.Association().PeerId, peer.Association().PeerId) - - // The exported secret must agree for several lengths and differ - // across labels/contexts. - for _, n := range []int{1, 16, 32, 100, 1000} { - sSecret, err := srv.Export("app", nil, n) - require.NoError(t, err, "server Export(%d): %v", n, err) - pSecret, err := peer.Export("app", nil, n) - require.NoError(t, err, "peer Export(%d): %v", n, err) - require.Len(t, sSecret, n, "export length %d, want %d", len(sSecret), n) - assert.Equal(t, pSecret, sSecret, "exported secret mismatch at length %d", n) - } - - a, err := srv.Export("label-a", nil, 32) - require.NoError(t, err, "export label-a") - b, err := srv.Export("label-b", nil, 32) - require.NoError(t, err, "export label-b") - assert.NotEqual(t, b, a, "different labels produced identical secrets") - c1, err := srv.Export("app", []byte("ctx1"), 32) - require.NoError(t, err, "export ctx1") - c2, err := srv.Export("app", []byte("ctx2"), 32) - require.NoError(t, err, "export ctx2") - assert.NotEqual(t, c2, c1, "different contexts produced identical secrets") - }) - } +func TestPairing_AuthenticatesIdentityAcrossSuitesAndDirections(t *testing.T) { + test := func(name string, suite, dir int) { + t.Run(name, func(t *testing.T) { + srv, peer := pair(t, suite, dir) + assert.Equal(t, suite, srv.csp) + assert.Equal(t, suite, peer.csp) + assert.Equal(t, peer.peerId, srv.peerId) + assert.JSONEq(t, `{"PeerName":"test-peer"}`, string(srv.PeerInfo())) + assert.JSONEq(t, `{"ServerName":"test-server"}`, string(peer.ServerInfo())) + }) } -} - -func TestExport_RejectsUnregisteredPairing(t *testing.T) { - peer := NewPeer(PeerConfig{}, nil) - _, err := peer.Export("app", nil, 16) - assert.ErrorIs(t, err, ErrNotRegistered, "Export before registration") + test("suite 1 server-to-peer", 1, 2) + test("suite 2 server-to-peer", 2, 2) + test("suite 1 peer-to-server", 1, 1) + test("suite 2 peer-to-server", 2, 1) } func TestPairing_RejectsTamperedMAC(t *testing.T) { - srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 1}, nil) - peer := NewPeer(PeerConfig{PreferDir: 1}, nil) + srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) driveConversation(t, srv, peer) - oob, err := peer.OOBOutput() + oob, err := srv.OOBOutput() require.NoError(t, err, "OOBOutput:") - require.NoError(t, srv.OOBInput(oob), "OOBInput:") + require.NoError(t, peer.OOBInput(oob), "OOBInput:") // Run the Completion handshake manually and corrupt MACp before the server // verifies it. @@ -134,7 +107,11 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { require.NoError(t, err, "start completion discovery") resp1, err := peer.Receive(req1) // Type=1 response require.NoError(t, err, "peer discovery:") - req6, err := srv.Receive(resp1.Send) // Type=6 request (MACs) + req5, err := srv.Receive(resp1.Send) // Type=5 request (NoobId) + require.NoError(t, err, "server NoobId request:") + resp5, err := peer.Receive(req5.Send) + require.NoError(t, err, "peer NoobId response:") + req6, err := srv.Receive(resp5.Send) // Type=6 request (MACs) require.NoError(t, err, "server completion request:") resp6, err := peer.Receive(req6.Send) // Type=6 response (MACp) require.NoError(t, err, "peer completion:") @@ -153,8 +130,8 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { } func TestPairing_RejectsIncorrectOutOfBandFingerprint(t *testing.T) { - srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 2}, nil) - peer := NewPeer(PeerConfig{PreferDir: 2}, nil) + srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) driveConversation(t, srv, peer) oob, err := srv.OOBOutput() @@ -164,26 +141,6 @@ func TestPairing_RejectsIncorrectOutOfBandFingerprint(t *testing.T) { assert.Equal(t, StateWaiting, peer.State(), "peer state %s after rejected OOB, want WaitingForOOB", peer.State()) } -func TestOneStepKDF_DerivesRepeatableKeys(t *testing.T) { - z := bytes.Repeat([]byte{0x01}, 32) - np := bytes.Repeat([]byte{0x02}, 32) - ns := bytes.Repeat([]byte{0x03}, 32) - noob := bytes.Repeat([]byte{0x04}, 16) - - km1 := deriveCompletion(z, np, ns, noob) - km2 := deriveCompletion(z, np, ns, noob) - - assert.Equal(t, km2.MSK, km1.MSK, "KDF not deterministic") - assert.Equal(t, km2.Kz, km1.Kz, "KDF not deterministic") - assert.Len(t, km1.MSK, 64, "MSK length") - assert.Len(t, km1.EMSK, 64, "EMSK length") - assert.Len(t, km1.AMSK, 64, "AMSK length") - assert.Len(t, km1.MethodID, 32, "MethodID length") - assert.Len(t, km1.Kms, 32, "Kms length") - assert.Len(t, km1.Kmp, 32, "Kmp length") - assert.Len(t, km1.Kz, 32, "Kz length") -} - func TestJWK_RoundTripsPublicKeys(t *testing.T) { test := func(name string, id int) { t.Run(name, func(t *testing.T) { diff --git a/services/eap-noob/example_test.go b/services/eap-noob/example_test.go index 0e51c2a40..b44f009e1 100644 --- a/services/eap-noob/example_test.go +++ b/services/eap-noob/example_test.go @@ -4,7 +4,6 @@ package eapnoob_test import ( - "bytes" "fmt" "eapnoob" @@ -39,24 +38,25 @@ func run(srv *eapnoob.Server, peer *eapnoob.Peer) error { } } -// Example shows a full EAP-NOOB pairing followed by exporting a shared secret of -// arbitrary length. Here the OOB message travels peer-to-server; the caller is -// responsible for relaying it over a user-assisted channel (QR code, etc.). +// Example shows PAIR's server-to-peer pairing with a caller-supplied Noob. +// The cluster manager validates and encodes the user-carried PIN before calling +// these methods, then pins the authenticated identity after Completion. func Example() { - srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 1}, nil) - peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 1}, nil) + srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: map[string]any{"name": "inviter"}}) + peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"name": "joiner"}}) // Initial Exchange. if err := run(srv, peer); err != nil { panic(err) } - // OOB Step: relay (PeerId, Noob, Hoob) from peer to server out of band. - oob, err := peer.OOBOutput() - if err != nil { + // A fixture representing PIN 000123, already validated and encoded by PAIR. + noob := make([]byte, 16) + noob[15] = 123 + if err := srv.OOBOutputWith(noob); err != nil { panic(err) } - if err := srv.OOBInput(oob); err != nil { + if err := peer.OOBInputNoob(noob); err != nil { panic(err) } @@ -65,22 +65,11 @@ func Example() { panic(err) } - // Both sides now share an association and can derive a secret of any length. - const secretLen = 48 - serverSecret, err := srv.Export("my-application", nil, secretLen) - if err != nil { - panic(err) - } - peerSecret, err := peer.Export("my-application", nil, secretLen) - if err != nil { - panic(err) - } - - fmt.Println("registered:", srv.State() == eapnoob.StateRegistered) - fmt.Println("secret length:", len(serverSecret)) - fmt.Println("secrets match:", bytes.Equal(serverSecret, peerSecret)) + fmt.Println("registered:", srv.State() == eapnoob.StateRegistered && peer.State() == eapnoob.StateRegistered) + fmt.Println("authenticated inviter:", string(peer.ServerInfo())) + fmt.Println("authenticated joiner:", string(srv.PeerInfo())) // Output: // registered: true - // secret length: 48 - // secrets match: true + // authenticated inviter: {"name":"inviter"} + // authenticated joiner: {"name":"joiner"} } diff --git a/services/eap-noob/export.go b/services/eap-noob/export.go deleted file mode 100644 index 7b6708207..000000000 --- a/services/eap-noob/export.go +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package eapnoob - -import ( - "crypto/hmac" - "crypto/sha256" - "errors" - "hash" -) - -// ErrNotRegistered is returned when a secret export is attempted before a -// successful Completion Exchange has established the association key Kz. -var ErrNotRegistered = errors.New("eapnoob: no registered association") - -// 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 -// context, which lets consumers exchange an arbitrary-length secret. -// -// The derivation is HKDF (RFC 5869) with SHA-256, using Kz as input keying -// material and the label and context as the info parameter. label and context -// are domain-separated by a length prefix so distinct inputs cannot collide. -func (a *Association) Export(label string, context []byte, length int) ([]byte, error) { - if a == nil || len(a.Kz) == 0 { - return nil, ErrNotRegistered - } - if length < 0 { - return nil, errors.New("eapnoob: negative export length") - } - info := make([]byte, 0, 2+len(label)+len(context)) - info = append(info, byte(len(label)>>8), byte(len(label))) - info = append(info, label...) - info = append(info, context...) - return hkdf(sha256.New, a.Kz, nil, info, length) -} - -// hkdf implements HKDF-Extract followed by HKDF-Expand (RFC 5869). -func hkdf(newHash func() hash.Hash, ikm, salt, info []byte, length int) ([]byte, error) { - h := newHash() - hashLen := h.Size() - if length > 255*hashLen { - return nil, errors.New("eapnoob: requested export length too large") - } - if salt == nil { - salt = make([]byte, hashLen) - } - - extract := hmac.New(newHash, salt) - extract.Write(ikm) - prk := extract.Sum(nil) - - out := make([]byte, 0, length) - var prev []byte - for i := 1; len(out) < length; i++ { - exp := hmac.New(newHash, prk) - exp.Write(prev) - exp.Write(info) - exp.Write([]byte{byte(i)}) - prev = exp.Sum(nil) - out = append(out, prev...) - } - return out[:length], nil -} diff --git a/services/eap-noob/helpers.go b/services/eap-noob/helpers.go index 5edf238a8..e8853ad51 100644 --- a/services/eap-noob/helpers.go +++ b/services/eap-noob/helpers.go @@ -106,25 +106,6 @@ func oobOutput(ms *methodState, dir int) (OOBMessage, error) { }, nil } -// oobOutputWith builds the OOB message for the given direction using a -// caller-supplied 16-byte secret nonce Noob instead of generating a fresh -// random one. It is the low-entropy variant used when the Noob is carried out -// of band by a human (e.g. a six-digit PIN encoded into the 16 bytes); the -// returned Noob/Hoob need not be transmitted, since the peer recomputes them -// locally from the same PIN and the transcript. -func oobOutputWith(ms *methodState, dir int, noob []byte) (OOBMessage, error) { - if len(noob) != 16 { - return OOBMessage{}, fmt.Errorf("eapnoob: Noob must be 16 bytes") - } - ms.setNoob(noob) - hoob := ms.suite.computeHoob(dir, ms.in) - return OOBMessage{ - PeerId: ms.peerId, - Noob: b64Encode(noob), - Hoob: b64Encode(hoob), - }, nil -} - // oobInputNoob records a caller-supplied 16-byte Noob without an OOBMessage // envelope. Unlike oobInput it does not verify a received Hoob (there is none // to verify for a human-carried Noob); transcript agreement is instead proven diff --git a/services/eap-noob/kdf.go b/services/eap-noob/kdf.go index e12a53c9f..793445433 100644 --- a/services/eap-noob/kdf.go +++ b/services/eap-noob/kdf.go @@ -12,15 +12,10 @@ import ( // Section 3.5, Table 4). var algorithmID = []byte("EAP-NOOB") -// keyMaterial holds the labeled slices of the KDF output (RFC 9140, Table 5). +// keyMaterial retains only the Completion authentication keys (RFC 9140, Table 5). type keyMaterial struct { - MSK []byte // 64 - EMSK []byte // 64 - AMSK []byte // 64 - MethodID []byte // 32 - Kms []byte // 32 - Kmp []byte // 32 - Kz []byte // 32 (Completion / KeyingMode 3 only) + Kms []byte // 32 + Kmp []byte // 32 } // oneStepKDF implements the NIST SP 800-56C one-step KDF with a 4-byte @@ -61,12 +56,7 @@ func completionFixedInfo(np, ns, noob []byte) []byte { func deriveCompletion(z, np, ns, noob []byte) keyMaterial { out := oneStepKDF(z, completionFixedInfo(np, ns, noob), 320) return keyMaterial{ - MSK: out[0:64], - EMSK: out[64:128], - AMSK: out[128:192], - MethodID: out[192:224], - Kms: out[224:256], - Kmp: out[256:288], - Kz: out[288:320], + Kms: out[224:256], + Kmp: out[256:288], } } diff --git a/services/eap-noob/peer.go b/services/eap-noob/peer.go index d1d5042ba..0122e8f50 100644 --- a/services/eap-noob/peer.go +++ b/services/eap-noob/peer.go @@ -32,9 +32,9 @@ type PeerConfig struct { // Peer drives the EAP peer side of one EAP-NOOB association. type Peer struct { - cfg PeerConfig - store Store - state State + cfg PeerConfig + state State + completionVerified bool methodState @@ -43,9 +43,8 @@ type Peer struct { dirp int } -// NewPeer creates a peer using the given configuration and association store. -// If store is nil an in-memory store is used. -func NewPeer(cfg PeerConfig, store Store) *Peer { +// NewPeer creates a peer for one pairing using the given configuration. +func NewPeer(cfg PeerConfig) *Peer { if cfg.NAI == "" { cfg.NAI = defaultNAI } @@ -55,10 +54,7 @@ func NewPeer(cfg PeerConfig, store Store) *Peer { if len(cfg.Cryptosuites) == 0 { cfg.Cryptosuites = []int{1, 2} } - if store == nil { - store = NewMemoryStore() - } - p := &Peer{cfg: cfg, store: store, state: StateUnregistered} + p := &Peer{cfg: cfg, state: StateUnregistered} p.nai = cfg.NAI return p } @@ -71,16 +67,6 @@ func (p *Peer) State() State { return p.state } // authenticated once the association reaches Registered. func (p *Peer) ServerInfo() json.RawMessage { return p.in.ServerInfo } -// Association returns the registered association once the Completion Exchange -// has succeeded, or nil. -func (p *Peer) Association() *Association { return p.registered } - -// Export derives an arbitrary-length shared secret from the registered -// association (see Association.Export). -func (p *Peer) Export(label string, context []byte, length int) ([]byte, error) { - return p.registered.Export(label, context, length) -} - // Receive processes one inbound message from the server and returns the next // action. func (p *Peer) Receive(in []byte) (Outcome, error) { @@ -91,6 +77,7 @@ func (p *Peer) Receive(in []byte) (Outcome, error) { if wm.EAP != "" { return p.onResult(wm) } + p.completionVerified = false if wm.ErrorCode != nil { return Outcome{State: p.state, Done: true, Err: &ProtocolError{Code: *wm.ErrorCode, Info: wm.ErrorInfo}}, nil } @@ -117,20 +104,15 @@ func (p *Peer) Receive(in []byte) (Outcome, error) { func (p *Peer) onResult(wm *wireMessage) (Outcome, error) { if wm.EAP == eapSuccess { - assoc := &Association{ - PeerId: p.peerId, - NAI: p.nai, - Cryptosuitep: p.csp, - Kz: append([]byte(nil), p.keyM.Kz...), - } - if err := p.store.Save(assoc); err != nil { - return Outcome{}, err + if !p.completionVerified { + return p.fail(ErrStateMismatch, "success before verified Completion") } - p.registered = assoc + p.completionVerified = false p.state = StateRegistered return Outcome{State: p.state, Done: true, Success: true}, nil } // EAP-Failure: the conversation ended without registration. + p.completionVerified = false return Outcome{State: p.state, Done: true}, nil } @@ -342,6 +324,7 @@ func (p *Peer) onCompletionRequest(wm *wireMessage) (Outcome, error) { if err != nil { return Outcome{}, err } + p.completionVerified = true return Outcome{Send: b, State: p.state}, nil } @@ -376,9 +359,8 @@ func (p *Peer) OOBInput(msg OOBMessage) error { // OOBInputNoob accepts a caller-supplied 16-byte Noob for the server-to-peer // direction (e.g. derived from a user-carried PIN) without an OOBMessage -// envelope, recomputing the fingerprint locally rather than verifying a -// received Hoob. On success the peer moves to the OOB Received state. Transcript -// agreement is proven later by the Completion Exchange MACs. +// envelope or Hoob computation. On success the peer moves to OOB Received. +// Transcript agreement is proven later by the Completion Exchange MACs. func (p *Peer) OOBInputNoob(noob []byte) error { if p.state != StateWaiting { return fmt.Errorf("eapnoob: OOBInputNoob requires Waiting state, have %s", p.state) @@ -394,6 +376,7 @@ func (p *Peer) OOBInputNoob(noob []byte) error { } func (p *Peer) fail(code int, info string) (Outcome, error) { + p.completionVerified = false b, err := errorMessage(code, info) if err != nil { return Outcome{}, err diff --git a/services/eap-noob/pin_test.go b/services/eap-noob/pin_test.go index bff669bfe..c2db7a77b 100644 --- a/services/eap-noob/pin_test.go +++ b/services/eap-noob/pin_test.go @@ -42,40 +42,35 @@ func driveAllowFail(srv *Server, peer *Peer) bool { // PIN flow takes): the server supplies the Noob via OOBOutputWith and the peer // records the same Noob via OOBInputNoob, with no OOBMessage/Hoob envelope. func TestPairing_AcceptsMatchingCallerProvidedNoob(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) + srv := NewServer(ServerConfig{Dirs: 2, ServerInfo: map[string]any{"role": "server"}}) + peer := NewPeer(PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"role": "peer"}}) driveConversation(t, srv, peer) require.Equal(t, StateWaiting, srv.State(), "after Initial: server=%s peer=%s, want WaitingForOOB", srv.State(), peer.State()) require.Equal(t, StateWaiting, peer.State(), "after Initial: server=%s peer=%s, want WaitingForOOB", srv.State(), peer.State()) noob := bytes.Repeat([]byte{0xAB}, 16) - _, err := srv.OOBOutputWith(noob) - require.NoError(t, err, "server OOBOutputWith:") + require.NoError(t, srv.OOBOutputWith(noob), "server OOBOutputWith:") + assert.Equal(t, StateWaiting, srv.State()) require.NoError(t, peer.OOBInputNoob(noob), "peer OOBInputNoob:") require.Equal(t, StateOOBReceived, peer.State(), "peer state %s, want OOBReceived", peer.State()) driveConversation(t, srv, peer) require.Equal(t, StateRegistered, srv.State(), "after Completion: server=%s peer=%s, want Registered", srv.State(), peer.State()) require.Equal(t, StateRegistered, peer.State(), "after Completion: server=%s peer=%s, want Registered", srv.State(), peer.State()) - assert.Equal(t, peer.Association().Kz, srv.Association().Kz, "Kz mismatch between server and peer") - s, err := srv.Export("cluster-mtls", nil, 32) - require.NoError(t, err, "server Export:") - p, err := peer.Export("cluster-mtls", nil, 32) - require.NoError(t, err, "peer Export:") - assert.Equal(t, p, s, "exported secret mismatch") + assert.JSONEq(t, `{"role":"server"}`, string(peer.ServerInfo())) + assert.JSONEq(t, `{"role":"peer"}`, string(srv.PeerInfo())) } // TestMismatchedInjectedNoobFails is the wrong-PIN case: the two sides inject // different Noobs, so the Completion MACs cannot agree and pairing must fail. func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}, nil) - peer := NewPeer(PeerConfig{PreferDir: 2}, nil) + srv := NewServer(ServerConfig{Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) driveConversation(t, srv, peer) - _, err := srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)) - require.NoError(t, err, "server OOBOutputWith:") + require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") require.NoError(t, peer.OOBInputNoob(bytes.Repeat([]byte{0x02}, 16)), "peer OOBInputNoob:") assert.True(t, driveAllowFail(srv, peer), "expected the Completion Exchange to fail with mismatched Noobs") @@ -94,15 +89,14 @@ func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { // reports it this way, the cluster manager would silently degrade a wrong PIN to // a generic failure (empty reason) instead of reason:"incorrect-pin". func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}, nil) - peer := NewPeer(PeerConfig{PreferDir: 2}, nil) + srv := NewServer(ServerConfig{Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) // Initial Exchange. driveConversation(t, srv, peer) // Server-to-peer OOB with MISMATCHED Noobs — the wrong-PIN condition. - _, err := srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)) - require.NoError(t, err, "server OOBOutputWith:") + require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") require.NoError(t, peer.OOBInputNoob(bytes.Repeat([]byte{0x02}, 16)), "peer OOBInputNoob:") // Drive the Completion Exchange exactly as the joiner does: the server @@ -132,9 +126,8 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { // TestOOBOutputWithRejectsBadLength guards the 16-byte contract. func TestOOBOutputWith_RejectsBadLength(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}, nil) - peer := NewPeer(PeerConfig{PreferDir: 2}, nil) + srv := NewServer(ServerConfig{Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) driveConversation(t, srv, peer) - _, err := srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 8)) - require.Error(t, err, "expected OOBOutputWith to reject an 8-byte Noob") + require.Error(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 8)), "expected OOBOutputWith to reject an 8-byte Noob") } diff --git a/services/eap-noob/server.go b/services/eap-noob/server.go index cd95d35e4..d487b6f03 100644 --- a/services/eap-noob/server.go +++ b/services/eap-noob/server.go @@ -33,7 +33,6 @@ type ServerConfig struct { // Server drives the EAP server side of one EAP-NOOB association. type Server struct { cfg ServerConfig - store Store state State methodState @@ -44,9 +43,8 @@ type Server struct { dirp int } -// NewServer creates a server using the given configuration and association -// store. If store is nil an in-memory store is used. -func NewServer(cfg ServerConfig, store Store) *Server { +// NewServer creates a server for one pairing using the given configuration. +func NewServer(cfg ServerConfig) *Server { if len(cfg.Versions) == 0 { cfg.Versions = []int{1} } @@ -56,10 +54,7 @@ func NewServer(cfg ServerConfig, store Store) *Server { if cfg.Dirs == 0 { cfg.Dirs = 3 } - if store == nil { - store = NewMemoryStore() - } - return &Server{cfg: cfg, store: store, state: StateUnregistered} + return &Server{cfg: cfg, state: StateUnregistered} } // State returns the server's current association state. @@ -70,16 +65,6 @@ func (s *Server) State() State { return s.state } // the association reaches Registered. func (s *Server) PeerInfo() json.RawMessage { return s.in.PeerInfo } -// Association returns the registered association once the Completion Exchange -// has succeeded, or nil. -func (s *Server) Association() *Association { return s.registered } - -// Export derives an arbitrary-length shared secret from the registered -// association (see Association.Export). -func (s *Server) Export(label string, context []byte, length int) ([]byte, error) { - return s.registered.Export(label, context, length) -} - // Start begins a new EAP conversation by producing the first EAP-NOOB request // (Type=1, PeerId/PeerState discovery). func (s *Server) Start() ([]byte, error) { @@ -377,16 +362,6 @@ func (s *Server) onCompletion(wm *wireMessage) (Outcome, error) { return s.fail(ErrHMACVerificationFailed, "MACp verification failed") } - assoc := &Association{ - PeerId: s.peerId, - NAI: s.nai, - Cryptosuitep: s.csp, - Kz: append([]byte(nil), s.keyM.Kz...), - } - if err := s.store.Save(assoc); err != nil { - return Outcome{}, err - } - s.registered = assoc s.state = StateRegistered return Outcome{Send: resultBytes(eapSuccess), State: s.state, Done: true, Success: true}, nil } @@ -405,19 +380,17 @@ func (s *Server) OOBOutput() (OOBMessage, error) { return oobOutput(&s.methodState, 2) } -// OOBOutputWith produces the server-to-peer OOB material using a caller-supplied -// 16-byte Noob (e.g. derived from a user-carried PIN) instead of a randomly -// generated one. Preconditions match OOBOutput. The returned OOBMessage is -// informational: the Noob is caller-controlled and the peer recomputes Hoob -// locally, so nothing here needs to be transmitted in band. -func (s *Server) OOBOutputWith(noob []byte) (OOBMessage, error) { +// OOBOutputWith records a caller-supplied 16-byte Noob for server-to-peer OOB. +// The server stays Waiting, without building an OOBMessage or computing Hoob. +// Preconditions match OOBOutput; Completion authenticates the transcript. +func (s *Server) OOBOutputWith(noob []byte) error { if s.state != StateWaiting { - return OOBMessage{}, fmt.Errorf("eapnoob: OOBOutputWith requires Waiting state, have %s", s.state) + return fmt.Errorf("eapnoob: OOBOutputWith requires Waiting state, have %s", s.state) } if s.dirp != 2 && s.dirp != 3 { - return OOBMessage{}, fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") + return fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") } - return oobOutputWith(&s.methodState, 2, noob) + return oobInputNoob(&s.methodState, noob) } // OOBInput accepts the out-of-band message for the peer-to-server direction and diff --git a/services/eap-noob/state_test.go b/services/eap-noob/state_test.go new file mode 100644 index 000000000..d1f9fd0a1 --- /dev/null +++ b/services/eap-noob/state_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package eapnoob + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPeer_RejectsSuccessBeforeVerifiedCompletion(t *testing.T) { + test := func(name string, state State) { + t.Run(name, func(t *testing.T) { + srv := NewServer(ServerConfig{Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) + if state != StateUnregistered { + driveConversation(t, srv, peer) + } + if state == StateOOBReceived { + require.NoError(t, peer.OOBInputNoob(make([]byte, 16))) + } + require.Equal(t, state, peer.State()) + + out, err := peer.Receive(resultBytes(eapSuccess)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrStateMismatch, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.Equal(t, state, peer.State()) + }) + } + test("before initial exchange", StateUnregistered) + test("before OOB input", StateWaiting) + test("after OOB input without Completion", StateOOBReceived) +} + +func TestOOB_RejectsNoobOutsideWaiting(t *testing.T) { + test := func(name string, registered bool) { + t.Run(name, func(t *testing.T) { + srv := NewServer(ServerConfig{Dirs: 2}) + peer := NewPeer(PeerConfig{PreferDir: 2}) + wantState := StateUnregistered + if registered { + srv, peer = pair(t, 2, 2) + wantState = StateRegistered + } + + assert.Error(t, srv.OOBOutputWith(make([]byte, 16))) + assert.Error(t, peer.OOBInputNoob(make([]byte, 16))) + assert.Equal(t, wantState, srv.State()) + assert.Equal(t, wantState, peer.State()) + }) + } + test("before initial exchange", false) + test("after successful Completion", true) +} diff --git a/services/nvpair-cluster-manager/cm_unit_test.go b/services/nvpair-cluster-manager/cm_unit_test.go index bac23ddb1..25067e585 100644 --- a/services/nvpair-cluster-manager/cm_unit_test.go +++ b/services/nvpair-cluster-manager/cm_unit_test.go @@ -4,11 +4,13 @@ package main import ( - "math/big" + "bytes" + "encoding/json" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,23 +36,70 @@ func TestIdentity_RejectsReloadWhenPrivateKeyIsMissing(t *testing.T) { require.Error(t, err, "expected a loud failure when identity.json exists but the key is gone") } -func TestPIN_RoundTripsThroughNoobEncoding(t *testing.T) { - test := func(name, pin string) { +func TestPIN_EncodesExactNoobBytes(t *testing.T) { + test := func(name, pin string, want [16]byte) { t.Run(name, func(t *testing.T) { - noob := noobFromPIN(pin) - require.Len(t, noob, 16, "noob length") - want := new(big.Int) - want.SetString(pin, 10) - require.Equal(t, want.String(), new(big.Int).SetBytes(noob).String(), "noob decodes incorrectly") + assert.Equal(t, want[:], noobFromPIN(pin)) }) } - test("all zeroes", "000000") - test("leading zeroes", "000123") - test("ordinary PIN", "402199") - test("maximum PIN", "999999") + test("all zeroes", "000000", [16]byte{}) + test("leading zeroes", "000123", [16]byte{15: 0x7b}) + test("ordinary PIN", "402199", [16]byte{13: 0x06, 14: 0x23, 15: 0x17}) + test("maximum PIN", "999999", [16]byte{13: 0x0f, 14: 0x42, 15: 0x3f}) +} - gp, noob, err := generatePIN() +func TestGeneratePIN_ReturnsMatchingNoob(t *testing.T) { + pin, noob, err := generatePIN() require.NoError(t, err, "generatePIN") - require.Regexp(t, pinPattern, gp, "generated PIN") - require.Equal(t, string(noobFromPIN(gp)), string(noob), "generatePIN's noob does not match noobFromPIN of its PIN") + assert.Regexp(t, pinPattern, pin) + assert.Equal(t, noobFromPIN(pin), noob) +} + +func TestRespondToInvite_RejectsMalformedPIN(t *testing.T) { + test := func(name, pinJSON string) { + t.Run(name, func(t *testing.T) { + m := newTestManager(t) + var response bytes.Buffer + m.codec = NewCodec(&response) + m.putInvite(&Invite{InviteID: "inv-pin", State: inviteStatePending}) + m.putSession(&pairingSession{inviteID: "inv-pin", role: roleJoiner}) + params, err := json.Marshal(struct { + InviteID string `json:"inviteId"` + Accept bool `json:"accept"` + Pin json.RawMessage `json:"pin,omitempty"` + }{InviteID: "inv-pin", Accept: true, Pin: json.RawMessage(pinJSON)}) + require.NoError(t, err) + id := json.RawMessage("1") + + m.handleRespondToInvite(&Message{ID: &id, Params: params}) + + var reply Message + require.NoError(t, json.Unmarshal(response.Bytes(), &reply)) + require.NotNil(t, reply.Error) + assert.Equal(t, codeInvalidParams, reply.Error.Code) + var data struct { + Field string `json:"field"` + } + require.NoError(t, json.Unmarshal(reply.Error.Data, &data)) + assert.Equal(t, "pin", data.Field) + invite, ok := m.getInvite("inv-pin") + require.True(t, ok) + assert.Equal(t, inviteStatePending, invite.State) + _, ok = m.getSession("inv-pin") + assert.True(t, ok, "malformed PIN must leave the pending pairing available") + }) + } + test("missing PIN", "") + test("null PIN", "null") + test("empty PIN", `""`) + test("five digits", `"00123"`) + test("seven digits", `"0000123"`) + test("alphabetic character", `"00012a"`) + test("leading space", `" 00123"`) + test("trailing space", `"00123 "`) + test("positive sign", `"+00123"`) + test("negative sign", `"-00123"`) + test("decimal point", `"001.23"`) + test("non-ASCII digits", `"000123"`) + test("trailing newline", `"00123\n"`) } diff --git a/services/nvpair-cluster-manager/invite.go b/services/nvpair-cluster-manager/invite.go index 388acef19..a0541864d 100644 --- a/services/nvpair-cluster-manager/invite.go +++ b/services/nvpair-cluster-manager/invite.go @@ -313,7 +313,7 @@ func (m *Manager) runInitialExchange(inviteID, target, cid0 string, sessGen0 uin if err != nil { return sess, "", fmt.Errorf("generate pin: %w", err) } - if _, err := server.OOBOutputWith(noob); err != nil { + if err := server.OOBOutputWith(noob); err != nil { return sess, "", fmt.Errorf("inject pin noob: %w", err) } return sess, pin, nil diff --git a/services/nvpair-cluster-manager/pairing.go b/services/nvpair-cluster-manager/pairing.go index 218d14678..611092e40 100644 --- a/services/nvpair-cluster-manager/pairing.go +++ b/services/nvpair-cluster-manager/pairing.go @@ -140,13 +140,13 @@ func noobFromPIN(pin string) []byte { // newPairingServer builds the inviter-side EAP-NOOB Server, server-to-peer OOB // only (Dirs=2), carrying this node's PairingInfo as ServerInfo. func newPairingServer(info map[string]any) *eapnoob.Server { - return eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: info}, nil) + return eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: info}) } // newPairingPeer builds the joiner-side EAP-NOOB Peer preferring server-to-peer // OOB (PreferDir=2), carrying this node's PairingInfo as PeerInfo. func newPairingPeer(info map[string]any) *eapnoob.Peer { - return eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: info}, nil) + return eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: info}) } // pairingRole fixes which EAP-NOOB role a session plays for its whole lifetime. diff --git a/services/nvpair-cluster-manager/spec.md b/services/nvpair-cluster-manager/spec.md index c86534f57..a3da41a32 100644 --- a/services/nvpair-cluster-manager/spec.md +++ b/services/nvpair-cluster-manager/spec.md @@ -44,9 +44,9 @@ Because membership **is** trust, this service is also the **trust fabric** for t - **Membership persistence (closed)**: confirmed members, pins, active admission, removal proofs, and teardown intent survive restart; in-flight pairing sessions remain transient. - **`cluster_id` semantics on accept (closed)**: a joiner adopts the inviter's cluster id after the authenticated pairing commits; a founder mints a UUID v4. - **Who writes `cluster_id` (closed)**: Cluster Manager emits `cluster:identity-changed`; the Broker persists the UI setting. Cluster Manager separately persists admission state required for authorization and crash recovery. -- **Authentication — decided**: the inter-node interface is **mTLS over HTTPS**, authenticated against the trusted-node store this service owns (§7.4). The trust model is **self-signed leaf certs pinned at the join handshake**, keyed by a **stable per-node UUID** rather than hostname — see §7.4 for the rationale and the on-disk layout. The pinning is bootstrapped by an **EAP-NOOB (RFC 9140) pairing authenticated by a user-carried PIN** (the in-tree `eap-noob` library). Residual decisions: +- **Authentication — decided**: the inter-node interface is **mTLS over HTTPS**, authenticated against the trusted-node store this service owns (§7.4). The trust model is **self-signed leaf certs pinned at the join handshake**, keyed by a **stable per-node UUID** rather than hostname — see §7.4 for the rationale and the on-disk layout. The pinning is bootstrapped by an **EAP-NOOB (RFC 9140) pairing authenticated by a user-carried PIN** (the in-tree `eap-noob` pairing component). Residual decisions: - **⚠️ Six-digit PIN is temporary security debt — MUST UPGRADE before production cross-node trust**: EAP-NOOB's security depends on the out-of-band secret (`Noob`) being high-entropy, because the in-band Completion Exchange exposes `NoobId = H("NoobId", Noob)` and the MACs over the transcript. A six-digit PIN (~20 bits) is therefore **offline-brute-forceable by an active man-in-the-middle** on the LAN: an attacker who relays the in-band exchange can recover the PIN from `NoobId`, derive the keys, and substitute its own cert. The PIN as specified defends against **passive eavesdroppers and accidental/unauthorized joins**, not a determined active MITM. This is accepted temporarily to ship the flow; it must be replaced with a high-entropy OOB value (a copy/paste pairing code or QR encoding a high-entropy `Noob`, ~16–32 bytes) before the cluster trust is relied on. - - **Library delta the PIN needs**: today `eap-noob` random-generates the 16-byte `Noob` and its OOB API exchanges a full `OOBMessage{PeerId, Noob, Hoob}`. The six-digit design needs **one** small additive change — a caller-injected `Noob` on each role (`Server.OOBOutputWith` / `Peer.OOBInputNoob`) — so the human-carried digits are the only OOB input; nothing else is relayed (the joiner already holds `PeerId` and recomputes `Hoob` locally). Concrete API and the exact PIN→16-byte-`Noob` encoding are in §7.4. The delta disappears when the OOB upgrades to a high-entropy pairing code. + - **PAIR OOB API**: `eap-noob` accepts a caller-supplied 16-byte `Noob` on each role (`Server.OOBOutputWith` / `Peer.OOBInputNoob`), so the human-carried digits are the only OOB input. Completion MACs prove transcript agreement without computing or transmitting a Hoob in the PIN flow. Concrete APIs and the exact PIN→16-byte-`Noob` encoding are in §7.4. The full server-to-peer `OOBMessage{PeerId, Noob, Hoob}` path remains for the planned high-entropy pairing code. - **CA-less pinning vs a cluster CA (leaning CA-less)**: pinning each peer's self-signed leaf needs no central authority and matches the no-founder, peer-to-peer model — but it's O(n²) pins and re-pins on rotation. A shared cluster CA (the accepting node, or the cluster founder, signs joiners) would shrink trust to "verify against the cluster CA" but centralizes issuance. Starting CA-less; because certs ride in the EAP-NOOB transcript, swapping the pinned-leaf check for a CA-chain check later needs no wire change. - **Cert lifetime & rotation (open)**: proposed long-lived leaves (1–2 years) with a re-pin path when a node rotates its key (a node whose pin no longer matches is treated as untrusted until re-invited or an explicit re-pin flow runs). Exact lifetime, rotation trigger, and whether rotation can be done without a re-invite are TBD. - **Sharing the trust fabric with other services (open)**: `nvpair-workload-manager` and `nvpair-errors` peer-sync also need mTLS. Intended: they consume the same keypair + trusted-node store this service owns (via a shared on-disk location or a small local query API). The exact handoff (file paths the Broker passes, vs. an IPC call) is unspecified here. @@ -271,9 +271,9 @@ Example `cluster:respond-to-invite` request (joiner submits the PIN) and result: ```json { "inviteId": "inv-9f3a1c", "phase": "initial|completion", "msg": "" } ``` -- `msg` is the opaque `Outcome.Send` blob from the `eap-noob` library, relayed verbatim; the receiver feeds it to its `Server`/`Peer` and returns that side's next `Outcome.Send` in the HTTP response. `msg` may be the **empty string** — that is the Completion *kickoff* (see "two HTTP drivers" below), the only case where a request carries no EAP blob. -- **No out-of-band material is ever transmitted on this channel.** There is no `noob`/`peerId`/`hoob` field: the only OOB secret is the six-digit PIN, and it is human-carried. Each side already holds everything else — the `PeerId` is assigned during the Initial Exchange (the joiner records it from the negotiation message), and `Hoob` is recomputed locally from the transcript, so neither needs to be relayed. The joiner builds its EAP-NOOB OOB input entirely from local state + the typed PIN (§7.4). -- **`PairingInfo` — the object each side puts in EAP-NOOB `ServerInfo` (inviter) / `PeerInfo` (joiner)** (identical schema both directions). It is folded into the `Hoob` fingerprint and the Completion `MACs`/`MACp` (`eap-noob/mac.go::noobArray`), so the cert and identity are cryptographically bound to the pairing (§7.4). Exact, fixed schema: +- `msg` is the opaque `Outcome.Send` blob from the `eap-noob` component, relayed verbatim; the receiver feeds it to its `Server`/`Peer` and returns that side's next `Outcome.Send` in the HTTP response. `msg` may be the **empty string** — that is the Completion *kickoff* (see "two HTTP drivers" below), the only case where a request carries no EAP blob. +- **No out-of-band material is ever transmitted on this channel.** There is no `noob`/`peerId`/`hoob` field: the only OOB secret is the six-digit PIN, and it is human-carried. The `PeerId` is assigned during the Initial Exchange and recorded by the joiner. Each role supplies the PIN-derived `Noob` locally; the Completion MACs check the secret and transcript (§7.4). +- **`PairingInfo` — the object each side puts in EAP-NOOB `ServerInfo` (inviter) / `PeerInfo` (joiner)** (identical schema both directions). It is folded into the Completion `MACs`/`MACp` (`eap-noob/mac.go::noobArray`), so the cert and identity are cryptographically bound to the pairing (§7.4). The retained full-message OOB path also binds these objects into its Hoob fingerprint. Exact, fixed schema: ```json { "v": 2, @@ -365,30 +365,31 @@ Note the deliberate split: `ClusterNode.id` (hostname) stays the *logical/displa **Each node self-signs its own leaf** (no CA, §2/§4). The keypair (Ed25519 or P-256 — implementation choice) and a long-lived self-signed cert (proposed 1–2 years, §4) are generated on first run and reused across restarts. -**Trust is established by an EAP-NOOB pairing authenticated by a six-digit PIN.** The join handshake runs the in-tree `eap-noob` library (RFC 9140) over the pairing channel (§7.2): +**Trust is established by an EAP-NOOB pairing authenticated by a six-digit PIN.** The join handshake runs PAIR's in-tree `eap-noob` pairing component (RFC 9140) over the pairing channel (§7.2): -1. **Bind the certs into the exchange.** The inviter (EAP-NOOB **Server**) puts its identity + **full cert PEM** in the EAP-NOOB `ServerInfo`; the joiner (**Peer**) puts the same in `PeerInfo`. Both objects are folded into the `Hoob` fingerprint and the Completion `MACs`/`MACp` (`eap-noob/mac.go::noobArray`), so a successful pairing proves both sides agree on each other's exact cert — no separate encrypted channel is needed to ship them (certs are public; only integrity matters). +1. **Bind the certs into the exchange.** The inviter (EAP-NOOB **Server**) puts its identity + **full cert PEM** in the EAP-NOOB `ServerInfo`; the joiner (**Peer**) puts the same in `PeerInfo`. Both objects are folded into the Completion `MACs`/`MACp` (`eap-noob/mac.go::noobArray`), so a successful pairing proves both sides agree on each other's exact cert — no separate encrypted channel is needed to ship them (certs are public; only integrity matters). 2. **Initial Exchange** (in-band, machine-to-machine): ephemeral ECDH; both reach `Waiting`. -3. **OOB step = the PIN.** OOB direction is **server-to-peer** (`ServerConfig.Dirs = 2`, `PeerConfig.PreferDir = 2`): the inviter (Server) produces the OOB value and displays it as the PIN; the joiner (Peer) consumes it. The PIN is the **only** thing carried out of band, by the human. Nothing is relayed in-band for the OOB step — the joiner already holds the `PeerId` (assigned during the Initial Exchange) and recomputes `Hoob` locally from the transcript, so it builds its EAP-NOOB OOB input entirely from local state + the typed PIN (see "Library extension" below). +3. **OOB step = the PIN.** OOB direction is **server-to-peer** (`ServerConfig.Dirs = 2`, `PeerConfig.PreferDir = 2`): the inviter (Server) produces the OOB value and displays it as the PIN; the joiner (Peer) consumes it. The PIN is the **only** thing carried out of band, by the human. Nothing is relayed in-band for the OOB step. Each side supplies the PIN-derived `Noob` to its local role; the Completion MACs prove agreement on the secret and transcript (see "PAIR-facing OOB API" below). 4. **Completion Exchange** (in-band): MAC verification; both reach `Registered`. -5. **Pin.** Each side reads the peer's cert out of the now-authenticated `PairingInfo` (§7.2) and pins it (full-cert match, keyed by the peer's UUID) into the trusted-node store. Optionally `Export(...)` a confirmation value to check both derived the same `Kz` before committing the pin. +5. **Pin.** Each side reads the peer's cert out of the now-authenticated `PairingInfo` (§7.2) and pins it (full-cert match, keyed by the peer's UUID) into the trusted-node store. The verified Completion authenticates the certificate information; no application-secret export is involved. The user entering the PIN is the out-of-band authorization — there is no silent auto-pin. Thereafter, every inter-node call must present a cert matching the pin for its claimed UUID. **PIN ↔ `Noob` encoding (strongly defined — both nodes MUST agree byte-for-byte).** - The **PIN** is exactly **six ASCII decimal digits** (`"000000".."999999"`, zero-padded, leading zeros significant). The inviter generates it with a uniform CSPRNG (`crypto/rand`) over `[0, 1000000)`. -- The EAP-NOOB **`Noob` is the fixed 16-byte big-endian encoding of that integer**: a 16-byte buffer, bytes `0..7` zero, bytes `8..15` = `binary.BigEndian.PutUint64(noob[8:], uint64(pinValue))`. (E.g. PIN `"402199"` → `pinValue = 402199` → `noob = 00 00 00 00 00 00 00 00 00 00 00 00 00 06 23 57`.) The base64url JSON form of these 16 bytes is what feeds `Hoob`/`NoobId`/MACs, so identical `pinValue` on both sides ⇒ identical transcript. +- The EAP-NOOB **`Noob` is the fixed 16-byte big-endian encoding of that integer**: a 16-byte buffer, bytes `0..7` zero, bytes `8..15` = `binary.BigEndian.PutUint64(noob[8:], uint64(pinValue))`. (E.g. PIN `"402199"` → `pinValue = 402199` → `noob = 00 00 00 00 00 00 00 00 00 00 00 00 00 06 23 17`.) The base64url JSON form of these 16 bytes feeds `NoobId` and the Completion MACs; both nodes must use exactly the same encoding. - The **joiner** parses the typed PIN: require exactly six digits, `pinValue = strconv.Atoi`, re-encode the same 16 bytes, and feed that `Noob` to its Peer (see below). A non-six-digit / non-numeric entry is rejected locally before EAP-NOOB (no wasted attempt). -**Library extension (the §4 delta, concretely).** The in-tree `eap-noob` library today random-generates a 16-byte `Noob` (`helpers.go::oobOutput`) and its OOB API exchanges the full `OOBMessage{PeerId, Noob, Hoob}` (`Server.OOBOutput`/`Peer.OOBInput`). Our flow human-carries only the `Noob`, so each side needs one small **additive** entry point that injects a caller-supplied `Noob` and derives the rest internally — no `OOBMessage` is built, transmitted, or parsed by the cluster-manager: +**PAIR-facing OOB API.** The in-tree `eap-noob` component serves PAIR's pairing requirements. Today's flow human-carries only the PIN, so each role accepts a caller-supplied 16-byte `Noob`. No `OOBMessage` is built, transmitted, or parsed by the cluster-manager: - **Inviter (Server):** `Server.OOBOutputWith(noob []byte) error` — set `s.noob` to the PIN-derived 16 bytes (instead of `rand.Read`), compute `s.noobID`, and stay in `Waiting`. (It needs only the side effect of registering the `Noob`; nothing is sent.) The CM calls this when it displays the PIN. -- **Joiner (Peer):** `Peer.OOBInputNoob(noob []byte) error` — set `p.noob`/`p.noobID` from the PIN-derived 16 bytes and move to `OOBReceived`, using the Peer's own already-known `PeerId` and its locally-recomputed `Hoob` (no externally-supplied `Hoob` to verify — for a human-carried low-entropy `Noob` there is no separate `Hoob` channel; the real transcript agreement is checked by the Completion `MACs`/`MACp`). The CM calls this on `cluster:respond-to-invite` with the typed PIN. +- **Joiner (Peer):** `Peer.OOBInputNoob(noob []byte) error` — set `p.noob`/`p.noobID` from the PIN-derived 16 bytes and move to `OOBReceived`. No Hoob is computed or verified in this path; the Completion `MACs`/`MACp` authenticate the secret and transcript. The CM calls this on `cluster:respond-to-invite` with the typed PIN. - (The existing `Server.OOBOutput()` / `Peer.OOBInput(OOBMessage)` stay untouched for full-`OOBMessage` callers; the two `*Noob`/`*With` variants are purely additive.) -- This single delta — caller-injected `Noob` on both roles — is the *only* change the cluster-manager requires in `eap-noob`. It vanishes when the OOB upgrades to a high-entropy pairing code (§4), which would carry a real `OOBMessage` and could use the original API. +Both PIN-facing methods validate the 16-byte length and the role's current state. `Server.OOBOutputWith` registers the secret without constructing a discarded Hoob or OOB envelope. + +The server-to-peer `Server.OOBOutput()` / `Peer.OOBInput(OOBMessage)` path remains for PAIR's planned high-entropy pairing code (§4). It generates a random `Noob` and exchanges a full `OOBMessage{PeerId, Noob, Hoob}`, with the receiver verifying Hoob. Today's PIN pairing uses the caller-supplied `Noob` methods above. Two integration notes: -- **EAP-NOOB association `Store`:** construct `NewServer`/`NewPeer` with a `nil` (in-memory) store. The library's association store is an *ephemeral* per-pairing artifact and is **not** the trusted-node store — the cluster-manager pins certs into its own `trusted/.json` files (below) from the authenticated `PairingInfo`, independent of whatever `eap-noob` keeps internally. +- **Pairing state and durable trust:** construct `NewServer(ServerConfig)` / `NewPeer(PeerConfig)` without an association store. `eap-noob` keeps only the state needed for the pairing execution and its Completion authentication keys, `Kms` and `Kmp`. It does not store completed associations or export application secrets. The cluster-manager owns durable trust and pins certs into its own `trusted/.json` files (below) from the authenticated `PairingInfo`. - **`ServerInfo`/`PeerInfo` size:** RFC 9140 suggests ≤ 500 bytes for these objects; a full leaf PEM exceeds that (more so for P-256 than Ed25519). The in-tree library does **not** enforce the cap and the transport is HTTP (no EAP/RADIUS fragmentation), so embedding the PEM works today — this is a deliberate, documented deviation. If the library is ever hardened to enforce the RFC limit, switch the embedded cert to DER/base64 or raise the cap; prefer **Ed25519 leaves** to stay smallest. > **⚠️ The six-digit PIN is temporary, low-entropy security debt (full analysis in §4):** it guards against passive eavesdroppers and accidental joins but not an active MITM, and must be replaced by a high-entropy pairing code before cluster trust is relied on. That upgrade changes only the OOB encoding and the library's `Noob` source — the EAP-NOOB transcript and pin format are unchanged. @@ -515,7 +516,7 @@ Point-to-point pairing alone leaves trust un-propagated: if A pairs B and then A ## 8. Dependencies - **Upstream**: a supervising parent — in practice `nvpair-ui-broker`, which spawns the Cluster Manager, drives the §7.0 requests, and consumes its notifications. The process is **supervisor-agnostic**: it runs under any parent that speaks the §7.0 JSON-RPC contract over stdio (or `--ipc`). Since the node identity is self-owned (§7.4), the only thing a parent must supply is the *cluster* identity via `cluster:set-identity` (from `nvpair-node-settings`); without it the node stays unclustered until it pairs. - **Downstream**: local UI Broker (consumes results, `cluster:invite-received`, and `nodes:changed`); peer Cluster Manager instances (run pairings over the pairing channel and receive removals over mTLS). -- **Library**: the in-tree `eap-noob` Go module (RFC 9140) drives the PIN-authenticated key exchange; it needs the one small additive extension noted in §4/§7.4 (caller-injected `Noob` on each role, `Server.OOBOutputWith` / `Peer.OOBInputNoob`). +- **Pairing component**: the in-tree `eap-noob` Go module (RFC 9140) drives the PIN-authenticated key exchange through its PAIR-facing `Server.OOBOutputWith` / `Peer.OOBInputNoob` methods (§7.4). Durable certificate trust and membership remain cluster-manager responsibilities. - **External / sibling**: - **`nvpair-node-settings`** — owns `cluster_id` / `cluster_friendly_name` / `cluster_auto_sync`. The Cluster Manager reads the local identity (through the Broker) and reports the outcome of a join; it does not write settings directly (open question §4). - **`nvpair-node-scanner` discovery daemon, via the Broker's relay** — advertises this node's `cl` port on the node's single consolidated record and supplies the `discovery:nodes` snapshots the peer resolver is rebuilt from (§7.5). Its snapshots are merged with the addresses the Broker supplies directly (e.g. from `discovery:get-nodes` / manual nodes), which take precedence, so a node reachable only by manual entry can still be invited. From 9413d12ebe768bae60dd3529ebecbc1035b5a70a Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Wed, 28 Oct 2026 14:45:29 -0400 Subject: [PATCH 2/6] Fix pairing policy and remove public configuration knobs Accept typed PairingInfo values directly, remove map conversions and forwarding constructors, and name the supported cryptosuite identifiers. Fix PAIR protocol policy while retaining both algorithms and authenticated transcript inputs. Update callers and colocate behavior tests with their implementations. Signed-off-by: Kaylee Lubick --- services/eap-noob/README.md | 54 +++- services/eap-noob/crypto.go | 32 +- services/eap-noob/eapnoob_test.go | 102 ++++--- services/eap-noob/example_test.go | 21 +- services/eap-noob/helpers.go | 28 +- services/eap-noob/info.go | 20 ++ services/eap-noob/mac.go | 6 +- services/eap-noob/messages.go | 6 + services/eap-noob/peer.go | 125 ++------ services/eap-noob/peer_test.go | 281 ++++++++++++++++++ services/eap-noob/pin_test.go | 53 ++-- services/eap-noob/server.go | 130 ++------ services/eap-noob/server_test.go | 180 +++++++++++ services/eap-noob/state_test.go | 60 ---- .../admission_migration_test.go | 4 +- .../nvpair-cluster-manager/cm_unit_test.go | 71 ----- services/nvpair-cluster-manager/httpserver.go | 15 +- services/nvpair-cluster-manager/invite.go | 6 +- .../invite_expiry_inbound_test.go | 4 +- .../leave_provenance_test.go | 4 +- services/nvpair-cluster-manager/pairing.go | 51 +--- .../pairing_teardown_test.go | 6 +- .../nvpair-cluster-manager/pairing_test.go | 30 ++ .../nvpair-cluster-manager/respond_test.go | 62 ++++ .../review_closure_test.go | 4 +- services/nvpair-cluster-manager/spec.md | 8 +- 26 files changed, 841 insertions(+), 522 deletions(-) create mode 100644 services/eap-noob/info.go create mode 100644 services/eap-noob/peer_test.go create mode 100644 services/eap-noob/server_test.go delete mode 100644 services/eap-noob/state_test.go create mode 100644 services/nvpair-cluster-manager/pairing_test.go create mode 100644 services/nvpair-cluster-manager/respond_test.go diff --git a/services/eap-noob/README.md b/services/eap-noob/README.md index 6444233c5..03ba5b9c6 100644 --- a/services/eap-noob/README.md +++ b/services/eap-noob/README.md @@ -29,7 +29,7 @@ application-secret export library. Implemented: -- Common handshake, Initial Exchange, OOB Step (both directions), Waiting +- Common handshake, Initial Exchange, server-to-peer OOB Step, Waiting Exchange, Completion Exchange. - Cryptosuite 1 (Curve25519 / SHA-256, mandatory) and cryptosuite 2 (NIST P-256 / SHA-256, recommended). @@ -43,17 +43,41 @@ Reconnect / rekeying exchanges (message types 7-9, KeyingMode 1-3) and AAA roaming are not implemented. Completed associations and application keys are not persisted by this module. +## Cryptographic implementations + +ECDH, SHA-256, HMAC, and randomness use Go's standard library. Completion key +derivation retains the small in-tree NIST SP 800-56C implementation, built on +standard-library SHA-256, to avoid an additional runtime dependency. It preserves +the RFC 9140 nonce order, length-prefixed Noob, 320-byte output, and `Kms`/`Kmp` +offsets. + +The EAP-NOOB state machine and authenticated-transcript encoding remain in this +component. The cluster-manager owns certificate pinning and durable trust. + ## Usage ```go -srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: serverInfo}) -peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: peerInfo}) +serverInfo := eapnoob.PairingInfo{ + NodeID: "inviter-node", + Name: "Office workstation", + //... +} +peerInfo := eapnoob.PairingInfo{ + NodeID: "joiner-node", + Name: "Laptop", + //... +} +srv := eapnoob.NewServer(serverInfo) +peer := eapnoob.NewPeer(peerInfo) // 1. Initial Exchange: relay each side's Outcome.Send to the other until done. // (srv.Start() produces the first message.) // 2. The cluster-manager validates and encodes the user-carried PIN as the // same 16-byte noob on both sides. No OOBMessage is sent in the PIN flow. +// This fixture represents PIN 000123; production code generates a fresh PIN. +noob := make([]byte, 16) +noob[15] = 123 if err := srv.OOBOutputWith(noob); err != nil { return err } @@ -61,7 +85,8 @@ if err := peer.OOBInputNoob(noob); err != nil { return err } -// 3. Completion Exchange: relay messages again until both reach Registered. +// 3. Completion Exchange: call srv.Start() again and relay messages until +// both reach Registered. // 4. After successful Completion, the cluster-manager reads srv.PeerInfo() // and peer.ServerInfo() and pins the authenticated certificates. @@ -86,13 +111,22 @@ EAP-NOOB spans two EAP conversations with the OOB step in between: run one conversation for the Initial Exchange, perform the OOB relay, then run a second conversation for the Completion Exchange (call `srv.Start()` again to begin it). -### OOB direction +### Pairing policy + +`NewServer(serverInfo PairingInfo)` and `NewPeer(peerInfo PairingInfo)` accept +only the identity information each role authenticates. Protocol policy +is fixed to version 1, both supported cryptosuites, and server-to-peer OOB +(direction 2). The server advertises suites in priority order `[2, 1]`; peers +still negotiate a mutually supported suite. + +The peer starts with `noob@eap-noob.arpa` as its NAI. Receive-side handling of a +server-supplied `NewNAI` and discovery NAI is preserved, including their use in +the authenticated transcript. There are no public NAI assignment or probe +interval options. -`Dirs`/`PreferDir` select the OOB direction (`1` = peer-to-server, `2` = -server-to-peer). The sending side calls `OOBOutput()` and the receiving side -calls `OOBInput()`. +### OOB flow -PAIR uses direction 2. Its PIN flow calls `Server.OOBOutputWith(noob)` to +The PIN flow calls `Server.OOBOutputWith(noob)` to register the inviter's secret while staying in `Waiting`, and `Peer.OOBInputNoob(noob)` to move the joiner to `OOBReceived`. Neither method constructs or verifies a Hoob fingerprint; transcript agreement is proved by @@ -110,6 +144,6 @@ go test ./... ``` The suite exercises both cryptosuites, PAIR's caller-supplied `Noob` flow, -Completion authentication, KDF outputs, JWK round-trips, and OOB fingerprint +Completion authentication, JWK round-trips, and OOB fingerprint rejection. The cluster-manager tests cover PIN encoding and the durable trust boundary. diff --git a/services/eap-noob/crypto.go b/services/eap-noob/crypto.go index 30ad3c6d4..1e2c4a57b 100644 --- a/services/eap-noob/crypto.go +++ b/services/eap-noob/crypto.go @@ -23,10 +23,22 @@ type JWK struct { Y string `json:"y,omitempty"` } +// cryptosuiteID identifies an EAP-NOOB set of cryptographic algorithms. +type cryptosuiteID int + +// Cryptosuite identifiers are assigned by RFC 9140, Section 5.1. +// PAIR nodes normally select P-256; X25519 is retained because the RFC requires +// implementations to support it, and remains available during negotiation. +// Both suites use SHA-256. Keep these values explicit to match the protocol. +const ( + cryptosuiteX25519 cryptosuiteID = 1 + cryptosuiteP256 cryptosuiteID = 2 +) + // cryptosuite implements one EAP-NOOB cryptosuite (RFC 9140, Section 5.1). // Both registered suites use SHA-256, so only the curve and JWK encoding vary. type cryptosuite struct { - id int + id cryptosuiteID curve ecdh.Curve kty string crv string @@ -36,23 +48,19 @@ type cryptosuite struct { // suiteByID returns the cryptosuite for an IANA identifier. Suite 1 // (Curve25519/SHA-256) is mandatory; suite 2 (NIST P-256/SHA-256) is // recommended (RFC 9140, Section 5.1). -func suiteByID(id int) (*cryptosuite, error) { +func suiteByID(id cryptosuiteID) (*cryptosuite, error) { switch id { - case 1: - return &cryptosuite{id: 1, curve: ecdh.X25519(), kty: "OKP", crv: "X25519", ec: false}, nil - case 2: - return &cryptosuite{id: 2, curve: ecdh.P256(), kty: "EC", crv: "P-256", ec: true}, nil + case cryptosuiteX25519: + return &cryptosuite{id: cryptosuiteX25519, curve: ecdh.X25519(), kty: "OKP", crv: "X25519", ec: false}, nil + case cryptosuiteP256: + return &cryptosuite{id: cryptosuiteP256, curve: ecdh.P256(), kty: "EC", crv: "P-256", ec: true}, nil default: return nil, fmt.Errorf("eapnoob: unsupported cryptosuite %d", id) } } -// supportedCryptosuites lists the suites this library implements, in the -// recommended server priority order. -func supportedCryptosuites() []int { return []int{2, 1} } - -func isSupportedCryptosuite(id int) bool { - return id == 1 || id == 2 +func isSupportedCryptosuite(id cryptosuiteID) bool { + return id == cryptosuiteX25519 || id == cryptosuiteP256 } // generateKeypair creates an ephemeral ECDHE key pair and returns the private diff --git a/services/eap-noob/eapnoob_test.go b/services/eap-noob/eapnoob_test.go index 6e679fe6b..4f74d5087 100644 --- a/services/eap-noob/eapnoob_test.go +++ b/services/eap-noob/eapnoob_test.go @@ -4,6 +4,7 @@ package eapnoob import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -17,9 +18,15 @@ func driveConversation(t *testing.T, srv *Server, peer *Peer) { t.Helper() msg, err := srv.Start() require.NoError(t, err, "server Start:") + driveMessages(t, srv, peer, msg) +} + +func driveMessages(t *testing.T, srv *Server, peer *Peer, msg []byte) { + t.Helper() peerTurn := true for { var out Outcome + var err error if peerTurn { out, err = peer.Receive(msg) } else { @@ -35,38 +42,44 @@ func driveConversation(t *testing.T, srv *Server, peer *Peer) { } } -// pair runs a full EAP-NOOB pairing (Initial + OOB + Completion) for the given -// cryptosuite and OOB direction, returning the registered server and peer. -func pair(t *testing.T, suite, dir int) (*Server, *Peer) { +// driveInitialWithSuite supplies a single-suite offer as a remote-protocol +// fixture. Production always advertises both suites; tests can exercise each +// negotiated suite without adding configuration to the application API. +func driveInitialWithSuite(t *testing.T, srv *Server, peer *Peer, suite cryptosuiteID) { + t.Helper() + request, err := srv.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + negotiation, err := srv.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + wire, err := decode(negotiation.Send) + require.NoError(t, err) + wire.Cryptosuites = rawIntSlice([]int{int(suite)}) + request, parsed, err := encode(wire) + require.NoError(t, err) + srv.in.Cryptosuites = parsed.Cryptosuites + driveMessages(t, srv, peer, request) +} + +// pair runs a full server-to-peer pairing for the given cryptosuite. +func pair(t *testing.T, suite cryptosuiteID) (*Server, *Peer) { t.Helper() - srv := NewServer(ServerConfig{ - Cryptosuites: []int{suite}, - Dirs: dir, - ServerInfo: map[string]any{"ServerName": "test-server"}, - }) - peer := NewPeer(PeerConfig{ - PreferDir: dir, - PeerInfo: map[string]any{"PeerName": "test-peer"}, - }) + srv := NewServer(PairingInfo{Name: "test-server"}) + peer := NewPeer(PairingInfo{Name: "test-peer"}) // Initial Exchange. - driveConversation(t, srv, peer) + driveInitialWithSuite(t, srv, peer, suite) require.Equal(t, StateWaiting, srv.State(), "server after Initial") require.Equal(t, StateWaiting, peer.State(), "peer after Initial") - // OOB Step in the negotiated direction. - switch dir { - case 1: // peer-to-server - msg, err := peer.OOBOutput() - require.NoError(t, err, "peer OOBOutput:") - require.NoError(t, srv.OOBInput(msg), "server OOBInput:") - require.Equal(t, StateOOBReceived, srv.State()) - case 2: // server-to-peer - msg, err := srv.OOBOutput() - require.NoError(t, err, "server OOBOutput:") - require.NoError(t, peer.OOBInput(msg), "peer OOBInput:") - require.Equal(t, StateOOBReceived, peer.State()) - } + msg, err := srv.OOBOutput() + require.NoError(t, err, "server OOBOutput:") + require.NoError(t, peer.OOBInput(msg), "peer OOBInput:") + require.Equal(t, StateWaiting, srv.State()) + require.Equal(t, StateOOBReceived, peer.State()) // Completion Exchange. driveConversation(t, srv, peer) @@ -75,26 +88,27 @@ func pair(t *testing.T, suite, dir int) (*Server, *Peer) { return srv, peer } -func TestPairing_AuthenticatesIdentityAcrossSuitesAndDirections(t *testing.T) { - test := func(name string, suite, dir int) { +func TestPairing_AuthenticatesIdentityWithBothSuites(t *testing.T) { + test := func(name string, suite cryptosuiteID) { t.Run(name, func(t *testing.T) { - srv, peer := pair(t, suite, dir) - assert.Equal(t, suite, srv.csp) - assert.Equal(t, suite, peer.csp) + srv, peer := pair(t, suite) + assert.Equal(t, suite, srv.suite.id) + assert.Equal(t, suite, peer.suite.id) assert.Equal(t, peer.peerId, srv.peerId) - assert.JSONEq(t, `{"PeerName":"test-peer"}`, string(srv.PeerInfo())) - assert.JSONEq(t, `{"ServerName":"test-server"}`, string(peer.ServerInfo())) + var serverInfo, peerInfo PairingInfo + require.NoError(t, json.Unmarshal(peer.ServerInfo(), &serverInfo)) + require.NoError(t, json.Unmarshal(srv.PeerInfo(), &peerInfo)) + assert.Equal(t, PairingInfo{Name: "test-server"}, serverInfo) + assert.Equal(t, PairingInfo{Name: "test-peer"}, peerInfo) }) } - test("suite 1 server-to-peer", 1, 2) - test("suite 2 server-to-peer", 2, 2) - test("suite 1 peer-to-server", 1, 1) - test("suite 2 peer-to-server", 2, 1) + test("suite 1", cryptosuiteX25519) + test("suite 2", cryptosuiteP256) } func TestPairing_RejectsTamperedMAC(t *testing.T) { - srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) driveConversation(t, srv, peer) oob, err := srv.OOBOutput() @@ -130,8 +144,8 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { } func TestPairing_RejectsIncorrectOutOfBandFingerprint(t *testing.T) { - srv := NewServer(ServerConfig{Cryptosuites: []int{1}, Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) driveConversation(t, srv, peer) oob, err := srv.OOBOutput() @@ -142,7 +156,7 @@ func TestPairing_RejectsIncorrectOutOfBandFingerprint(t *testing.T) { } func TestJWK_RoundTripsPublicKeys(t *testing.T) { - test := func(name string, id int) { + test := func(name string, id cryptosuiteID) { t.Run(name, func(t *testing.T) { cs, err := suiteByID(id) require.NoError(t, err) @@ -153,6 +167,6 @@ func TestJWK_RoundTripsPublicKeys(t *testing.T) { assert.Equal(t, priv.PublicKey().Bytes(), pub.Bytes(), "JWK round-trip mismatch") }) } - test("suite 1", 1) - test("suite 2", 2) + test("suite 1", cryptosuiteX25519) + test("suite 2", cryptosuiteP256) } diff --git a/services/eap-noob/example_test.go b/services/eap-noob/example_test.go index b44f009e1..fab756c10 100644 --- a/services/eap-noob/example_test.go +++ b/services/eap-noob/example_test.go @@ -4,6 +4,7 @@ package eapnoob_test import ( + "encoding/json" "fmt" "eapnoob" @@ -42,8 +43,8 @@ func run(srv *eapnoob.Server, peer *eapnoob.Peer) error { // The cluster manager validates and encodes the user-carried PIN before calling // these methods, then pins the authenticated identity after Completion. func Example() { - srv := eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: map[string]any{"name": "inviter"}}) - peer := eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"name": "joiner"}}) + srv := eapnoob.NewServer(eapnoob.PairingInfo{Name: "inviter"}) + peer := eapnoob.NewPeer(eapnoob.PairingInfo{Name: "joiner"}) // Initial Exchange. if err := run(srv, peer); err != nil { @@ -65,11 +66,19 @@ func Example() { panic(err) } + var inviter, joiner eapnoob.PairingInfo + if err := json.Unmarshal(peer.ServerInfo(), &inviter); err != nil { + panic(err) + } + if err := json.Unmarshal(srv.PeerInfo(), &joiner); err != nil { + panic(err) + } + fmt.Println("registered:", srv.State() == eapnoob.StateRegistered && peer.State() == eapnoob.StateRegistered) - fmt.Println("authenticated inviter:", string(peer.ServerInfo())) - fmt.Println("authenticated joiner:", string(srv.PeerInfo())) + fmt.Println("authenticated inviter:", inviter.Name) + fmt.Println("authenticated joiner:", joiner.Name) // Output: // registered: true - // authenticated inviter: {"name":"inviter"} - // authenticated joiner: {"name":"joiner"} + // authenticated inviter: inviter + // authenticated joiner: joiner } diff --git a/services/eap-noob/helpers.go b/services/eap-noob/helpers.go index e8853ad51..81dd901bf 100644 --- a/services/eap-noob/helpers.go +++ b/services/eap-noob/helpers.go @@ -31,16 +31,10 @@ func newPeerId() (string, error) { return b64Encode(b), nil } -// marshalInfo encodes a ServerInfo/PeerInfo object, defaulting to an empty JSON -// object when none is configured. -func marshalInfo(m map[string]any) json.RawMessage { - if m == nil { - return json.RawMessage("{}") - } - b, err := json.Marshal(m) - if err != nil || len(b) == 0 { - return json.RawMessage("{}") - } +// marshalInfo encodes the identity object included in the authenticated transcript. +func marshalInfo(info PairingInfo) json.RawMessage { + // PairingInfo contains only strings and integers, so encoding cannot fail. + b, _ := json.Marshal(info) return b } @@ -89,16 +83,16 @@ func decodeMAC(r json.RawMessage) ([]byte, error) { func hmacEqual(a, b []byte) bool { return hmac.Equal(a, b) } -// oobOutput builds the OOB message for the given direction, generating a fresh +// oobOutput builds a server-to-peer OOB message, generating a fresh // secret nonce Noob and computing the fingerprint Hoob over the negotiated // Initial Exchange parameters (RFC 9140, Section 3.3.2). -func oobOutput(ms *methodState, dir int) (OOBMessage, error) { +func oobOutput(ms *methodState) (OOBMessage, error) { noob := make([]byte, 16) if _, err := rand.Read(noob); err != nil { return OOBMessage{}, err } ms.setNoob(noob) - hoob := ms.suite.computeHoob(dir, ms.in) + hoob := ms.suite.computeHoob(ms.in) return OOBMessage{ PeerId: ms.peerId, Noob: b64Encode(noob), @@ -106,11 +100,11 @@ func oobOutput(ms *methodState, dir int) (OOBMessage, error) { }, nil } -// oobInputNoob records a caller-supplied 16-byte Noob without an OOBMessage +// setOOBNoob records a caller-supplied 16-byte Noob without an OOBMessage // envelope. Unlike oobInput it does not verify a received Hoob (there is none // to verify for a human-carried Noob); transcript agreement is instead proven // by the Completion Exchange MACs. -func oobInputNoob(ms *methodState, noob []byte) error { +func setOOBNoob(ms *methodState, noob []byte) error { if len(noob) != 16 { return fmt.Errorf("eapnoob: Noob must be 16 bytes") } @@ -121,7 +115,7 @@ func oobInputNoob(ms *methodState, noob []byte) error { // oobInput verifies a received OOB message: the PeerId must match and the // locally recomputed Hoob must equal the received value (RFC 9140, // Section 3.2.3). -func oobInput(ms *methodState, dir int, msg OOBMessage) error { +func oobInput(ms *methodState, msg OOBMessage) error { if ms.peerId != "" && msg.PeerId != ms.peerId { return fmt.Errorf("eapnoob: OOB PeerId mismatch") } @@ -130,7 +124,7 @@ func oobInput(ms *methodState, dir int, msg OOBMessage) error { return fmt.Errorf("eapnoob: invalid OOB Noob") } ms.setNoob(noob) - hoob := ms.suite.computeHoob(dir, ms.in) + hoob := ms.suite.computeHoob(ms.in) if b64Encode(hoob) != msg.Hoob { return fmt.Errorf("eapnoob: OOB Hoob verification failed") } diff --git a/services/eap-noob/info.go b/services/eap-noob/info.go new file mode 100644 index 000000000..f9f512652 --- /dev/null +++ b/services/eap-noob/info.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package eapnoob + +// PairingInfo identifies a node in ServerInfo (inviter) or PeerInfo (joiner). +// Completion authenticates these fields, including the public certificate. +// The cluster manager constructs and validates this identity and owns its +// durable certificate pins. +type PairingInfo struct { + V int `json:"v"` + NodeUUID string `json:"nodeUuid"` + NodeID string `json:"nodeId"` + Name string `json:"name"` + ClusterID string `json:"clusterId"` + AdmissionEpoch uint64 `json:"admissionEpoch,omitempty"` + ClusterFriendlyName string `json:"clusterFriendlyName"` + Addr string `json:"addr,omitempty"` + Cert string `json:"cert"` +} diff --git a/services/eap-noob/mac.go b/services/eap-noob/mac.go index ee03c4fa3..235af34cc 100644 --- a/services/eap-noob/mac.go +++ b/services/eap-noob/mac.go @@ -75,11 +75,11 @@ func noobArray(lead json.RawMessage, in macInputs) []byte { ) } -// computeHoob returns the 16-byte fingerprint Hoob for the given OOB direction +// computeHoob returns the 16-byte fingerprint Hoob for server-to-peer OOB // (RFC 9140, Section 3.3.2): the cryptosuite hash of the input array truncated // to its 16 leftmost bytes. -func (cs *cryptosuite) computeHoob(dir int, in macInputs) []byte { - arr := noobArray(rawInt(dir), in) +func (cs *cryptosuite) computeHoob(in macInputs) []byte { + arr := noobArray(rawInt(oobDirection), in) return cs.hash(arr)[:16] } diff --git a/services/eap-noob/messages.go b/services/eap-noob/messages.go index f6b82f2ef..55f24b183 100644 --- a/services/eap-noob/messages.go +++ b/services/eap-noob/messages.go @@ -21,6 +21,12 @@ const ( // NAI (RFC 9140, Section 3.2.1). const defaultNAI = "noob@eap-noob.arpa" +// PAIR supports protocol version 1 and server-to-peer OOB. +const ( + protocolVersion = 1 + oobDirection = 2 +) + // eapResult is a non-protocol control message used by this transport-agnostic // library to convey the terminating EAP-Success / EAP-Failure indication that a // real EAP lower layer would deliver out of band of the EAP-NOOB payloads. diff --git a/services/eap-noob/peer.go b/services/eap-noob/peer.go index 0122e8f50..cb890db20 100644 --- a/services/eap-noob/peer.go +++ b/services/eap-noob/peer.go @@ -10,52 +10,20 @@ import ( "fmt" ) -// PeerConfig configures the EAP peer role. -type PeerConfig struct { - // NAI is the peer's Network Access Identifier. Defaults to - // "noob@eap-noob.arpa". - NAI string - // Versions are the protocol versions the peer supports. Defaults to - // []int{1}. - Versions []int - // Cryptosuites are the cryptosuites the peer supports. Defaults to - // []int{1, 2} (suite 1 is mandatory). - Cryptosuites []int - // PreferDir selects the OOB direction the peer prefers when the server - // offers both (1=peer-to-server, 2=server-to-peer). Defaults to - // server-to-peer. - PreferDir int - // PeerInfo is an optional application-defined JSON object sent to the - // server in the Initial Exchange. - PeerInfo map[string]any -} - // Peer drives the EAP peer side of one EAP-NOOB association. type Peer struct { - cfg PeerConfig + peerInfo json.RawMessage state State completionVerified bool methodState - - verp int - csp int - dirp int } -// NewPeer creates a peer for one pairing using the given configuration. -func NewPeer(cfg PeerConfig) *Peer { - if cfg.NAI == "" { - cfg.NAI = defaultNAI - } - if len(cfg.Versions) == 0 { - cfg.Versions = []int{1} - } - if len(cfg.Cryptosuites) == 0 { - cfg.Cryptosuites = []int{1, 2} - } - p := &Peer{cfg: cfg, state: StateUnregistered} - p.nai = cfg.NAI +// NewPeer creates a PAIR joiner. peerInfo carries the node's identity and +// certificate; Completion authenticates it to the inviter. +func NewPeer(peerInfo PairingInfo) *Peer { + p := &Peer{peerInfo: marshalInfo(peerInfo), state: StateUnregistered} + p.nai = defaultNAI return p } @@ -144,15 +112,14 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { if err != nil { return p.fail(ErrInvalidData, "bad Vers") } - verp, ok := bestVersion(serverVers, p.cfg.Versions) - if !ok { + if !containsInt(serverVers, protocolVersion) { return p.fail(ErrUnsupportedVersion, "no common protocol version") } serverCS, err := rawToIntSlice(wm.Cryptosuites) if err != nil { return p.fail(ErrInvalidData, "bad Cryptosuites") } - csp, ok := firstSupported(serverCS, p.cfg.Cryptosuites) + csp, ok := firstSupported(serverCS) if !ok { return p.fail(ErrUnsupportedCryptosuite, "no common cryptosuite") } @@ -160,8 +127,7 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { if err != nil { return p.fail(ErrInvalidData, "bad Dirs") } - dirp, ok := chooseDir(serverDirs, p.cfg.PreferDir) - if !ok { + if serverDirs != oobDirection && serverDirs != 3 { return p.fail(ErrNoMutuallySupportedOOB, "no mutually supported OOB direction") } @@ -170,20 +136,18 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { return p.fail(ErrUnsupportedCryptosuite, err.Error()) } p.suite = suite - p.verp, p.csp, p.dirp = verp, csp, dirp // Determine the effective NAI: a server-assigned NewNAI overrides the - // peer's configured NAI (RFC 9140, Section 3.3.1). + // default NAI (RFC 9140, Section 3.3.1). if len(wm.NewNAI) > 0 { if nai, err := rawToString(wm.NewNAI); err == nil { p.nai = nai } } - verpRaw := rawInt(verp) - cspRaw := rawInt(csp) - dirpRaw := rawInt(dirp) - peerInfo := marshalInfo(p.cfg.PeerInfo) + verpRaw := rawInt(protocolVersion) + cspRaw := rawInt(int(csp)) + dirpRaw := rawInt(oobDirection) p.in.Vers = wm.Vers p.in.PeerId = wm.PeerId @@ -193,7 +157,7 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { p.in.Verp = verpRaw p.in.Cryptosuitep = cspRaw p.in.Dirp = dirpRaw - p.in.PeerInfo = peerInfo + p.in.PeerInfo = p.peerInfo p.in.NAI = rawString(p.nai) m := &wireMessage{ @@ -202,7 +166,7 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { PeerId: rawString(peerId), Cryptosuitep: cspRaw, Dirp: dirpRaw, - PeerInfo: peerInfo, + PeerInfo: p.peerInfo, } b, err := json.Marshal(m) if err != nil { @@ -328,18 +292,6 @@ func (p *Peer) onCompletionRequest(wm *wireMessage) (Outcome, error) { return Outcome{Send: b, State: p.state}, nil } -// OOBOutput produces the out-of-band message for the peer-to-server direction -// (RFC 9140, Section 3.2.3). -func (p *Peer) OOBOutput() (OOBMessage, error) { - if p.state != StateWaiting { - return OOBMessage{}, fmt.Errorf("eapnoob: OOBOutput requires Waiting state, have %s", p.state) - } - if p.dirp != 1 && p.dirp != 3 { - return OOBMessage{}, fmt.Errorf("eapnoob: peer-to-server OOB not negotiated") - } - return oobOutput(&p.methodState, 1) -} - // OOBInput accepts the out-of-band message for the server-to-peer direction and // verifies its fingerprint (RFC 9140, Section 3.2.3). On success the peer moves // to the OOB Received state. @@ -347,10 +299,7 @@ func (p *Peer) OOBInput(msg OOBMessage) error { if p.state != StateWaiting { return fmt.Errorf("eapnoob: OOBInput requires Waiting state, have %s", p.state) } - if p.dirp != 2 && p.dirp != 3 { - return fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") - } - if err := oobInput(&p.methodState, 2, msg); err != nil { + if err := oobInput(&p.methodState, msg); err != nil { return err } p.state = StateOOBReceived @@ -365,10 +314,7 @@ func (p *Peer) OOBInputNoob(noob []byte) error { if p.state != StateWaiting { return fmt.Errorf("eapnoob: OOBInputNoob requires Waiting state, have %s", p.state) } - if p.dirp != 2 && p.dirp != 3 { - return fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") - } - if err := oobInputNoob(&p.methodState, noob); err != nil { + if err := setOOBNoob(&p.methodState, noob); err != nil { return err } p.state = StateOOBReceived @@ -384,39 +330,12 @@ func (p *Peer) fail(code int, info string) (Outcome, error) { return Outcome{Send: b, State: p.state, Done: true, Err: &ProtocolError{Code: code, Info: info}}, nil } -// negotiation helpers --------------------------------------------------------- - -func bestVersion(serverVers, peerVers []int) (int, bool) { - best, ok := 0, false - for _, v := range serverVers { - if containsInt(peerVers, v) && v >= best { - best, ok = v, true - } - } - return best, ok -} - -func firstSupported(serverCS, peerCS []int) (int, bool) { - for _, c := range serverCS { - if containsInt(peerCS, c) && isSupportedCryptosuite(c) { - return c, true +func firstSupported(serverCS []int) (cryptosuiteID, bool) { + for _, offeredSuite := range serverCS { + id := cryptosuiteID(offeredSuite) + if isSupportedCryptosuite(id) { + return id, true } } return 0, false } - -func chooseDir(serverDirs, prefer int) (int, bool) { - switch serverDirs { - case 1: - return 1, true - case 2: - return 2, true - case 3: - if prefer == 1 || prefer == 2 { - return prefer, true - } - return 2, true - default: - return 0, false - } -} diff --git a/services/eap-noob/peer_test.go b/services/eap-noob/peer_test.go new file mode 100644 index 000000000..b3654e28f --- /dev/null +++ b/services/eap-noob/peer_test.go @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package eapnoob + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func peerNegotiationOffer(t *testing.T) (*Peer, *wireMessage) { + t.Helper() + server := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + request, err := server.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + negotiation, err := server.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + offer, err := decode(negotiation.Send) + require.NoError(t, err) + return peer, offer +} + +func TestPeer_AdvertisesPAIRDefaults(t *testing.T) { + server := NewServer(PairingInfo{}) + info := PairingInfo{ + V: 2, + NodeUUID: "peer-uuid", + NodeID: "peer-node", + Name: "peer", + ClusterID: "test-cluster", + AdmissionEpoch: 9007199254740993, + ClusterFriendlyName: "Test cluster", + Addr: "peer.example:14321", + Cert: "peer-cert", + } + peer := NewPeer(info) + request, err := server.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + identity, err := decode(discovery.Send) + require.NoError(t, err) + assert.Equal(t, json.RawMessage(`"noob@eap-noob.arpa"`), identity.NAI) + + negotiation, err := server.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + selection, err := peer.Receive(negotiation.Send) + require.NoError(t, err) + require.Nil(t, selection.Err) + response, err := decode(selection.Send) + require.NoError(t, err) + assert.Equal(t, json.RawMessage(`1`), response.Verp) + assert.Equal(t, json.RawMessage(`2`), response.Cryptosuitep) + assert.Equal(t, json.RawMessage(`2`), response.Dirp) + var receivedInfo PairingInfo + require.NoError(t, json.Unmarshal(response.PeerInfo, &receivedInfo)) + assert.Equal(t, info, receivedInfo) +} + +func TestPeerNegotiation_SelectsSupportedParameters(t *testing.T) { + test := func(name string, versions, suites, directions json.RawMessage, wantSuite string) { + t.Run(name, func(t *testing.T) { + peer, offer := peerNegotiationOffer(t) + offer.Vers = versions + offer.Cryptosuites = suites + offer.Dirs = directions + message, _, err := encode(offer) + require.NoError(t, err) + + out, err := peer.Receive(message) + + require.NoError(t, err) + require.Nil(t, out.Err) + response, err := decode(out.Send) + require.NoError(t, err) + assert.Equal(t, json.RawMessage(`1`), response.Verp) + assert.Equal(t, json.RawMessage(wantSuite), response.Cryptosuitep) + assert.Equal(t, json.RawMessage(`2`), response.Dirp) + }) + } + test("suite 1 remains supported", json.RawMessage(`[1]`), json.RawMessage(`[1]`), json.RawMessage(`2`), "1") + test("suite 2 remains supported", json.RawMessage(`[1]`), json.RawMessage(`[2]`), json.RawMessage(`2`), "2") + test("server preference selects suite 2", json.RawMessage(`[1]`), json.RawMessage(`[2,1]`), json.RawMessage(`2`), "2") + test("unsupported suite is skipped", json.RawMessage(`[1]`), json.RawMessage(`[99,1]`), json.RawMessage(`2`), "1") + test("supported version is selected from a larger offer", json.RawMessage(`[99,1]`), json.RawMessage(`[2,1]`), json.RawMessage(`2`), "2") + test("both directions select server-to-peer", json.RawMessage(`[1]`), json.RawMessage(`[2,1]`), json.RawMessage(`3`), "2") +} + +func TestPeerNegotiation_RejectsUnsupportedOffers(t *testing.T) { + test := func(name string, change func(*wireMessage), wantCode int) { + t.Run(name, func(t *testing.T) { + peer, offer := peerNegotiationOffer(t) + change(offer) + message, _, err := encode(offer) + require.NoError(t, err) + + out, err := peer.Receive(message) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, wantCode, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.NotEqual(t, StateRegistered, peer.State()) + }) + } + test("unsupported version", func(offer *wireMessage) { + offer.Vers = json.RawMessage(`[99]`) + }, ErrUnsupportedVersion) + test("unsupported cryptosuite", func(offer *wireMessage) { + offer.Cryptosuites = json.RawMessage(`[99]`) + }, ErrUnsupportedCryptosuite) + test("inverse direction only", func(offer *wireMessage) { + offer.Dirs = json.RawMessage(`1`) + }, ErrNoMutuallySupportedOOB) + test("no OOB direction", func(offer *wireMessage) { + offer.Dirs = json.RawMessage(`0`) + }, ErrNoMutuallySupportedOOB) +} + +func TestPeerNegotiation_BindsAssignedNAIToAuthenticatedTranscript(t *testing.T) { + peer, offer := peerNegotiationOffer(t) + offer.NewNAI = json.RawMessage(`"assigned@pair.test"`) + message, _, err := encode(offer) + require.NoError(t, err) + + out, err := peer.Receive(message) + + require.NoError(t, err) + require.Nil(t, out.Err) + assert.Equal(t, json.RawMessage(`"assigned@pair.test"`), peer.in.NAI) + discovery, err := peer.Receive([]byte(`{"Type":1}`)) + require.NoError(t, err) + require.Nil(t, discovery.Err) + identity, err := decode(discovery.Send) + require.NoError(t, err) + assert.Equal(t, json.RawMessage(`"assigned@pair.test"`), identity.NAI) +} + +func TestPeer_RejectsSuccessBeforeVerifiedCompletion(t *testing.T) { + test := func(name string, state State) { + t.Run(name, func(t *testing.T) { + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + if state != StateUnregistered { + driveConversation(t, srv, peer) + } + if state == StateOOBReceived { + require.NoError(t, peer.OOBInputNoob(make([]byte, 16))) + } + require.Equal(t, state, peer.State()) + + out, err := peer.Receive(resultBytes(eapSuccess)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrStateMismatch, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.Equal(t, state, peer.State()) + }) + } + test("before initial exchange", StateUnregistered) + test("before OOB input", StateWaiting) + test("after OOB input without Completion", StateOOBReceived) +} + +func TestPeer_RejectsNoobOutsideWaiting(t *testing.T) { + test := func(name string, registered bool) { + t.Run(name, func(t *testing.T) { + peer := NewPeer(PairingInfo{}) + wantState := StateUnregistered + if registered { + _, peer = pair(t, 2) + wantState = StateRegistered + } + + assert.Error(t, peer.OOBInputNoob(make([]byte, 16))) + assert.Equal(t, wantState, peer.State()) + }) + } + test("before initial exchange", false) + test("after successful Completion", true) +} + +func TestPeer_RejectsChangedAuthenticatedCertificateInformation(t *testing.T) { + test := func(name string, changedServerInfo, changedPeerInfo json.RawMessage) { + t.Run(name, func(t *testing.T) { + server := NewServer(PairingInfo{Cert: "original-server-cert"}) + peer := NewPeer(PairingInfo{Cert: "original-peer-cert"}) + + msg, err := server.Start() + require.NoError(t, err) + peerTurn := true + for rounds := 0; ; rounds++ { + require.Less(t, rounds, 16, "Initial Exchange did not terminate") + wire, err := decode(msg) + require.NoError(t, err) + if wire.Type != nil && *wire.Type == typeNegotiation { + if peerTurn && len(changedServerInfo) != 0 { + wire.ServerInfo = changedServerInfo + msg, _, err = encode(wire) + require.NoError(t, err) + } + if !peerTurn && len(changedPeerInfo) != 0 { + wire.PeerInfo = changedPeerInfo + msg, _, err = encode(wire) + require.NoError(t, err) + } + } + var out Outcome + if peerTurn { + out, err = peer.Receive(msg) + } else { + out, err = server.Receive(msg) + } + require.NoError(t, err) + require.Nil(t, out.Err) + if len(out.Send) == 0 { + break + } + msg = out.Send + peerTurn = !peerTurn + } + require.Equal(t, StateWaiting, server.State()) + require.Equal(t, StateWaiting, peer.State()) + + noob := bytes.Repeat([]byte{0xAB}, 16) + require.NoError(t, server.OOBOutputWith(noob)) + require.NoError(t, peer.OOBInputNoob(noob)) + msg, err = server.Start() + require.NoError(t, err) + peerTurn = true + for rounds := 0; ; rounds++ { + require.Less(t, rounds, 16, "Completion Exchange did not terminate") + var out Outcome + if peerTurn { + out, err = peer.Receive(msg) + } else { + out, err = server.Receive(msg) + } + require.NoError(t, err) + if out.Err != nil { + assert.True(t, peerTurn, "peer must reject the server's transcript MAC") + assert.Equal(t, ErrHMACVerificationFailed, out.Err.Code) + assert.False(t, out.Success) + assert.NotEqual(t, StateRegistered, peer.State()) + assert.NotEqual(t, StateRegistered, server.State()) + return + } + require.False(t, out.Done, "Completion succeeded despite changed certificate information") + require.NotEmpty(t, out.Send) + msg = out.Send + peerTurn = !peerTurn + } + }) + } + test("changed server certificate", json.RawMessage(`{"cert":"changed-server-cert"}`), nil) + test("changed peer certificate", nil, json.RawMessage(`{"cert":"changed-peer-cert"}`)) +} + +func TestPeer_RejectsMalformedJSON(t *testing.T) { + out, err := NewPeer(PairingInfo{}).Receive([]byte(`{"Type":`)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrInvalidMessageStructure, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) +} diff --git a/services/eap-noob/pin_test.go b/services/eap-noob/pin_test.go index c2db7a77b..6ffb3fc2b 100644 --- a/services/eap-noob/pin_test.go +++ b/services/eap-noob/pin_test.go @@ -42,31 +42,36 @@ func driveAllowFail(srv *Server, peer *Peer) bool { // PIN flow takes): the server supplies the Noob via OOBOutputWith and the peer // records the same Noob via OOBInputNoob, with no OOBMessage/Hoob envelope. func TestPairing_AcceptsMatchingCallerProvidedNoob(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2, ServerInfo: map[string]any{"role": "server"}}) - peer := NewPeer(PeerConfig{PreferDir: 2, PeerInfo: map[string]any{"role": "peer"}}) - - driveConversation(t, srv, peer) - require.Equal(t, StateWaiting, srv.State(), "after Initial: server=%s peer=%s, want WaitingForOOB", srv.State(), peer.State()) - require.Equal(t, StateWaiting, peer.State(), "after Initial: server=%s peer=%s, want WaitingForOOB", srv.State(), peer.State()) - - noob := bytes.Repeat([]byte{0xAB}, 16) - require.NoError(t, srv.OOBOutputWith(noob), "server OOBOutputWith:") - assert.Equal(t, StateWaiting, srv.State()) - require.NoError(t, peer.OOBInputNoob(noob), "peer OOBInputNoob:") - require.Equal(t, StateOOBReceived, peer.State(), "peer state %s, want OOBReceived", peer.State()) - - driveConversation(t, srv, peer) - require.Equal(t, StateRegistered, srv.State(), "after Completion: server=%s peer=%s, want Registered", srv.State(), peer.State()) - require.Equal(t, StateRegistered, peer.State(), "after Completion: server=%s peer=%s, want Registered", srv.State(), peer.State()) - assert.JSONEq(t, `{"role":"server"}`, string(peer.ServerInfo())) - assert.JSONEq(t, `{"role":"peer"}`, string(srv.PeerInfo())) + test := func(name string, suite cryptosuiteID) { + t.Run(name, func(t *testing.T) { + srv := NewServer(PairingInfo{Name: "server"}) + peer := NewPeer(PairingInfo{Name: "peer"}) + driveInitialWithSuite(t, srv, peer, suite) + require.Equal(t, StateWaiting, srv.State()) + require.Equal(t, StateWaiting, peer.State()) + + noob := bytes.Repeat([]byte{0xAB}, 16) + require.NoError(t, srv.OOBOutputWith(noob)) + assert.Equal(t, StateWaiting, srv.State()) + require.NoError(t, peer.OOBInputNoob(noob)) + require.Equal(t, StateOOBReceived, peer.State()) + + driveConversation(t, srv, peer) + require.Equal(t, StateRegistered, srv.State()) + require.Equal(t, StateRegistered, peer.State()) + assert.Equal(t, srv.serverInfo, peer.ServerInfo()) + assert.Equal(t, peer.peerInfo, srv.PeerInfo()) + }) + } + test("suite 1", cryptosuiteX25519) + test("suite 2", cryptosuiteP256) } // TestMismatchedInjectedNoobFails is the wrong-PIN case: the two sides inject // different Noobs, so the Completion MACs cannot agree and pairing must fail. func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) driveConversation(t, srv, peer) @@ -89,8 +94,8 @@ func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { // reports it this way, the cluster manager would silently degrade a wrong PIN to // a generic failure (empty reason) instead of reason:"incorrect-pin". func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) // Initial Exchange. driveConversation(t, srv, peer) @@ -126,8 +131,8 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { // TestOOBOutputWithRejectsBadLength guards the 16-byte contract. func TestOOBOutputWith_RejectsBadLength(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) + srv := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) driveConversation(t, srv, peer) require.Error(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 8)), "expected OOBOutputWith to reject an 8-byte Noob") } diff --git a/services/eap-noob/server.go b/services/eap-noob/server.go index d487b6f03..50a4c2175 100644 --- a/services/eap-noob/server.go +++ b/services/eap-noob/server.go @@ -10,51 +10,18 @@ import ( "fmt" ) -// ServerConfig configures the EAP server role. -type ServerConfig struct { - // Versions are the supported protocol versions, highest priority first. - // Defaults to []int{1}. - Versions []int - // Cryptosuites are the supported cryptosuites in decreasing priority. - // Defaults to the recommended order []int{2, 1}. - Cryptosuites []int - // Dirs is the supported OOB channel direction (1=peer-to-server, - // 2=server-to-peer, 3=both). Defaults to 3. - Dirs int - // ServerInfo is an optional application-defined JSON object sent to the - // peer in the Initial Exchange. - ServerInfo map[string]any - // NewNAI optionally assigns a new NAI to the peer. - NewNAI string - // SleepTime is an optional minimum probe interval advertised to the peer. - SleepTime int -} - // Server drives the EAP server side of one EAP-NOOB association. type Server struct { - cfg ServerConfig - state State + serverInfo json.RawMessage + state State methodState - - peerState int - verp int - csp int - dirp int } -// NewServer creates a server for one pairing using the given configuration. -func NewServer(cfg ServerConfig) *Server { - if len(cfg.Versions) == 0 { - cfg.Versions = []int{1} - } - if len(cfg.Cryptosuites) == 0 { - cfg.Cryptosuites = supportedCryptosuites() - } - if cfg.Dirs == 0 { - cfg.Dirs = 3 - } - return &Server{cfg: cfg, state: StateUnregistered} +// NewServer creates a PAIR inviter. serverInfo carries the node's identity and +// certificate; Completion authenticates it to the joiner. +func NewServer(serverInfo PairingInfo) *Server { + return &Server{serverInfo: marshalInfo(serverInfo), state: StateUnregistered} } // State returns the server's current association state. @@ -108,7 +75,6 @@ func (s *Server) onDiscovery(wm *wireMessage) (Outcome, error) { if wm.PeerState == nil { return s.fail(ErrInvalidData, "missing PeerState") } - s.peerState = *wm.PeerState if len(wm.NAI) > 0 { if nai, err := rawToString(wm.NAI); err == nil { s.nai = nai @@ -118,13 +84,13 @@ func (s *Server) onDiscovery(wm *wireMessage) (Outcome, error) { s.nai = defaultNAI } - ss, ps := int(s.state), s.peerState + ss, ps := s.state, State(*wm.PeerState) switch { - case (ss == 2 && (ps == 1 || ps == 2)) || (ps == 2 && (ss == 1 || ss == 2)): + case ss == StateWaiting && ps == StateOOBReceived: return s.startCompletion(wm) - case ss == 1 && ps == 1: + case ss == StateWaiting && ps == StateWaiting: return s.buildWaitingRequest() - case ss == 0 || ps == 0: + case ss == StateUnregistered || ps == StateUnregistered: return s.buildNegotiationRequest() default: return s.fail(ErrStateMismatch, fmt.Sprintf("server state %d / peer state %d", ss, ps)) @@ -138,10 +104,9 @@ func (s *Server) buildNegotiationRequest() (Outcome, error) { } s.peerId = peerId - vers := rawIntSlice(s.cfg.Versions) - cs := rawIntSlice(s.cfg.Cryptosuites) - dirs := rawInt(s.cfg.Dirs) - srvInfo := marshalInfo(s.cfg.ServerInfo) + vers := rawIntSlice([]int{protocolVersion}) + cs := rawIntSlice([]int{int(cryptosuiteP256), int(cryptosuiteX25519)}) + dirs := rawInt(oobDirection) m := &wireMessage{ Type: intp(typeNegotiation), @@ -149,18 +114,14 @@ func (s *Server) buildNegotiationRequest() (Outcome, error) { PeerId: rawString(peerId), Cryptosuites: cs, Dirs: dirs, - ServerInfo: srvInfo, - } - if s.cfg.NewNAI != "" { - m.NewNAI = rawString(s.cfg.NewNAI) - s.nai = s.cfg.NewNAI + ServerInfo: s.serverInfo, } s.in.Vers = vers s.in.PeerId = rawString(peerId) s.in.Cryptosuites = cs s.in.Dirs = dirs - s.in.ServerInfo = srvInfo + s.in.ServerInfo = s.serverInfo b, err := json.Marshal(m) if err != nil { @@ -177,21 +138,22 @@ func (s *Server) onNegotiation(wm *wireMessage) (Outcome, error) { if err != nil { return s.fail(ErrInvalidData, "bad Verp") } - if !containsInt(s.cfg.Versions, verp) { + if verp != protocolVersion { return s.fail(ErrUnsupportedVersion, "peer selected unsupported version") } - csp, err := rawToInt(wm.Cryptosuitep) + selectedSuite, err := rawToInt(wm.Cryptosuitep) if err != nil { return s.fail(ErrInvalidData, "bad Cryptosuitep") } - if !containsInt(s.cfg.Cryptosuites, csp) || !isSupportedCryptosuite(csp) { + csp := cryptosuiteID(selectedSuite) + if !isSupportedCryptosuite(csp) { return s.fail(ErrUnsupportedCryptosuite, "peer selected unsupported cryptosuite") } dirp, err := rawToInt(wm.Dirp) if err != nil { return s.fail(ErrInvalidData, "bad Dirp") } - if dirp&s.cfg.Dirs == 0 || dirp < 1 || dirp > 3 { + if dirp&oobDirection == 0 || dirp < 1 || dirp > 3 { return s.fail(ErrNoMutuallySupportedOOB, "no mutually supported OOB direction") } @@ -200,7 +162,6 @@ func (s *Server) onNegotiation(wm *wireMessage) (Outcome, error) { return s.fail(ErrUnsupportedCryptosuite, err.Error()) } s.suite = suite - s.verp, s.csp, s.dirp = verp, csp, dirp s.in.Verp = wm.Verp s.in.Cryptosuitep = wm.Cryptosuitep @@ -234,9 +195,6 @@ func (s *Server) buildKeyExchangeRequest() (Outcome, error) { PKs: pks, Ns: nsRaw, } - if s.cfg.SleepTime > 0 { - m.SleepTime = intp(s.cfg.SleepTime) - } s.in.PKs = pks s.in.Ns = nsRaw @@ -280,9 +238,6 @@ func (s *Server) onKeyExchange(wm *wireMessage) (Outcome, error) { func (s *Server) buildWaitingRequest() (Outcome, error) { m := &wireMessage{Type: intp(typeWaiting), PeerId: rawString(s.peerId)} - if s.cfg.SleepTime > 0 { - m.SleepTime = intp(s.cfg.SleepTime) - } b, err := json.Marshal(m) if err != nil { return Outcome{}, err @@ -304,17 +259,12 @@ func (s *Server) startCompletion(wm *wireMessage) (Outcome, error) { if len(s.noob) == 0 { return s.fail(ErrStateMismatch, "no OOB message processed") } - if s.peerState == 2 { - // Server-to-peer direction: discover the NoobId the peer accepted. - m := &wireMessage{Type: intp(typeNoobID), PeerId: rawString(s.peerId)} - b, err := json.Marshal(m) - if err != nil { - return Outcome{}, err - } - return Outcome{Send: b, State: s.state}, nil + m := &wireMessage{Type: intp(typeNoobID), PeerId: rawString(s.peerId)} + b, err := json.Marshal(m) + if err != nil { + return Outcome{}, err } - // Peer-to-server direction (peer still Waiting): send MACs directly. - return s.buildCompletionRequest() + return Outcome{Send: b, State: s.state}, nil } func (s *Server) onNoobID(wm *wireMessage) (Outcome, error) { @@ -368,16 +318,12 @@ func (s *Server) onCompletion(wm *wireMessage) (Outcome, error) { // OOBOutput produces the out-of-band message for the server-to-peer direction // (RFC 9140, Section 3.2.3). The server must have completed the Initial -// Exchange (Waiting state) and have negotiated a direction that includes -// server-to-peer. +// Exchange (Waiting state). func (s *Server) OOBOutput() (OOBMessage, error) { if s.state != StateWaiting { return OOBMessage{}, fmt.Errorf("eapnoob: OOBOutput requires Waiting state, have %s", s.state) } - if s.dirp != 2 && s.dirp != 3 { - return OOBMessage{}, fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") - } - return oobOutput(&s.methodState, 2) + return oobOutput(&s.methodState) } // OOBOutputWith records a caller-supplied 16-byte Noob for server-to-peer OOB. @@ -387,27 +333,7 @@ func (s *Server) OOBOutputWith(noob []byte) error { if s.state != StateWaiting { return fmt.Errorf("eapnoob: OOBOutputWith requires Waiting state, have %s", s.state) } - if s.dirp != 2 && s.dirp != 3 { - return fmt.Errorf("eapnoob: server-to-peer OOB not negotiated") - } - return oobInputNoob(&s.methodState, noob) -} - -// OOBInput accepts the out-of-band message for the peer-to-server direction and -// verifies its fingerprint (RFC 9140, Section 3.2.3). On success the server -// moves to the OOB Received state. -func (s *Server) OOBInput(msg OOBMessage) error { - if s.state != StateWaiting { - return fmt.Errorf("eapnoob: OOBInput requires Waiting state, have %s", s.state) - } - if s.dirp != 1 && s.dirp != 3 { - return fmt.Errorf("eapnoob: peer-to-server OOB not negotiated") - } - if err := oobInput(&s.methodState, 1, msg); err != nil { - return err - } - s.state = StateOOBReceived - return nil + return setOOBNoob(&s.methodState, noob) } func (s *Server) checkPeerId(wm *wireMessage) error { diff --git a/services/eap-noob/server_test.go b/services/eap-noob/server_test.go new file mode 100644 index 000000000..65cd499ce --- /dev/null +++ b/services/eap-noob/server_test.go @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package eapnoob + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServer_AdvertisesPAIRDefaults(t *testing.T) { + info := PairingInfo{ + V: 2, + NodeUUID: "server-uuid", + NodeID: "server-node", + Name: "server", + ClusterID: "test-cluster", + AdmissionEpoch: 9007199254740993, + ClusterFriendlyName: "Test cluster", + Addr: "server.example:14321", + Cert: "server-cert", + } + server := NewServer(info) + peer := NewPeer(PairingInfo{}) + request, err := server.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + negotiation, err := server.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + offer, err := decode(negotiation.Send) + require.NoError(t, err) + assert.Equal(t, json.RawMessage(`[1]`), offer.Vers) + assert.Equal(t, json.RawMessage(`[2,1]`), offer.Cryptosuites) + assert.Equal(t, json.RawMessage(`2`), offer.Dirs) + var receivedInfo PairingInfo + require.NoError(t, json.Unmarshal(offer.ServerInfo, &receivedInfo)) + assert.Equal(t, info, receivedInfo) + assert.Empty(t, offer.NewNAI) + assert.Nil(t, offer.SleepTime) +} + +func TestServerDiscovery_BindsIncomingNAIToAuthenticatedTranscript(t *testing.T) { + server := NewServer(PairingInfo{}) + negotiation, err := server.Receive([]byte(`{"Type":1,"PeerState":0,"NAI":"remote@pair.test"}`)) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + offer, err := decode(negotiation.Send) + require.NoError(t, err) + selection, _, err := encode(&wireMessage{ + Type: intp(typeNegotiation), + PeerId: offer.PeerId, + Verp: json.RawMessage(`1`), + Cryptosuitep: json.RawMessage(`2`), + Dirp: json.RawMessage(`2`), + PeerInfo: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + out, err := server.Receive(selection) + + require.NoError(t, err) + require.Nil(t, out.Err) + assert.Equal(t, json.RawMessage(`"remote@pair.test"`), server.in.NAI) +} + +func TestServerNegotiation_AcceptsSelectionsIncludingServerToPeer(t *testing.T) { + test := func(name, direction string) { + t.Run(name, func(t *testing.T) { + server := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + request, err := server.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + negotiation, err := server.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + selection, err := peer.Receive(negotiation.Send) + require.NoError(t, err) + require.Nil(t, selection.Err) + wire, err := decode(selection.Send) + require.NoError(t, err) + wire.Dirp = json.RawMessage(direction) + message, _, err := encode(wire) + require.NoError(t, err) + + out, err := server.Receive(message) + + require.NoError(t, err) + require.Nil(t, out.Err) + keyExchange, err := decode(out.Send) + require.NoError(t, err) + require.NotNil(t, keyExchange.Type) + assert.Equal(t, typeKeyExchange, *keyExchange.Type) + }) + } + test("server-to-peer selection", "2") + test("both-direction selection still includes server-to-peer", "3") +} + +func TestServerNegotiation_RejectsUnsupportedOrMismatchedSelections(t *testing.T) { + test := func(name string, change func(*wireMessage), wantCode int) { + t.Run(name, func(t *testing.T) { + server := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + request, err := server.Start() + require.NoError(t, err) + discovery, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, discovery.Err) + negotiation, err := server.Receive(discovery.Send) + require.NoError(t, err) + require.Nil(t, negotiation.Err) + response, err := peer.Receive(negotiation.Send) + require.NoError(t, err) + require.Nil(t, response.Err) + wire, err := decode(response.Send) + require.NoError(t, err) + change(wire) + changed, _, err := encode(wire) + require.NoError(t, err) + + out, err := server.Receive(changed) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, wantCode, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.NotEqual(t, StateRegistered, server.State()) + }) + } + test("unsupported version", func(wire *wireMessage) { + wire.Verp = json.RawMessage(`99`) + }, ErrUnsupportedVersion) + test("unsupported cryptosuite", func(wire *wireMessage) { + wire.Cryptosuitep = json.RawMessage(`99`) + }, ErrUnsupportedCryptosuite) + test("inverse OOB direction", func(wire *wireMessage) { + wire.Dirp = json.RawMessage(`1`) + }, ErrNoMutuallySupportedOOB) + test("mismatched peer identifier", func(wire *wireMessage) { + wire.PeerId = json.RawMessage(`"another-peer"`) + }, ErrUnexpectedPeerIdentier) +} + +func TestServer_RejectsNoobOutsideWaiting(t *testing.T) { + test := func(name string, registered bool) { + t.Run(name, func(t *testing.T) { + srv := NewServer(PairingInfo{}) + wantState := StateUnregistered + if registered { + srv, _ = pair(t, 2) + wantState = StateRegistered + } + + assert.Error(t, srv.OOBOutputWith(make([]byte, 16))) + assert.Equal(t, wantState, srv.State()) + }) + } + test("before initial exchange", false) + test("after successful Completion", true) +} + +func TestServer_RejectsMalformedJSON(t *testing.T) { + out, err := NewServer(PairingInfo{}).Receive([]byte(`{"Type":`)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrInvalidMessageStructure, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) +} diff --git a/services/eap-noob/state_test.go b/services/eap-noob/state_test.go deleted file mode 100644 index d1f9fd0a1..000000000 --- a/services/eap-noob/state_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package eapnoob - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPeer_RejectsSuccessBeforeVerifiedCompletion(t *testing.T) { - test := func(name string, state State) { - t.Run(name, func(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) - if state != StateUnregistered { - driveConversation(t, srv, peer) - } - if state == StateOOBReceived { - require.NoError(t, peer.OOBInputNoob(make([]byte, 16))) - } - require.Equal(t, state, peer.State()) - - out, err := peer.Receive(resultBytes(eapSuccess)) - - require.NoError(t, err) - require.NotNil(t, out.Err) - assert.Equal(t, ErrStateMismatch, out.Err.Code) - assert.True(t, out.Done) - assert.False(t, out.Success) - assert.Equal(t, state, peer.State()) - }) - } - test("before initial exchange", StateUnregistered) - test("before OOB input", StateWaiting) - test("after OOB input without Completion", StateOOBReceived) -} - -func TestOOB_RejectsNoobOutsideWaiting(t *testing.T) { - test := func(name string, registered bool) { - t.Run(name, func(t *testing.T) { - srv := NewServer(ServerConfig{Dirs: 2}) - peer := NewPeer(PeerConfig{PreferDir: 2}) - wantState := StateUnregistered - if registered { - srv, peer = pair(t, 2, 2) - wantState = StateRegistered - } - - assert.Error(t, srv.OOBOutputWith(make([]byte, 16))) - assert.Error(t, peer.OOBInputNoob(make([]byte, 16))) - assert.Equal(t, wantState, srv.State()) - assert.Equal(t, wantState, peer.State()) - }) - } - test("before initial exchange", false) - test("after successful Completion", true) -} diff --git a/services/nvpair-cluster-manager/admission_migration_test.go b/services/nvpair-cluster-manager/admission_migration_test.go index 18f6598fc..44897dca3 100644 --- a/services/nvpair-cluster-manager/admission_migration_test.go +++ b/services/nvpair-cluster-manager/admission_migration_test.go @@ -7,12 +7,14 @@ import ( "encoding/json" "testing" + "eapnoob" + "github.com/stretchr/testify/require" ) func TestLegacyPairingInfo_MapsToFirstAdmission(t *testing.T) { peer := newTestManagerPort(t, 15123) - raw, err := json.Marshal(PairingInfo{ + raw, err := json.Marshal(eapnoob.PairingInfo{ V: 1, NodeUUID: peer.identity.NodeUUID, NodeID: peer.identity.NodeID, diff --git a/services/nvpair-cluster-manager/cm_unit_test.go b/services/nvpair-cluster-manager/cm_unit_test.go index 25067e585..f0f4a2c9d 100644 --- a/services/nvpair-cluster-manager/cm_unit_test.go +++ b/services/nvpair-cluster-manager/cm_unit_test.go @@ -4,13 +4,10 @@ package main import ( - "bytes" - "encoding/json" "os" "path/filepath" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -35,71 +32,3 @@ func TestIdentity_RejectsReloadWhenPrivateKeyIsMissing(t *testing.T) { _, err = loadOrMintIdentity(dir) require.Error(t, err, "expected a loud failure when identity.json exists but the key is gone") } - -func TestPIN_EncodesExactNoobBytes(t *testing.T) { - test := func(name, pin string, want [16]byte) { - t.Run(name, func(t *testing.T) { - assert.Equal(t, want[:], noobFromPIN(pin)) - }) - } - test("all zeroes", "000000", [16]byte{}) - test("leading zeroes", "000123", [16]byte{15: 0x7b}) - test("ordinary PIN", "402199", [16]byte{13: 0x06, 14: 0x23, 15: 0x17}) - test("maximum PIN", "999999", [16]byte{13: 0x0f, 14: 0x42, 15: 0x3f}) -} - -func TestGeneratePIN_ReturnsMatchingNoob(t *testing.T) { - pin, noob, err := generatePIN() - require.NoError(t, err, "generatePIN") - assert.Regexp(t, pinPattern, pin) - assert.Equal(t, noobFromPIN(pin), noob) -} - -func TestRespondToInvite_RejectsMalformedPIN(t *testing.T) { - test := func(name, pinJSON string) { - t.Run(name, func(t *testing.T) { - m := newTestManager(t) - var response bytes.Buffer - m.codec = NewCodec(&response) - m.putInvite(&Invite{InviteID: "inv-pin", State: inviteStatePending}) - m.putSession(&pairingSession{inviteID: "inv-pin", role: roleJoiner}) - params, err := json.Marshal(struct { - InviteID string `json:"inviteId"` - Accept bool `json:"accept"` - Pin json.RawMessage `json:"pin,omitempty"` - }{InviteID: "inv-pin", Accept: true, Pin: json.RawMessage(pinJSON)}) - require.NoError(t, err) - id := json.RawMessage("1") - - m.handleRespondToInvite(&Message{ID: &id, Params: params}) - - var reply Message - require.NoError(t, json.Unmarshal(response.Bytes(), &reply)) - require.NotNil(t, reply.Error) - assert.Equal(t, codeInvalidParams, reply.Error.Code) - var data struct { - Field string `json:"field"` - } - require.NoError(t, json.Unmarshal(reply.Error.Data, &data)) - assert.Equal(t, "pin", data.Field) - invite, ok := m.getInvite("inv-pin") - require.True(t, ok) - assert.Equal(t, inviteStatePending, invite.State) - _, ok = m.getSession("inv-pin") - assert.True(t, ok, "malformed PIN must leave the pending pairing available") - }) - } - test("missing PIN", "") - test("null PIN", "null") - test("empty PIN", `""`) - test("five digits", `"00123"`) - test("seven digits", `"0000123"`) - test("alphabetic character", `"00012a"`) - test("leading space", `" 00123"`) - test("trailing space", `"00123 "`) - test("positive sign", `"+00123"`) - test("negative sign", `"-00123"`) - test("decimal point", `"001.23"`) - test("non-ASCII digits", `"000123"`) - test("trailing newline", `"00123\n"`) -} diff --git a/services/nvpair-cluster-manager/httpserver.go b/services/nvpair-cluster-manager/httpserver.go index f010e58bd..09dab3b82 100644 --- a/services/nvpair-cluster-manager/httpserver.go +++ b/services/nvpair-cluster-manager/httpserver.go @@ -168,16 +168,11 @@ func (m *Manager) handlePairingInitial(w http.ResponseWriter, env *pairingEnvelo http.Error(w, "reserve admission", http.StatusInternalServerError) return } - info, err := m.localPairingInfo(myAddr, admissionEpoch).toMap() - if err != nil { - http.Error(w, "build pairing info", http.StatusInternalServerError) - return - } sess = &pairingSession{ inviteID: env.InviteID, role: roleJoiner, createdAt: time.Now().UnixMilli(), - peer: newPairingPeer(info), + peer: eapnoob.NewPeer(m.localPairingInfo(myAddr, admissionEpoch)), localAdmissionEpoch: admissionEpoch, } registered, atCapacity := m.registerJoinerSession(sess) @@ -649,7 +644,7 @@ func (m *Manager) onInviterPaired(inviteID string, sess *pairingSession) error { // onInviterPaired so the commit and its abort path are unit-testable without a // full EAP-NOOB handshake (onInviterPaired's only extra job is parsing the // joiner PairingInfo off the completed eap-noob server). -func (m *Manager) finalizePairing(inviteID string, sess *pairingSession, pi *PairingInfo, cert *x509.Certificate) error { +func (m *Manager) finalizePairing(inviteID string, sess *pairingSession, pi *eapnoob.PairingInfo, cert *x509.Certificate) error { now := time.Now().UnixMilli() committed, pinErr := m.commitPairing(sess, pi, cert, now) if pinErr != nil { @@ -697,7 +692,7 @@ func (m *Manager) finalizePairing(inviteID string, sess *pairingSession, pi *Pai // // Either way it never resurrects a pin/member the removal was meant to drop. // Returns whether it committed and any pin error. -func (m *Manager) commitPairing(sess *pairingSession, pi *PairingInfo, cert *x509.Certificate, now int64) (committed bool, err error) { +func (m *Manager) commitPairing(sess *pairingSession, pi *eapnoob.PairingInfo, cert *x509.Certificate, now int64) (committed bool, err error) { m.withClusterComposition(func() { if m.teardownPending.Load() { err = fmt.Errorf("cluster teardown is pending") @@ -762,7 +757,7 @@ func testCommitDelay() { // pinPeer durably pins a peer's certificate in the trusted-node store, carrying // the endorsements that vouch for it (this node's own endorsement at pair time, // plus any gathered via gossip). -func (m *Manager) pinPeer(pi *PairingInfo, cert *x509.Certificate, endorsements []Endorsement) error { +func (m *Manager) pinPeer(pi *eapnoob.PairingInfo, cert *x509.Certificate, endorsements []Endorsement) error { return m.trust.Pin(&TrustedPin{ NodeUUID: pi.NodeUUID, NodeID: pi.NodeID, @@ -777,7 +772,7 @@ func (m *Manager) pinPeer(pi *PairingInfo, cert *x509.Certificate, endorsements } // recordMember upserts a ClusterNode for a peer described by its PairingInfo. -func (m *Manager) recordMember(pi *PairingInfo, state MembershipState, joinedAt *int64) { +func (m *Manager) recordMember(pi *eapnoob.PairingInfo, state MembershipState, joinedAt *int64) { host, port := splitAddr(pi.Addr, m.port) m.upsertMember(&ClusterNode{ ID: pi.NodeID, diff --git a/services/nvpair-cluster-manager/invite.go b/services/nvpair-cluster-manager/invite.go index a0541864d..3fa78a1ae 100644 --- a/services/nvpair-cluster-manager/invite.go +++ b/services/nvpair-cluster-manager/invite.go @@ -257,11 +257,7 @@ 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() - if err != nil { - return nil, "", fmt.Errorf("build pairing info: %w", err) - } - server := newPairingServer(info) + server := eapnoob.NewServer(m.localPairingInfo(myAddr)) // 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. diff --git a/services/nvpair-cluster-manager/invite_expiry_inbound_test.go b/services/nvpair-cluster-manager/invite_expiry_inbound_test.go index 7b184b086..ca6208935 100644 --- a/services/nvpair-cluster-manager/invite_expiry_inbound_test.go +++ b/services/nvpair-cluster-manager/invite_expiry_inbound_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "eapnoob" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,7 +36,7 @@ func putPendingJoinerSession(t *testing.T, m *Manager, inviteID, fromUUID, invit State: inviteStatePending, CreatedAt: createdAt, }) - pi := &PairingInfo{NodeUUID: fromUUID, NodeID: "inviter-node", ClusterID: "cluster-remote"} + pi := &eapnoob.PairingInfo{NodeUUID: fromUUID, NodeID: "inviter-node", ClusterID: "cluster-remote"} m.putSession(&pairingSession{ inviteID: inviteID, role: roleJoiner, diff --git a/services/nvpair-cluster-manager/leave_provenance_test.go b/services/nvpair-cluster-manager/leave_provenance_test.go index 53df7e8a8..bdcd6849d 100644 --- a/services/nvpair-cluster-manager/leave_provenance_test.go +++ b/services/nvpair-cluster-manager/leave_provenance_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "eapnoob" + "github.com/stretchr/testify/require" ) @@ -134,7 +136,7 @@ func TestExpirePendingInvite_ClearsBothSides(t *testing.T) { // The receiver holds the matching pending inbound invite + joiner session // (addr points at the inviter's pairing endpoint) + tentative member. receiver.setClusterIdentity("", "") - peer := &PairingInfo{ + peer := &eapnoob.PairingInfo{ NodeID: inviter.identity.NodeID, NodeUUID: inviter.identity.NodeUUID, Name: inviter.identity.Name, diff --git a/services/nvpair-cluster-manager/pairing.go b/services/nvpair-cluster-manager/pairing.go index 611092e40..d93c30ec8 100644 --- a/services/nvpair-cluster-manager/pairing.go +++ b/services/nvpair-cluster-manager/pairing.go @@ -29,25 +29,10 @@ const legacyAdmissionEpoch uint64 = 1 // pinPattern matches a well-formed six-digit PIN (§7.6). var pinPattern = regexp.MustCompile(`^[0-9]{6}$`) -// PairingInfo is the object each side puts in EAP-NOOB ServerInfo (inviter) / -// PeerInfo (joiner). It binds the node's cert and identity into the Hoob and the -// Completion MACs (§7.2). Identical schema both directions. -type PairingInfo struct { - V int `json:"v"` - NodeUUID string `json:"nodeUuid"` - NodeID string `json:"nodeId"` - Name string `json:"name"` - ClusterID string `json:"clusterId"` - AdmissionEpoch uint64 `json:"admissionEpoch,omitempty"` - ClusterFriendlyName string `json:"clusterFriendlyName"` - Addr string `json:"addr,omitempty"` - Cert string `json:"cert"` -} - // localPairingInfo builds this node's PairingInfo. addr is the inviter's // reachable host:port for the joiner-driven Completion Exchange; it is required // on the inviter's ServerInfo and optional on the joiner's PeerInfo. -func (m *Manager) localPairingInfo(addr string, admissionEpoch ...uint64) *PairingInfo { +func (m *Manager) localPairingInfo(addr string, admissionEpoch ...uint64) eapnoob.PairingInfo { cid, friendly := m.clusterIdentity() epoch := uint64(0) if len(admissionEpoch) > 0 { @@ -55,7 +40,7 @@ func (m *Manager) localPairingInfo(addr string, admissionEpoch ...uint64) *Pairi } else if admissionCID, current := m.currentAdmission(); admissionCID == cid { epoch = current } - return &PairingInfo{ + return eapnoob.PairingInfo{ V: pairingInfoVersion, NodeUUID: m.identity.NodeUUID, NodeID: m.identity.NodeID, @@ -68,25 +53,11 @@ func (m *Manager) localPairingInfo(addr string, admissionEpoch ...uint64) *Pairi } } -// 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) { - b, err := json.Marshal(pi) - if err != nil { - return nil, err - } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - return nil, err - } - return m, nil -} - // parsePairingInfo decodes a peer's PairingInfo from the authenticated EAP-NOOB // transcript and validates that the embedded cert's principal matches its // nodeUuid (§7.2: a PairingInfo whose cert CN/URI != nodeUuid is rejected). -func parsePairingInfo(raw []byte) (*PairingInfo, *x509.Certificate, error) { - var pi PairingInfo +func parsePairingInfo(raw []byte) (*eapnoob.PairingInfo, *x509.Certificate, error) { + var pi eapnoob.PairingInfo if err := json.Unmarshal(raw, &pi); err != nil { return nil, nil, fmt.Errorf("decode PairingInfo: %w", err) } @@ -137,18 +108,6 @@ func noobFromPIN(pin string) []byte { return b } -// newPairingServer builds the inviter-side EAP-NOOB Server, server-to-peer OOB -// only (Dirs=2), carrying this node's PairingInfo as ServerInfo. -func newPairingServer(info map[string]any) *eapnoob.Server { - return eapnoob.NewServer(eapnoob.ServerConfig{Dirs: 2, ServerInfo: info}) -} - -// newPairingPeer builds the joiner-side EAP-NOOB Peer preferring server-to-peer -// OOB (PreferDir=2), carrying this node's PairingInfo as PeerInfo. -func newPairingPeer(info map[string]any) *eapnoob.Peer { - return eapnoob.NewPeer(eapnoob.PeerConfig{PreferDir: 2, PeerInfo: info}) -} - // pairingRole fixes which EAP-NOOB role a session plays for its whole lifetime. type pairingRole int @@ -183,7 +142,7 @@ type pairingSession struct { // peerPairing / peerCert hold the other side's authenticated PairingInfo, // captured when the Initial Exchange completes (joiner) or at Registered // (inviter), for pinning and cluster adoption. - peerPairing *PairingInfo + peerPairing *eapnoob.PairingInfo peerCert *x509.Certificate // localAdmissionEpoch is this node's reserved admission incarnation carried // in its authenticated PairingInfo. It is activated only if the joiner diff --git a/services/nvpair-cluster-manager/pairing_teardown_test.go b/services/nvpair-cluster-manager/pairing_teardown_test.go index 3db290714..1ac0e7c4c 100644 --- a/services/nvpair-cluster-manager/pairing_teardown_test.go +++ b/services/nvpair-cluster-manager/pairing_teardown_test.go @@ -14,15 +14,17 @@ import ( "testing" "time" + "eapnoob" + "github.com/stretchr/testify/require" ) // pairingInfoFor builds an authenticated PairingInfo + parsed cert for a peer // fixture, standing in for the joiner's PeerInfo that onInviterPaired feeds to // commitPairing. -func pairingInfoFor(t *testing.T, f pinFixture) (*PairingInfo, *x509.Certificate) { +func pairingInfoFor(t *testing.T, f pinFixture) (*eapnoob.PairingInfo, *x509.Certificate) { t.Helper() - pi := &PairingInfo{ + pi := &eapnoob.PairingInfo{ V: pairingInfoVersion, NodeUUID: f.uuid, NodeID: "peer-2", diff --git a/services/nvpair-cluster-manager/pairing_test.go b/services/nvpair-cluster-manager/pairing_test.go new file mode 100644 index 000000000..61c9ec3bc --- /dev/null +++ b/services/nvpair-cluster-manager/pairing_test.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPIN_EncodesExactNoobBytes(t *testing.T) { + test := func(name, pin string, want [16]byte) { + t.Run(name, func(t *testing.T) { + assert.Equal(t, want[:], noobFromPIN(pin)) + }) + } + test("all zeroes", "000000", [16]byte{}) + test("leading zeroes", "000123", [16]byte{15: 0x7b}) + test("ordinary PIN", "402199", [16]byte{13: 0x06, 14: 0x23, 15: 0x17}) + test("maximum PIN", "999999", [16]byte{13: 0x0f, 14: 0x42, 15: 0x3f}) +} + +func TestGeneratePIN_ReturnsMatchingNoob(t *testing.T) { + pin, noob, err := generatePIN() + require.NoError(t, err, "generatePIN") + assert.Regexp(t, pinPattern, pin) + assert.Equal(t, noobFromPIN(pin), noob) +} diff --git a/services/nvpair-cluster-manager/respond_test.go b/services/nvpair-cluster-manager/respond_test.go new file mode 100644 index 000000000..dd7a86e6b --- /dev/null +++ b/services/nvpair-cluster-manager/respond_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRespondToInvite_RejectsMalformedPIN(t *testing.T) { + test := func(name, pinJSON string) { + t.Run(name, func(t *testing.T) { + m := newTestManager(t) + var response bytes.Buffer + m.codec = NewCodec(&response) + m.putInvite(&Invite{InviteID: "inv-pin", State: inviteStatePending}) + m.putSession(&pairingSession{inviteID: "inv-pin", role: roleJoiner}) + params, err := json.Marshal(struct { + InviteID string `json:"inviteId"` + Accept bool `json:"accept"` + Pin json.RawMessage `json:"pin,omitempty"` + }{InviteID: "inv-pin", Accept: true, Pin: json.RawMessage(pinJSON)}) + require.NoError(t, err) + id := json.RawMessage("1") + + m.handleRespondToInvite(&Message{ID: &id, Params: params}) + + var reply Message + require.NoError(t, json.Unmarshal(response.Bytes(), &reply)) + require.NotNil(t, reply.Error) + assert.Equal(t, codeInvalidParams, reply.Error.Code) + var data struct { + Field string `json:"field"` + } + require.NoError(t, json.Unmarshal(reply.Error.Data, &data)) + assert.Equal(t, "pin", data.Field) + invite, ok := m.getInvite("inv-pin") + require.True(t, ok) + assert.Equal(t, inviteStatePending, invite.State) + _, ok = m.getSession("inv-pin") + assert.True(t, ok, "malformed PIN must leave the pending pairing available") + }) + } + test("missing PIN", "") + test("null PIN", "null") + test("empty PIN", `""`) + test("five digits", `"00123"`) + test("seven digits", `"0000123"`) + test("alphabetic character", `"00012a"`) + test("leading space", `" 00123"`) + test("trailing space", `"00123 "`) + test("positive sign", `"+00123"`) + test("negative sign", `"-00123"`) + test("decimal point", `"001.23"`) + test("non-ASCII digits", `"000123"`) + test("trailing newline", `"00123\n"`) +} diff --git a/services/nvpair-cluster-manager/review_closure_test.go b/services/nvpair-cluster-manager/review_closure_test.go index cf0646f17..bf9d096c2 100644 --- a/services/nvpair-cluster-manager/review_closure_test.go +++ b/services/nvpair-cluster-manager/review_closure_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + "eapnoob" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,7 +30,7 @@ func TestInboundInitial_CannotPublishAfterTeardown(t *testing.T) { sess := &pairingSession{ inviteID: inviteID, role: roleJoiner, - peerPairing: &PairingInfo{ + peerPairing: &eapnoob.PairingInfo{ NodeUUID: peer.identity.NodeUUID, NodeID: peer.identity.NodeID, Name: peer.identity.Name, diff --git a/services/nvpair-cluster-manager/spec.md b/services/nvpair-cluster-manager/spec.md index a3da41a32..97d7fe6fd 100644 --- a/services/nvpair-cluster-manager/spec.md +++ b/services/nvpair-cluster-manager/spec.md @@ -369,7 +369,7 @@ Note the deliberate split: `ClusterNode.id` (hostname) stays the *logical/displa 1. **Bind the certs into the exchange.** The inviter (EAP-NOOB **Server**) puts its identity + **full cert PEM** in the EAP-NOOB `ServerInfo`; the joiner (**Peer**) puts the same in `PeerInfo`. Both objects are folded into the Completion `MACs`/`MACp` (`eap-noob/mac.go::noobArray`), so a successful pairing proves both sides agree on each other's exact cert — no separate encrypted channel is needed to ship them (certs are public; only integrity matters). 2. **Initial Exchange** (in-band, machine-to-machine): ephemeral ECDH; both reach `Waiting`. -3. **OOB step = the PIN.** OOB direction is **server-to-peer** (`ServerConfig.Dirs = 2`, `PeerConfig.PreferDir = 2`): the inviter (Server) produces the OOB value and displays it as the PIN; the joiner (Peer) consumes it. The PIN is the **only** thing carried out of band, by the human. Nothing is relayed in-band for the OOB step. Each side supplies the PIN-derived `Noob` to its local role; the Completion MACs prove agreement on the secret and transcript (see "PAIR-facing OOB API" below). +3. **OOB step = the PIN.** OOB direction is fixed to **server-to-peer** (direction 2): the inviter (Server) produces the OOB value and displays it as the PIN; the joiner (Peer) consumes it. The PIN is the **only** thing carried out of band, by the human. Nothing is relayed in-band for the OOB step. Each side supplies the PIN-derived `Noob` to its local role; the Completion MACs prove agreement on the secret and transcript (see "PAIR-facing OOB API" below). 4. **Completion Exchange** (in-band): MAC verification; both reach `Registered`. 5. **Pin.** Each side reads the peer's cert out of the now-authenticated `PairingInfo` (§7.2) and pins it (full-cert match, keyed by the peer's UUID) into the trusted-node store. The verified Completion authenticates the certificate information; no application-secret export is involved. @@ -380,7 +380,9 @@ The user entering the PIN is the out-of-band authorization — there is no silen - The EAP-NOOB **`Noob` is the fixed 16-byte big-endian encoding of that integer**: a 16-byte buffer, bytes `0..7` zero, bytes `8..15` = `binary.BigEndian.PutUint64(noob[8:], uint64(pinValue))`. (E.g. PIN `"402199"` → `pinValue = 402199` → `noob = 00 00 00 00 00 00 00 00 00 00 00 00 00 06 23 17`.) The base64url JSON form of these 16 bytes feeds `NoobId` and the Completion MACs; both nodes must use exactly the same encoding. - The **joiner** parses the typed PIN: require exactly six digits, `pinValue = strconv.Atoi`, re-encode the same 16 bytes, and feed that `Noob` to its Peer (see below). A non-six-digit / non-numeric entry is rejected locally before EAP-NOOB (no wasted attempt). -**PAIR-facing OOB API.** The in-tree `eap-noob` component serves PAIR's pairing requirements. Today's flow human-carries only the PIN, so each role accepts a caller-supplied 16-byte `Noob`. No `OOBMessage` is built, transmitted, or parsed by the cluster-manager: +**PAIR-facing OOB API.** The in-tree `eap-noob` component serves PAIR's pairing requirements. Its constructors, `NewServer(serverInfo PairingInfo)` and `NewPeer(peerInfo PairingInfo)`, accept the typed identity payload each role authenticates. `PairingInfo` is defined in `eap-noob/info.go` and uses the schema in §7.2; the cluster-manager passes it directly without a map conversion. Protocol policy is fixed to version 1, both cryptosuites (server priority `[2, 1]`), and server-to-peer OOB. The peer starts with the default NAI `noob@eap-noob.arpa`; server-side NAI assignment and probe-interval options are not exposed. Receive-side handling of server-supplied `NewNAI` and discovery NAI, including their authenticated transcript values, remains supported. + +Today's flow human-carries only the PIN, so each role accepts a caller-supplied 16-byte `Noob`. No `OOBMessage` is built, transmitted, or parsed by the cluster-manager: - **Inviter (Server):** `Server.OOBOutputWith(noob []byte) error` — set `s.noob` to the PIN-derived 16 bytes (instead of `rand.Read`), compute `s.noobID`, and stay in `Waiting`. (It needs only the side effect of registering the `Noob`; nothing is sent.) The CM calls this when it displays the PIN. - **Joiner (Peer):** `Peer.OOBInputNoob(noob []byte) error` — set `p.noob`/`p.noobID` from the PIN-derived 16 bytes and move to `OOBReceived`. No Hoob is computed or verified in this path; the Completion `MACs`/`MACp` authenticate the secret and transcript. The CM calls this on `cluster:respond-to-invite` with the typed PIN. @@ -389,7 +391,7 @@ Both PIN-facing methods validate the 16-byte length and the role's current state The server-to-peer `Server.OOBOutput()` / `Peer.OOBInput(OOBMessage)` path remains for PAIR's planned high-entropy pairing code (§4). It generates a random `Noob` and exchanges a full `OOBMessage{PeerId, Noob, Hoob}`, with the receiver verifying Hoob. Today's PIN pairing uses the caller-supplied `Noob` methods above. Two integration notes: -- **Pairing state and durable trust:** construct `NewServer(ServerConfig)` / `NewPeer(PeerConfig)` without an association store. `eap-noob` keeps only the state needed for the pairing execution and its Completion authentication keys, `Kms` and `Kmp`. It does not store completed associations or export application secrets. The cluster-manager owns durable trust and pins certs into its own `trusted/.json` files (below) from the authenticated `PairingInfo`. +- **Pairing state and durable trust:** `eap-noob` keeps only the state needed for the pairing execution and its Completion authentication keys, `Kms` and `Kmp`. It does not store completed associations or export application secrets. The cluster-manager owns durable trust and pins certs into its own `trusted/.json` files (below) from the authenticated `PairingInfo`. - **`ServerInfo`/`PeerInfo` size:** RFC 9140 suggests ≤ 500 bytes for these objects; a full leaf PEM exceeds that (more so for P-256 than Ed25519). The in-tree library does **not** enforce the cap and the transport is HTTP (no EAP/RADIUS fragmentation), so embedding the PEM works today — this is a deliberate, documented deviation. If the library is ever hardened to enforce the RFC limit, switch the embedded cert to DER/base64 or raise the cap; prefer **Ed25519 leaves** to stay smallest. > **⚠️ The six-digit PIN is temporary, low-entropy security debt (full analysis in §4):** it guards against passive eavesdroppers and accidental joins but not an active MITM, and must be replaced by a high-entropy pairing code before cluster trust is relied on. That upgrade changes only the OOB encoding and the library's `Noob` source — the EAP-NOOB transcript and pin format are unchanged. From 8c87c7cbb57d407a5490e277f03a9616af2824cc Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Wed, 28 Oct 2026 14:45:29 -0400 Subject: [PATCH 3/6] Organize pairing tests and document retained KDF Share negotiation setup and message drivers without removing test cases. Explain the typed pairing API and protocol terms, document required X25519 support and the retained standard-library-backed pairing KDF, and keep the usage example concise. Signed-off-by: Kaylee Lubick --- services/eap-noob/README.md | 157 ++++++++++++------------------ services/eap-noob/eapnoob_test.go | 42 ++++---- services/eap-noob/peer_test.go | 95 ++++++------------ services/eap-noob/pin_test.go | 36 +------ services/eap-noob/server_test.go | 55 +++-------- 5 files changed, 136 insertions(+), 249 deletions(-) diff --git a/services/eap-noob/README.md b/services/eap-noob/README.md index 03ba5b9c6..b6ff77f3c 100644 --- a/services/eap-noob/README.md +++ b/services/eap-noob/README.md @@ -5,54 +5,29 @@ SPDX-License-Identifier: Apache-2.0 # eapnoob -PAIR's pairing component implements the Initial, Waiting, and Completion -Exchanges of **EAP-NOOB** (RFC 9140, "Nimble Out-of-Band Authentication for EAP"). -The cluster-manager drives a `Server` and a `Peer` to authenticate the node -identity and certificate information exchanged during pairing. - -## What it does - -EAP-NOOB authenticates an ephemeral ECDH exchange using an out-of-band (OOB) -secret, `Noob`. PAIR currently obtains that secret from a six-digit PIN carried -by the user from the inviter to the joiner. The Completion MACs bind the secret -to the exchanged `ServerInfo` and `PeerInfo`, including both nodes' certificates. -After successful Completion, both roles reach `Registered` and the -cluster-manager pins the authenticated certificates in its own trusted-node -store. - -The component consumes and produces EAP-NOOB JSON messages. The cluster-manager -owns the transport, PIN validation and encoding, durable trust, and membership. -This module serves PAIR; it is not a general-purpose authentication or -application-secret export library. - -## Scope - -Implemented: - -- Common handshake, Initial Exchange, server-to-peer OOB Step, Waiting - Exchange, Completion Exchange. -- Cryptosuite 1 (Curve25519 / SHA-256, mandatory) and cryptosuite 2 - (NIST P-256 / SHA-256, recommended). -- NIST SP 800-56C one-step KDF, retaining the Completion MAC keys `Kms` and - `Kmp` at their RFC 9140 Table 5 offsets. -- Caller-supplied `Noob` for PAIR's PIN flow, plus the full server-to-peer - `OOBMessage` path reserved for PAIR's planned high-entropy pairing codes. -- Error notifications (Type=0) and the RFC error codes. - -Reconnect / rekeying exchanges (message types 7-9, KeyingMode 1-3) and AAA -roaming are not implemented. Completed associations and application keys are -not persisted by this module. - -## Cryptographic implementations - -ECDH, SHA-256, HMAC, and randomness use Go's standard library. Completion key -derivation retains the small in-tree NIST SP 800-56C implementation, built on -standard-library SHA-256, to avoid an additional runtime dependency. It preserves -the RFC 9140 nonce order, length-prefixed Noob, 320-byte output, and `Kms`/`Kmp` -offsets. - -The EAP-NOOB state machine and authenticated-transcript encoding remain in this -component. The cluster-manager owns certificate pinning and durable trust. +Pairs two PAIR nodes using the EAP-NOOB protocol (RFC 9140). The inviter is the +`Server`; the joiner is the `Peer`. The user carries a six-digit PIN from the +inviter to the joiner. + +Each side supplies a [`PairingInfo`](info.go) value containing its identity and +public certificate. Pairing verifies that both sides used the same PIN and saw +the same identity information. After success (`Registered`), the cluster manager +saves the verified certificates for future connections. + +This component processes JSON messages. The cluster manager handles networking, +PIN validation and encoding, certificate storage, and cluster membership. + +## Terms used in the code and specification + +| Term | Meaning | +| --- | --- | +| Cryptosuite | A set of algorithms used together. Suite 1 uses Curve25519 and SHA-256; suite 2 uses P-256 and SHA-256. | +| OOB (out of band) | A separate channel, such as the user copying the PIN between nodes. | +| NOOB | The 16-byte pairing secret, derived from the PIN in the pairing flow. | +| HOOB | A fingerprint of the pairing messages and NOOB, checked when transferring a full pairing code. The PIN flow does not use it. | +| NAI (Network Access Identifier) | The peer's protocol identity string, separate from its PAIR node UUID. | +| MAC (message authentication code) | A check made with a secret key to detect changed pairing data. | +| KDF (key derivation function) | Produces temporary authentication keys from shared secret material. | ## Usage @@ -70,12 +45,11 @@ peerInfo := eapnoob.PairingInfo{ srv := eapnoob.NewServer(serverInfo) peer := eapnoob.NewPeer(peerInfo) -// 1. Initial Exchange: relay each side's Outcome.Send to the other until done. -// (srv.Start() produces the first message.) +// 1. Exchange identities and temporary public keys: call srv.Start() and relay +// each side's Outcome.Send to the other until both are waiting for the PIN. -// 2. The cluster-manager validates and encodes the user-carried PIN as the -// same 16-byte noob on both sides. No OOBMessage is sent in the PIN flow. -// This fixture represents PIN 000123; production code generates a fresh PIN. +// 2. Each side locally encodes the user-carried PIN as the same 16-byte NOOB. +// This example uses PIN 000123; production code generates a fresh PIN. noob := make([]byte, 16) noob[15] = 123 if err := srv.OOBOutputWith(noob); err != nil { @@ -85,65 +59,60 @@ if err := peer.OOBInputNoob(noob); err != nil { return err } -// 3. Completion Exchange: call srv.Start() again and relay messages until -// both reach Registered. +// 3. Verify the PIN and finish pairing: call srv.Start() again and relay +// messages until both reach Registered. -// 4. After successful Completion, the cluster-manager reads srv.PeerInfo() -// and peer.ServerInfo() and pins the authenticated certificates. +// 4. After successful pairing, read srv.PeerInfo() and peer.ServerInfo(). +// The cluster manager saves their verified certificates for future connections. ``` -See [`example_test.go`](example_test.go) for a complete, runnable driver loop. +See [`example_test.go`](example_test.go) for a complete, runnable message loop. +The shortened identities above omit fields; PAIR supplies the full `PairingInfo`, +including each node's public certificate. -### Driving the message loop +### Processing messages -Each role processes one inbound message at a time: +Pass each incoming message to `Receive` and relay any response to the other node: ```go out, err := peer.Receive(msg) // or srv.Receive(msg) -// out.Send -> bytes to deliver to the other party (nil if none) -// out.State -> current association state -// out.Done -> this EAP conversation ended -// out.Success -> Completion succeeded; association is Registered -// out.Err -> an EAP-NOOB error notification was sent/received +// err -> a processing error +// out.Send -> response bytes to deliver (nil if none) +// out.State -> current pairing state +// out.Done -> this round of messages ended +// out.Success -> pairing completed successfully +// out.Err -> a protocol error reported by either side ``` -EAP-NOOB spans two EAP conversations with the OOB step in between: run one -conversation for the Initial Exchange, perform the OOB relay, then run a second -conversation for the Completion Exchange (call `srv.Start()` again to begin it). +`Done` alone does not mean pairing succeeded: the first round ends while waiting +for the PIN. Keep both objects alive, then call `srv.Start()` again after the PIN +has been supplied to finish pairing. -### Pairing policy +## Implementation scope -`NewServer(serverInfo PairingInfo)` and `NewPeer(peerInfo PairingInfo)` accept -only the identity information each role authenticates. Protocol policy -is fixed to version 1, both supported cryptosuites, and server-to-peer OOB -(direction 2). The server advertises suites in priority order `[2, 1]`; peers -still negotiate a mutually supported suite. +Protocol settings are fixed to version 1 and inviter-to-joiner PIN transfer. +PAIR-to-PAIR pairing selects P-256 (suite 2). X25519 (suite 1) is retained because +[RFC 9140 §5.1](https://www.rfc-editor.org/rfc/rfc9140.html#section-5.1) requires +implementations to support it. It remains available when negotiating with other +peers. Reconnection and key renewal are not implemented. -The peer starts with `noob@eap-noob.arpa` as its NAI. Receive-side handling of a -server-supplied `NewNAI` and discovery NAI is preserved, including their use in -the authenticated transcript. There are no public NAI assignment or probe -interval options. +Go's standard library supplies the cryptographic operations. A small local +[KDF implementation](kdf.go) derives the authentication keys without another +runtime dependency. -### OOB flow - -The PIN flow calls `Server.OOBOutputWith(noob)` to -register the inviter's secret while staying in `Waiting`, and -`Peer.OOBInputNoob(noob)` to move the joiner to `OOBReceived`. Neither method -constructs or verifies a Hoob fingerprint; transcript agreement is proved by -the Completion MACs. - -`Server.OOBOutput()` and `Peer.OOBInput(OOBMessage)` retain random `Noob` -generation and Hoob verification for the planned high-entropy OOB flow. That -flow carries the full `OOBMessage{PeerId, Noob, Hoob}` through the user-assisted -channel; it is not used by today's PIN pairing. +`Server.OOBOutput()` and `Peer.OOBInput(OOBMessage)` also support transferring a +random NOOB with its HOOB for planned pairing codes. Today's PIN flow uses +`Server.OOBOutputWith` and `Peer.OOBInputNoob` instead. See the +[cluster-manager specification](../nvpair-cluster-manager/spec.md) for protocol +details and PIN encoding. ## Testing +Run from this directory: + ```sh go test ./... ``` -The suite exercises both cryptosuites, PAIR's caller-supplied `Noob` flow, -Completion authentication, JWK round-trips, and OOB fingerprint -rejection. The cluster-manager tests cover PIN encoding and the durable trust -boundary. +Tests cover successful pairing with both cryptosuites, incorrect secrets, changed +identity information, and invalid fingerprints. diff --git a/services/eap-noob/eapnoob_test.go b/services/eap-noob/eapnoob_test.go index 4f74d5087..cd4193960 100644 --- a/services/eap-noob/eapnoob_test.go +++ b/services/eap-noob/eapnoob_test.go @@ -11,20 +11,19 @@ import ( "github.com/stretchr/testify/require" ) -// driveConversation relays messages between the server and peer for one EAP -// conversation, starting from the server's first request, until a side has -// nothing more to send. -func driveConversation(t *testing.T, srv *Server, peer *Peer) { +// driveConversation starts one message exchange and returns its final outcome. +func driveConversation(t *testing.T, srv *Server, peer *Peer) Outcome { t.Helper() msg, err := srv.Start() require.NoError(t, err, "server Start:") - driveMessages(t, srv, peer, msg) + return driveMessages(t, srv, peer, msg) } -func driveMessages(t *testing.T, srv *Server, peer *Peer, msg []byte) { +func driveMessages(t *testing.T, srv *Server, peer *Peer, msg []byte) Outcome { t.Helper() peerTurn := true - for { + for rounds := 0; ; rounds++ { + require.Less(t, rounds, 16, "message exchange did not terminate") var out Outcome var err error if peerTurn { @@ -33,19 +32,16 @@ func driveMessages(t *testing.T, srv *Server, peer *Peer, msg []byte) { out, err = srv.Receive(msg) } require.NoError(t, err, "Receive:") - require.Nil(t, out.Err, "unexpected protocol error: %v", out.Err) - if len(out.Send) == 0 { - return + if out.Err != nil || len(out.Send) == 0 { + return out } msg = out.Send peerTurn = !peerTurn } } -// driveInitialWithSuite supplies a single-suite offer as a remote-protocol -// fixture. Production always advertises both suites; tests can exercise each -// negotiated suite without adding configuration to the application API. -func driveInitialWithSuite(t *testing.T, srv *Server, peer *Peer, suite cryptosuiteID) { +// negotiationRequest runs discovery and returns the server's negotiation request. +func negotiationRequest(t *testing.T, srv *Server, peer *Peer) []byte { t.Helper() request, err := srv.Start() require.NoError(t, err) @@ -55,13 +51,21 @@ func driveInitialWithSuite(t *testing.T, srv *Server, peer *Peer, suite cryptosu negotiation, err := srv.Receive(discovery.Send) require.NoError(t, err) require.Nil(t, negotiation.Err) - wire, err := decode(negotiation.Send) + return negotiation.Send +} + +// driveInitialWithSuite supplies a single-suite offer as a remote-protocol +// fixture. Production always advertises both suites; tests can exercise each +// negotiated suite without adding configuration to the application API. +func driveInitialWithSuite(t *testing.T, srv *Server, peer *Peer, suite cryptosuiteID) { + t.Helper() + wire, err := decode(negotiationRequest(t, srv, peer)) require.NoError(t, err) wire.Cryptosuites = rawIntSlice([]int{int(suite)}) request, parsed, err := encode(wire) require.NoError(t, err) srv.in.Cryptosuites = parsed.Cryptosuites - driveMessages(t, srv, peer, request) + require.Nil(t, driveMessages(t, srv, peer, request).Err) } // pair runs a full server-to-peer pairing for the given cryptosuite. @@ -82,7 +86,7 @@ func pair(t *testing.T, suite cryptosuiteID) (*Server, *Peer) { require.Equal(t, StateOOBReceived, peer.State()) // Completion Exchange. - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) require.Equal(t, StateRegistered, srv.State(), "server after Completion") require.Equal(t, StateRegistered, peer.State(), "peer after Completion") return srv, peer @@ -110,7 +114,7 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) oob, err := srv.OOBOutput() require.NoError(t, err, "OOBOutput:") require.NoError(t, peer.OOBInput(oob), "OOBInput:") @@ -146,7 +150,7 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { func TestPairing_RejectsIncorrectOutOfBandFingerprint(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) oob, err := srv.OOBOutput() require.NoError(t, err, "OOBOutput:") diff --git a/services/eap-noob/peer_test.go b/services/eap-noob/peer_test.go index b3654e28f..d02eb0214 100644 --- a/services/eap-noob/peer_test.go +++ b/services/eap-noob/peer_test.go @@ -16,15 +16,7 @@ func peerNegotiationOffer(t *testing.T) (*Peer, *wireMessage) { t.Helper() server := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) - request, err := server.Start() - require.NoError(t, err) - discovery, err := peer.Receive(request) - require.NoError(t, err) - require.Nil(t, discovery.Err) - negotiation, err := server.Receive(discovery.Send) - require.NoError(t, err) - require.Nil(t, negotiation.Err) - offer, err := decode(negotiation.Send) + offer, err := decode(negotiationRequest(t, server, peer)) require.NoError(t, err) return peer, offer } @@ -154,7 +146,7 @@ func TestPeer_RejectsSuccessBeforeVerifiedCompletion(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) if state != StateUnregistered { - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) } if state == StateOOBReceived { require.NoError(t, peer.OOBInputNoob(make([]byte, 16))) @@ -200,70 +192,41 @@ func TestPeer_RejectsChangedAuthenticatedCertificateInformation(t *testing.T) { server := NewServer(PairingInfo{Cert: "original-server-cert"}) peer := NewPeer(PairingInfo{Cert: "original-peer-cert"}) - msg, err := server.Start() + offer, err := decode(negotiationRequest(t, server, peer)) + require.NoError(t, err) + if len(changedServerInfo) != 0 { + offer.ServerInfo = changedServerInfo + } + request, _, err := encode(offer) require.NoError(t, err) - peerTurn := true - for rounds := 0; ; rounds++ { - require.Less(t, rounds, 16, "Initial Exchange did not terminate") - wire, err := decode(msg) - require.NoError(t, err) - if wire.Type != nil && *wire.Type == typeNegotiation { - if peerTurn && len(changedServerInfo) != 0 { - wire.ServerInfo = changedServerInfo - msg, _, err = encode(wire) - require.NoError(t, err) - } - if !peerTurn && len(changedPeerInfo) != 0 { - wire.PeerInfo = changedPeerInfo - msg, _, err = encode(wire) - require.NoError(t, err) - } - } - var out Outcome - if peerTurn { - out, err = peer.Receive(msg) - } else { - out, err = server.Receive(msg) - } - require.NoError(t, err) - require.Nil(t, out.Err) - if len(out.Send) == 0 { - break - } - msg = out.Send - peerTurn = !peerTurn + selection, err := peer.Receive(request) + require.NoError(t, err) + require.Nil(t, selection.Err) + wire, err := decode(selection.Send) + require.NoError(t, err) + if len(changedPeerInfo) != 0 { + wire.PeerInfo = changedPeerInfo } + response, _, err := encode(wire) + require.NoError(t, err) + keyExchange, err := server.Receive(response) + require.NoError(t, err) + require.Nil(t, keyExchange.Err) + require.Nil(t, driveMessages(t, server, peer, keyExchange.Send).Err) require.Equal(t, StateWaiting, server.State()) require.Equal(t, StateWaiting, peer.State()) noob := bytes.Repeat([]byte{0xAB}, 16) require.NoError(t, server.OOBOutputWith(noob)) require.NoError(t, peer.OOBInputNoob(noob)) - msg, err = server.Start() - require.NoError(t, err) - peerTurn = true - for rounds := 0; ; rounds++ { - require.Less(t, rounds, 16, "Completion Exchange did not terminate") - var out Outcome - if peerTurn { - out, err = peer.Receive(msg) - } else { - out, err = server.Receive(msg) - } - require.NoError(t, err) - if out.Err != nil { - assert.True(t, peerTurn, "peer must reject the server's transcript MAC") - assert.Equal(t, ErrHMACVerificationFailed, out.Err.Code) - assert.False(t, out.Success) - assert.NotEqual(t, StateRegistered, peer.State()) - assert.NotEqual(t, StateRegistered, server.State()) - return - } - require.False(t, out.Done, "Completion succeeded despite changed certificate information") - require.NotEmpty(t, out.Send) - msg = out.Send - peerTurn = !peerTurn - } + out := driveConversation(t, server, peer) + + require.NotNil(t, out.Err) + assert.Equal(t, StateOOBReceived, out.State, "peer must reject the server's transcript MAC") + assert.Equal(t, ErrHMACVerificationFailed, out.Err.Code) + assert.False(t, out.Success) + assert.NotEqual(t, StateRegistered, peer.State()) + assert.NotEqual(t, StateRegistered, server.State()) }) } test("changed server certificate", json.RawMessage(`{"cert":"changed-server-cert"}`), nil) diff --git a/services/eap-noob/pin_test.go b/services/eap-noob/pin_test.go index 6ffb3fc2b..280207288 100644 --- a/services/eap-noob/pin_test.go +++ b/services/eap-noob/pin_test.go @@ -11,32 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -// driveAllowFail relays one EAP conversation, returning true if either side -// produced a protocol error (e.g. a Completion MAC mismatch) rather than t-Fatal. -func driveAllowFail(srv *Server, peer *Peer) bool { - msg, err := srv.Start() - if err != nil { - return true - } - peerTurn := true - for { - var out Outcome - if peerTurn { - out, err = peer.Receive(msg) - } else { - out, err = srv.Receive(msg) - } - if err != nil || out.Err != nil { - return true - } - if len(out.Send) == 0 { - return false - } - msg = out.Send - peerTurn = !peerTurn - } -} - // TestCallerInjectedNoobPairs drives a full pairing in the server-to-peer // direction using the caller-injected-Noob API (the path the cluster manager's // PIN flow takes): the server supplies the Noob via OOBOutputWith and the peer @@ -56,7 +30,7 @@ func TestPairing_AcceptsMatchingCallerProvidedNoob(t *testing.T) { require.NoError(t, peer.OOBInputNoob(noob)) require.Equal(t, StateOOBReceived, peer.State()) - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) require.Equal(t, StateRegistered, srv.State()) require.Equal(t, StateRegistered, peer.State()) assert.Equal(t, srv.serverInfo, peer.ServerInfo()) @@ -73,12 +47,12 @@ func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") require.NoError(t, peer.OOBInputNoob(bytes.Repeat([]byte{0x02}, 16)), "peer OOBInputNoob:") - assert.True(t, driveAllowFail(srv, peer), "expected the Completion Exchange to fail with mismatched Noobs") + require.NotNil(t, driveConversation(t, srv, peer).Err, "expected the Completion Exchange to fail with mismatched NOOB values") assert.NotEqual(t, StateRegistered, peer.State(), "a side reached Registered despite mismatched Noobs") assert.NotEqual(t, StateRegistered, srv.State(), "a side reached Registered despite mismatched Noobs") } @@ -98,7 +72,7 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { peer := NewPeer(PairingInfo{}) // Initial Exchange. - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) // Server-to-peer OOB with MISMATCHED Noobs — the wrong-PIN condition. require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") @@ -133,6 +107,6 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { func TestOOBOutputWith_RejectsBadLength(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) - driveConversation(t, srv, peer) + require.Nil(t, driveConversation(t, srv, peer).Err) require.Error(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 8)), "expected OOBOutputWith to reject an 8-byte Noob") } diff --git a/services/eap-noob/server_test.go b/services/eap-noob/server_test.go index 65cd499ce..825f5df12 100644 --- a/services/eap-noob/server_test.go +++ b/services/eap-noob/server_test.go @@ -11,6 +11,19 @@ import ( "github.com/stretchr/testify/require" ) +// serverNegotiationSelection returns a fresh server and a valid peer selection. +func serverNegotiationSelection(t *testing.T) (*Server, *wireMessage) { + t.Helper() + server := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + selection, err := peer.Receive(negotiationRequest(t, server, peer)) + require.NoError(t, err) + require.Nil(t, selection.Err) + wire, err := decode(selection.Send) + require.NoError(t, err) + return server, wire +} + func TestServer_AdvertisesPAIRDefaults(t *testing.T) { info := PairingInfo{ V: 2, @@ -25,15 +38,7 @@ func TestServer_AdvertisesPAIRDefaults(t *testing.T) { } server := NewServer(info) peer := NewPeer(PairingInfo{}) - request, err := server.Start() - require.NoError(t, err) - discovery, err := peer.Receive(request) - require.NoError(t, err) - require.Nil(t, discovery.Err) - negotiation, err := server.Receive(discovery.Send) - require.NoError(t, err) - require.Nil(t, negotiation.Err) - offer, err := decode(negotiation.Send) + offer, err := decode(negotiationRequest(t, server, peer)) require.NoError(t, err) assert.Equal(t, json.RawMessage(`[1]`), offer.Vers) assert.Equal(t, json.RawMessage(`[2,1]`), offer.Cryptosuites) @@ -72,21 +77,7 @@ func TestServerDiscovery_BindsIncomingNAIToAuthenticatedTranscript(t *testing.T) func TestServerNegotiation_AcceptsSelectionsIncludingServerToPeer(t *testing.T) { test := func(name, direction string) { t.Run(name, func(t *testing.T) { - server := NewServer(PairingInfo{}) - peer := NewPeer(PairingInfo{}) - request, err := server.Start() - require.NoError(t, err) - discovery, err := peer.Receive(request) - require.NoError(t, err) - require.Nil(t, discovery.Err) - negotiation, err := server.Receive(discovery.Send) - require.NoError(t, err) - require.Nil(t, negotiation.Err) - selection, err := peer.Receive(negotiation.Send) - require.NoError(t, err) - require.Nil(t, selection.Err) - wire, err := decode(selection.Send) - require.NoError(t, err) + server, wire := serverNegotiationSelection(t) wire.Dirp = json.RawMessage(direction) message, _, err := encode(wire) require.NoError(t, err) @@ -108,21 +99,7 @@ func TestServerNegotiation_AcceptsSelectionsIncludingServerToPeer(t *testing.T) func TestServerNegotiation_RejectsUnsupportedOrMismatchedSelections(t *testing.T) { test := func(name string, change func(*wireMessage), wantCode int) { t.Run(name, func(t *testing.T) { - server := NewServer(PairingInfo{}) - peer := NewPeer(PairingInfo{}) - request, err := server.Start() - require.NoError(t, err) - discovery, err := peer.Receive(request) - require.NoError(t, err) - require.Nil(t, discovery.Err) - negotiation, err := server.Receive(discovery.Send) - require.NoError(t, err) - require.Nil(t, negotiation.Err) - response, err := peer.Receive(negotiation.Send) - require.NoError(t, err) - require.Nil(t, response.Err) - wire, err := decode(response.Send) - require.NoError(t, err) + server, wire := serverNegotiationSelection(t) change(wire) changed, _, err := encode(wire) require.NoError(t, err) From 6c8fa905043c0492032cfbcae90b2a09fb50801c Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Wed, 28 Oct 2026 14:54:44 -0400 Subject: [PATCH 4/6] Reject pairing completion before authentication keys are ready An out-of-order Completion response could reach the inviter after the Initial Exchange but before the PIN was supplied and authentication keys were derived. onCompletion checked MACp using the still-empty Kmp and could mark pairing Registered when given an empty-key MAC over the public transcript. Require the server to be Waiting with a selected cryptosuite and a derived peer-authentication key before processing Completion. Reject premature responses with ErrStateMismatch. Add regression coverage for X25519 and P-256. Both cases fail without the guard and pass with it. The eap-noob suite, affected cluster-manager tests, and successful-pairing and wrong-PIN integration tests pass. Signed-off-by: Kaylee Lubick --- services/eap-noob/server.go | 3 +++ services/eap-noob/server_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/services/eap-noob/server.go b/services/eap-noob/server.go index 50a4c2175..1f790af5a 100644 --- a/services/eap-noob/server.go +++ b/services/eap-noob/server.go @@ -300,6 +300,9 @@ func (s *Server) buildCompletionRequest() (Outcome, error) { } func (s *Server) onCompletion(wm *wireMessage) (Outcome, error) { + if s.state != StateWaiting || s.suite == nil || len(s.keyM.Kmp) == 0 { + return s.fail(ErrStateMismatch, "completion before authentication keys are ready") + } if err := s.checkPeerId(wm); err != nil { return s.fail(ErrUnexpectedPeerIdentier, err.Error()) } diff --git a/services/eap-noob/server_test.go b/services/eap-noob/server_test.go index 825f5df12..71b766059 100644 --- a/services/eap-noob/server_test.go +++ b/services/eap-noob/server_test.go @@ -155,3 +155,38 @@ func TestServer_RejectsMalformedJSON(t *testing.T) { assert.True(t, out.Done) assert.False(t, out.Success) } + +// A completion message can arrive while the server is waiting for the PIN, +// before its authentication keys exist. We should reject in that case +func TestServer_RejectsCompletionBeforeKeysAreDerived(t *testing.T) { + test := func(name string, suite cryptosuiteID) { + t.Run(name, func(t *testing.T) { + server := NewServer(PairingInfo{}) + peer := NewPeer(PairingInfo{}) + driveInitialWithSuite(t, server, peer, suite) + require.Equal(t, StateWaiting, server.State()) + require.Empty(t, server.keyM.Kmp) + + // Before PIN verification, the transcript is public and an empty-key + // MAC is computable without knowing the PIN. It must not grant trust. + macp := server.suite.computeMAC(nil, 1, server.in) + message, _, err := encode(&wireMessage{ + Type: intp(typeCompletion), + PeerId: rawString(server.peerId), + MACp: rawString(b64Encode(macp)), + }) + require.NoError(t, err) + + out, err := server.Receive(message) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrStateMismatch, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.Equal(t, StateWaiting, server.State()) + }) + } + test("X25519", cryptosuiteX25519) + test("P-256", cryptosuiteP256) +} From 4f8b050071a2ac08adae0473e2cc801da16c3c63 Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Wed, 28 Oct 2026 15:10:40 -0400 Subject: [PATCH 5/6] Reject out-of-order pairing messages and release failed sessions A sender could skip negotiation and send a key-exchange message before an algorithm was selected. Both pairing roles could dereference a nil algorithm. On the joiner, this happened while holding the pairing session mutex: HTTP panic recovery left it locked, so expiry could never reclaim the session. Repeating the sequence could exhaust the 64 pending-session slots and block pairing until restart. On the inviter, a malformed response was processed outside HTTP panic recovery and could crash the cluster manager. Require negotiation before either role processes key exchange, and require the inviter's private key and the initial-exchange state. Scope joiner message processing with a deferred session unlock, remove sessions on terminal protocol errors as well as processing errors, and notify after unlocking. Add focused regressions for both roles and HTTP session cleanup. The new inviter regression reproduces the panic before the guard and passes with it. The eap-noob and cluster-manager suites and successful-pairing and wrong-PIN integration tests pass. Signed-off-by: Kaylee Lubick --- services/eap-noob/peer.go | 3 ++ services/eap-noob/peer_test.go | 18 ++++++++ services/eap-noob/server.go | 3 ++ services/eap-noob/server_test.go | 18 ++++++++ services/nvpair-cluster-manager/httpserver.go | 24 ++++++---- .../nvpair-cluster-manager/httpserver_test.go | 45 +++++++++++++++++++ 6 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 services/nvpair-cluster-manager/httpserver_test.go diff --git a/services/eap-noob/peer.go b/services/eap-noob/peer.go index cb890db20..41906d291 100644 --- a/services/eap-noob/peer.go +++ b/services/eap-noob/peer.go @@ -176,6 +176,9 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { } func (p *Peer) onKeyExchangeRequest(wm *wireMessage) (Outcome, error) { + if p.state != StateUnregistered || p.suite == nil { + return p.fail(ErrStateMismatch, "key exchange requires negotiation during the initial exchange") + } jwk, err := rawToJWK(wm.PKs) if err != nil { return p.fail(ErrInvalidData, "bad PKs") diff --git a/services/eap-noob/peer_test.go b/services/eap-noob/peer_test.go index d02eb0214..c17ffe64c 100644 --- a/services/eap-noob/peer_test.go +++ b/services/eap-noob/peer_test.go @@ -242,3 +242,21 @@ func TestPeer_RejectsMalformedJSON(t *testing.T) { assert.True(t, out.Done) assert.False(t, out.Success) } + +// A sender can skip negotiation after discovery. Reject its key-exchange +// message before accessing the algorithm that negotiation would have selected. +func TestPeer_RejectsKeyExchangeBeforeNegotiation(t *testing.T) { + peer := NewPeer(PairingInfo{}) + discovery, err := peer.Receive([]byte(`{"Type":1}`)) + require.NoError(t, err) + require.Nil(t, discovery.Err) + + out, err := peer.Receive([]byte(`{"Type":3,"PKs":{},"Ns":""}`)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrStateMismatch, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.Equal(t, StateUnregistered, peer.State()) +} diff --git a/services/eap-noob/server.go b/services/eap-noob/server.go index 1f790af5a..c5663bdeb 100644 --- a/services/eap-noob/server.go +++ b/services/eap-noob/server.go @@ -207,6 +207,9 @@ func (s *Server) buildKeyExchangeRequest() (Outcome, error) { } func (s *Server) onKeyExchange(wm *wireMessage) (Outcome, error) { + if s.state != StateUnregistered || s.suite == nil || s.priv == nil { + return s.fail(ErrStateMismatch, "key exchange requires negotiation during the initial exchange") + } if err := s.checkPeerId(wm); err != nil { return s.fail(ErrUnexpectedPeerIdentier, err.Error()) } diff --git a/services/eap-noob/server_test.go b/services/eap-noob/server_test.go index 71b766059..03ec72dcd 100644 --- a/services/eap-noob/server_test.go +++ b/services/eap-noob/server_test.go @@ -190,3 +190,21 @@ func TestServer_RejectsCompletionBeforeKeysAreDerived(t *testing.T) { test("X25519", cryptosuiteX25519) test("P-256", cryptosuiteP256) } + +// A node can answer discovery with a key-exchange response before negotiation +// selects an algorithm and creates the server's private key. Rejecting that +// response prevents a malformed pairing attempt from crashing the inviter. +func TestServer_RejectsKeyExchangeBeforeNegotiation(t *testing.T) { + server := NewServer(PairingInfo{}) + _, err := server.Start() + require.NoError(t, err) + + out, err := server.Receive([]byte(`{"Type":3,"PKp":{},"Np":""}`)) + + require.NoError(t, err) + require.NotNil(t, out.Err) + assert.Equal(t, ErrStateMismatch, out.Err.Code) + assert.True(t, out.Done) + assert.False(t, out.Success) + assert.Equal(t, StateUnregistered, server.State()) +} diff --git a/services/nvpair-cluster-manager/httpserver.go b/services/nvpair-cluster-manager/httpserver.go index 09dab3b82..eadcaed11 100644 --- a/services/nvpair-cluster-manager/httpserver.go +++ b/services/nvpair-cluster-manager/httpserver.go @@ -190,19 +190,25 @@ func (m *Manager) handlePairingInitial(w http.ResponseWriter, env *pairingEnvelo return } - sess.mu.Lock() - out, err := sess.peer.Receive(msg) + var completed *Invite + // Scope the lock so a panic releases it and notifications run after unlocking. + out, err := func() (eapnoob.Outcome, error) { + sess.mu.Lock() + defer sess.mu.Unlock() + out, err := sess.peer.Receive(msg) + if err != nil || out.Err != nil { + m.deleteSession(env.InviteID) + return out, err + } + if out.State == eapnoob.StateWaiting && !sess.invitedEmitted { + completed = m.prepareJoinerInitialComplete(env.InviteID, sess) + } + return out, nil + }() if err != nil { - m.deleteSession(env.InviteID) - sess.mu.Unlock() http.Error(w, "eap-noob: "+err.Error(), http.StatusBadRequest) return } - var completed *Invite - if out.State == eapnoob.StateWaiting && !sess.invitedEmitted { - completed = m.prepareJoinerInitialComplete(env.InviteID, sess) - } - sess.mu.Unlock() if completed != nil { m.onJoinerInitialComplete(completed, sess) } diff --git a/services/nvpair-cluster-manager/httpserver_test.go b/services/nvpair-cluster-manager/httpserver_test.go new file mode 100644 index 000000000..6e785e492 --- /dev/null +++ b/services/nvpair-cluster-manager/httpserver_test.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "eapnoob" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Skipping negotiation previously panicked while holding the session lock, +// leaving a session that expiry could never reclaim. +func TestPairingInitial_RejectsKeyExchangeBeforeNegotiation(t *testing.T) { + m := testManagerAt(t, t.TempDir(), 15211) + env := &pairingEnvelope{InviteID: "skipped-negotiation", Phase: "initial"} + discovery := httptest.NewRecorder() + m.handlePairingInitial(discovery, env, []byte(`{"Type":1}`), "127.0.0.1") + require.Equal(t, http.StatusOK, discovery.Code) + _, exists := m.getSession(env.InviteID) + require.True(t, exists) + + response := httptest.NewRecorder() + m.handlePairingInitial(response, env, []byte(`{"Type":3,"PKs":{},"Ns":""}`), "127.0.0.1") + + require.Equal(t, http.StatusOK, response.Code) + var reply pairingEnvelope + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &reply)) + message, err := base64.StdEncoding.DecodeString(reply.Msg) + require.NoError(t, err) + var failure struct { + ErrorCode int `json:"ErrorCode"` + } + require.NoError(t, json.Unmarshal(message, &failure)) + assert.Equal(t, eapnoob.ErrStateMismatch, failure.ErrorCode) + _, exists = m.getSession(env.InviteID) + assert.False(t, exists, "a terminal protocol error must release the pending session slot") +} From 4c5a54cff62bf3d7ae7f33f0d68a89aca9f3f54c Mon Sep 17 00:00:00 2001 From: Kaylee Lubick Date: Wed, 28 Oct 2026 17:03:28 -0400 Subject: [PATCH 6/6] Simplify pairing negotiation, state, and test helpers Use suiteByID as the single place that validates and constructs a cryptosuite. Keep the first supported suite from the server's offer instead of checking support and then looking up the same suite again. Preserve both algorithms, their identifiers, and the server's preference order. Remove the unused SleepTime wire field and reuse the transcript's encoded NOOB instead of storing a duplicate. Replace the marshal-then-unmarshal encoding helper with direct json.Marshal calls. Merge the duplicate wrong-PIN scenarios into the test that verifies the terminal protocol error reaches the peer. Retain assertions that neither side registers, and shorten the explanation of the incorrect-pin contract. The eap-noob and cluster-manager suites, successful-pairing and wrong-PIN integration tests, service-contract check, and SPDX header check pass. Signed-off-by: Kaylee Lubick --- services/eap-noob/crypto.go | 4 ---- services/eap-noob/eapnoob.go | 11 ++++------ services/eap-noob/eapnoob_test.go | 6 +++--- services/eap-noob/errors.go | 10 ++++----- services/eap-noob/messages.go | 17 ---------------- services/eap-noob/peer.go | 19 +++++++---------- services/eap-noob/peer_test.go | 10 ++++----- services/eap-noob/pin_test.go | 34 +++++-------------------------- services/eap-noob/server.go | 8 ++------ services/eap-noob/server_test.go | 9 ++++---- 10 files changed, 35 insertions(+), 93 deletions(-) diff --git a/services/eap-noob/crypto.go b/services/eap-noob/crypto.go index 1e2c4a57b..88606f26a 100644 --- a/services/eap-noob/crypto.go +++ b/services/eap-noob/crypto.go @@ -59,10 +59,6 @@ func suiteByID(id cryptosuiteID) (*cryptosuite, error) { } } -func isSupportedCryptosuite(id cryptosuiteID) bool { - return id == cryptosuiteX25519 || id == cryptosuiteP256 -} - // generateKeypair creates an ephemeral ECDHE key pair and returns the private // key together with the JWK encoding of the public component. func (cs *cryptosuite) generateKeypair() (*ecdh.PrivateKey, JWK, error) { diff --git a/services/eap-noob/eapnoob.go b/services/eap-noob/eapnoob.go index 34a9a26c5..1381a7e66 100644 --- a/services/eap-noob/eapnoob.go +++ b/services/eap-noob/eapnoob.go @@ -88,9 +88,8 @@ type methodState struct { ns []byte // raw server nonce np []byte // raw peer nonce - noob []byte // raw 16-byte secret nonce - noobB64 json.RawMessage - noobID []byte // raw 16-byte NoobId + noob []byte // raw 16-byte secret nonce + noobID []byte // raw 16-byte NoobId keyM keyMaterial } @@ -103,10 +102,8 @@ func (ms *methodState) deriveCompletionKeys() { // side that holds it. func (ms *methodState) setNoob(noob []byte) { ms.noob = noob - b64 := b64Encode(noob) - ms.noobB64 = rawString(b64) - ms.in.Noob = ms.noobB64 - ms.noobID = ms.suite.computeNoobID(ms.noobB64) + ms.in.Noob = rawString(b64Encode(noob)) + ms.noobID = ms.suite.computeNoobID(ms.in.Noob) } // raw-value helpers ----------------------------------------------------------- diff --git a/services/eap-noob/eapnoob_test.go b/services/eap-noob/eapnoob_test.go index cd4193960..8d0350b26 100644 --- a/services/eap-noob/eapnoob_test.go +++ b/services/eap-noob/eapnoob_test.go @@ -62,9 +62,9 @@ func driveInitialWithSuite(t *testing.T, srv *Server, peer *Peer, suite cryptosu wire, err := decode(negotiationRequest(t, srv, peer)) require.NoError(t, err) wire.Cryptosuites = rawIntSlice([]int{int(suite)}) - request, parsed, err := encode(wire) + request, err := json.Marshal(wire) require.NoError(t, err) - srv.in.Cryptosuites = parsed.Cryptosuites + srv.in.Cryptosuites = wire.Cryptosuites require.Nil(t, driveMessages(t, srv, peer, request).Err) } @@ -139,7 +139,7 @@ func TestPairing_RejectsTamperedMAC(t *testing.T) { wm, err := decode(resp6.Send) require.NoError(t, err, "decode completion response:") wm.MACp = rawString(b64Encode(make([]byte, 32))) - tampered, _, err := encode(wm) + tampered, err := json.Marshal(wm) require.NoError(t, err, "re-encode:") out, err := srv.Receive(tampered) require.NoError(t, err, "server receive tampered:") diff --git a/services/eap-noob/errors.go b/services/eap-noob/errors.go index ed663e7c1..4c3400a47 100644 --- a/services/eap-noob/errors.go +++ b/services/eap-noob/errors.go @@ -3,7 +3,10 @@ package eapnoob -import "fmt" +import ( + "encoding/json" + "fmt" +) // EAP-NOOB error codes (RFC 9140, Section 5.3, Table 10). const ( @@ -39,8 +42,5 @@ func (e *ProtocolError) Error() string { // errorMessage builds the wire bytes for a Type=0 error notification. func errorMessage(code int, info string) ([]byte, error) { - t := typeError - m := &wireMessage{Type: &t, ErrorCode: &code, ErrorInfo: info} - b, _, err := encode(m) - return b, err + return json.Marshal(&wireMessage{Type: intp(typeError), ErrorCode: &code, ErrorInfo: info}) } diff --git a/services/eap-noob/messages.go b/services/eap-noob/messages.go index 55f24b183..8d4e7d977 100644 --- a/services/eap-noob/messages.go +++ b/services/eap-noob/messages.go @@ -64,8 +64,6 @@ type wireMessage struct { Ns json.RawMessage `json:"Ns,omitempty"` Np json.RawMessage `json:"Np,omitempty"` - SleepTime *int `json:"SleepTime,omitempty"` - NoobId json.RawMessage `json:"NoobId,omitempty"` MACs json.RawMessage `json:"MACs,omitempty"` MACp json.RawMessage `json:"MACp,omitempty"` @@ -76,21 +74,6 @@ type wireMessage struct { EAP string `json:"eap,omitempty"` } -// encode marshals a message to compact JSON and parses it back so callers can -// capture the exact serialized bytes of each field (the bytes that travel on -// the wire and feed the MAC/Hoob arrays). -func encode(m *wireMessage) ([]byte, *wireMessage, error) { - b, err := json.Marshal(m) - if err != nil { - return nil, nil, err - } - parsed := new(wireMessage) - if err := json.Unmarshal(b, parsed); err != nil { - return nil, nil, err - } - return b, parsed, nil -} - func decode(b []byte) (*wireMessage, error) { m := new(wireMessage) if err := json.Unmarshal(b, m); err != nil { diff --git a/services/eap-noob/peer.go b/services/eap-noob/peer.go index 41906d291..29dd15d54 100644 --- a/services/eap-noob/peer.go +++ b/services/eap-noob/peer.go @@ -119,8 +119,8 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { if err != nil { return p.fail(ErrInvalidData, "bad Cryptosuites") } - csp, ok := firstSupported(serverCS) - if !ok { + suite := firstSupportedSuite(serverCS) + if suite == nil { return p.fail(ErrUnsupportedCryptosuite, "no common cryptosuite") } serverDirs, err := rawToInt(wm.Dirs) @@ -131,10 +131,6 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { return p.fail(ErrNoMutuallySupportedOOB, "no mutually supported OOB direction") } - suite, err := suiteByID(csp) - if err != nil { - return p.fail(ErrUnsupportedCryptosuite, err.Error()) - } p.suite = suite // Determine the effective NAI: a server-assigned NewNAI overrides the @@ -146,7 +142,7 @@ func (p *Peer) onNegotiationRequest(wm *wireMessage) (Outcome, error) { } verpRaw := rawInt(protocolVersion) - cspRaw := rawInt(int(csp)) + cspRaw := rawInt(int(suite.id)) dirpRaw := rawInt(oobDirection) p.in.Vers = wm.Vers @@ -333,12 +329,11 @@ func (p *Peer) fail(code int, info string) (Outcome, error) { return Outcome{Send: b, State: p.state, Done: true, Err: &ProtocolError{Code: code, Info: info}}, nil } -func firstSupported(serverCS []int) (cryptosuiteID, bool) { +func firstSupportedSuite(serverCS []int) *cryptosuite { for _, offeredSuite := range serverCS { - id := cryptosuiteID(offeredSuite) - if isSupportedCryptosuite(id) { - return id, true + if suite, err := suiteByID(cryptosuiteID(offeredSuite)); err == nil { + return suite } } - return 0, false + return nil } diff --git a/services/eap-noob/peer_test.go b/services/eap-noob/peer_test.go index c17ffe64c..c31d6a9f5 100644 --- a/services/eap-noob/peer_test.go +++ b/services/eap-noob/peer_test.go @@ -67,7 +67,7 @@ func TestPeerNegotiation_SelectsSupportedParameters(t *testing.T) { offer.Vers = versions offer.Cryptosuites = suites offer.Dirs = directions - message, _, err := encode(offer) + message, err := json.Marshal(offer) require.NoError(t, err) out, err := peer.Receive(message) @@ -94,7 +94,7 @@ func TestPeerNegotiation_RejectsUnsupportedOffers(t *testing.T) { t.Run(name, func(t *testing.T) { peer, offer := peerNegotiationOffer(t) change(offer) - message, _, err := encode(offer) + message, err := json.Marshal(offer) require.NoError(t, err) out, err := peer.Receive(message) @@ -124,7 +124,7 @@ func TestPeerNegotiation_RejectsUnsupportedOffers(t *testing.T) { func TestPeerNegotiation_BindsAssignedNAIToAuthenticatedTranscript(t *testing.T) { peer, offer := peerNegotiationOffer(t) offer.NewNAI = json.RawMessage(`"assigned@pair.test"`) - message, _, err := encode(offer) + message, err := json.Marshal(offer) require.NoError(t, err) out, err := peer.Receive(message) @@ -197,7 +197,7 @@ func TestPeer_RejectsChangedAuthenticatedCertificateInformation(t *testing.T) { if len(changedServerInfo) != 0 { offer.ServerInfo = changedServerInfo } - request, _, err := encode(offer) + request, err := json.Marshal(offer) require.NoError(t, err) selection, err := peer.Receive(request) require.NoError(t, err) @@ -207,7 +207,7 @@ func TestPeer_RejectsChangedAuthenticatedCertificateInformation(t *testing.T) { if len(changedPeerInfo) != 0 { wire.PeerInfo = changedPeerInfo } - response, _, err := encode(wire) + response, err := json.Marshal(wire) require.NoError(t, err) keyExchange, err := server.Receive(response) require.NoError(t, err) diff --git a/services/eap-noob/pin_test.go b/services/eap-noob/pin_test.go index 280207288..9bbd8cd75 100644 --- a/services/eap-noob/pin_test.go +++ b/services/eap-noob/pin_test.go @@ -41,32 +41,8 @@ func TestPairing_AcceptsMatchingCallerProvidedNoob(t *testing.T) { test("suite 2", cryptosuiteP256) } -// TestMismatchedInjectedNoobFails is the wrong-PIN case: the two sides inject -// different Noobs, so the Completion MACs cannot agree and pairing must fail. -func TestPairing_RejectsMismatchedCallerProvidedNoob(t *testing.T) { - srv := NewServer(PairingInfo{}) - peer := NewPeer(PairingInfo{}) - - require.Nil(t, driveConversation(t, srv, peer).Err) - - require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") - require.NoError(t, peer.OOBInputNoob(bytes.Repeat([]byte{0x02}, 16)), "peer OOBInputNoob:") - - require.NotNil(t, driveConversation(t, srv, peer).Err, "expected the Completion Exchange to fail with mismatched NOOB values") - assert.NotEqual(t, StateRegistered, peer.State(), "a side reached Registered despite mismatched Noobs") - assert.NotEqual(t, StateRegistered, srv.State(), "a side reached Registered despite mismatched Noobs") -} - -// TestPeerWrongPinYieldsProtocolError pins the exact contract the cluster -// manager's wrong-PIN classification depends on (nvpair-cluster-manager -// runCompletionExchange): when the joiner (Peer) drives the Completion Exchange -// against a server whose Noob differs (a wrong PIN), the Peer must surface the -// failure as a terminal Outcome carrying a *ProtocolError in out.Err — NOT as -// the returned error and NOT as a silent EAP-Failure (out.Err == nil) — with a -// code the classifier recognizes (2003 unrecognized NoobId, checked first, or -// 4001 MAC mismatch). If a future change moved detection so the Peer no longer -// reports it this way, the cluster manager would silently degrade a wrong PIN to -// a generic failure (empty reason) instead of reason:"incorrect-pin". +// The cluster manager uses the peer's terminal protocol error to report a +// wrong PIN as "incorrect-pin". Neither side may become registered. func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { srv := NewServer(PairingInfo{}) peer := NewPeer(PairingInfo{}) @@ -74,7 +50,7 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { // Initial Exchange. require.Nil(t, driveConversation(t, srv, peer).Err) - // Server-to-peer OOB with MISMATCHED Noobs — the wrong-PIN condition. + // Supply different NOOB values to represent a wrong PIN. require.NoError(t, srv.OOBOutputWith(bytes.Repeat([]byte{0x01}, 16)), "server OOBOutputWith:") require.NoError(t, peer.OOBInputNoob(bytes.Repeat([]byte{0x02}, 16)), "peer OOBInputNoob:") @@ -86,8 +62,7 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { for { require.NotEmpty(t, msg, "server ended completion unexpectedly before the peer went terminal") out, rerr := peer.Receive(msg) - // A wrong PIN must NOT come back as the returned error — the classifier - // only inspects out.Err. + // The classifier reads out.Err. require.NoError(t, rerr, "a wrong PIN must surface in out.Err") if out.Done { peerOut = out @@ -101,6 +76,7 @@ func TestPeerWrongPin_YieldsProtocolError(t *testing.T) { require.NotNil(t, peerOut.Err, "peer completion produced no ProtocolError on a wrong PIN; the classifier would degrade the reason to empty") assert.Contains(t, []int{ErrUnrecognizedOOBMsgID, ErrHMACVerificationFailed}, peerOut.Err.Code, "wrong PIN must produce an unrecognized NoobId or MAC mismatch") assert.NotEqual(t, StateRegistered, peer.State(), "peer reached Registered despite a wrong PIN") + assert.NotEqual(t, StateRegistered, srv.State(), "server reached Registered despite a wrong PIN") } // TestOOBOutputWithRejectsBadLength guards the 16-byte contract. diff --git a/services/eap-noob/server.go b/services/eap-noob/server.go index c5663bdeb..e8ded856c 100644 --- a/services/eap-noob/server.go +++ b/services/eap-noob/server.go @@ -145,8 +145,8 @@ func (s *Server) onNegotiation(wm *wireMessage) (Outcome, error) { if err != nil { return s.fail(ErrInvalidData, "bad Cryptosuitep") } - csp := cryptosuiteID(selectedSuite) - if !isSupportedCryptosuite(csp) { + suite, err := suiteByID(cryptosuiteID(selectedSuite)) + if err != nil { return s.fail(ErrUnsupportedCryptosuite, "peer selected unsupported cryptosuite") } dirp, err := rawToInt(wm.Dirp) @@ -157,10 +157,6 @@ func (s *Server) onNegotiation(wm *wireMessage) (Outcome, error) { return s.fail(ErrNoMutuallySupportedOOB, "no mutually supported OOB direction") } - suite, err := suiteByID(csp) - if err != nil { - return s.fail(ErrUnsupportedCryptosuite, err.Error()) - } s.suite = suite s.in.Verp = wm.Verp diff --git a/services/eap-noob/server_test.go b/services/eap-noob/server_test.go index 03ec72dcd..7b886183e 100644 --- a/services/eap-noob/server_test.go +++ b/services/eap-noob/server_test.go @@ -47,7 +47,6 @@ func TestServer_AdvertisesPAIRDefaults(t *testing.T) { require.NoError(t, json.Unmarshal(offer.ServerInfo, &receivedInfo)) assert.Equal(t, info, receivedInfo) assert.Empty(t, offer.NewNAI) - assert.Nil(t, offer.SleepTime) } func TestServerDiscovery_BindsIncomingNAIToAuthenticatedTranscript(t *testing.T) { @@ -57,7 +56,7 @@ func TestServerDiscovery_BindsIncomingNAIToAuthenticatedTranscript(t *testing.T) require.Nil(t, negotiation.Err) offer, err := decode(negotiation.Send) require.NoError(t, err) - selection, _, err := encode(&wireMessage{ + selection, err := json.Marshal(&wireMessage{ Type: intp(typeNegotiation), PeerId: offer.PeerId, Verp: json.RawMessage(`1`), @@ -79,7 +78,7 @@ func TestServerNegotiation_AcceptsSelectionsIncludingServerToPeer(t *testing.T) t.Run(name, func(t *testing.T) { server, wire := serverNegotiationSelection(t) wire.Dirp = json.RawMessage(direction) - message, _, err := encode(wire) + message, err := json.Marshal(wire) require.NoError(t, err) out, err := server.Receive(message) @@ -101,7 +100,7 @@ func TestServerNegotiation_RejectsUnsupportedOrMismatchedSelections(t *testing.T t.Run(name, func(t *testing.T) { server, wire := serverNegotiationSelection(t) change(wire) - changed, _, err := encode(wire) + changed, err := json.Marshal(wire) require.NoError(t, err) out, err := server.Receive(changed) @@ -170,7 +169,7 @@ func TestServer_RejectsCompletionBeforeKeysAreDerived(t *testing.T) { // Before PIN verification, the transcript is public and an empty-key // MAC is computable without knowing the PIN. It must not grant trust. macp := server.suite.computeMAC(nil, 1, server.in) - message, _, err := encode(&wireMessage{ + message, err := json.Marshal(&wireMessage{ Type: intp(typeCompletion), PeerId: rawString(server.peerId), MACp: rawString(b64Encode(macp)),