From 959f57294a8afb33f4038b911da68844aec7340d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:15:08 +0100 Subject: [PATCH] Add the contract of the enrolment state and of redeem, so the holder, the grants part and the engine read one shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §2 gives the seat mesh-enrolment the bucket enrolments with keys node. (an Enrolment: node, address, why, caller, ask, stage, issued, expires, keys digest) and address.
(the node's name alone), no secret in either. The token's life rules (an hour by default, at most a day, §4), the event enrolment-expired's content (§2), and redeem's call, proof bytes, keys digest and signed answer (§6) live here too, because the holder in mesh-delivery and the engine in mesh-host both read them. The proof binds the node and its keys, not the token's secret, which the holder never holds (§1). Tested as rows of TestNodeRecord, no new test function. (issue 774, 774/S35) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/enrolment.go | 240 +++++++++++++++++++++++++++++++++ go/delivery/noderecord_test.go | 59 +++++++- 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 go/delivery/enrolment.go diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go new file mode 100644 index 0000000..de8afad --- /dev/null +++ b/go/delivery/enrolment.go @@ -0,0 +1,240 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/netip" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding +// no secret, with two kinds of key. `node.`, one per open enrolment, holds an Enrolment; `address.
` +// reserves one private-network address for one open enrolment and holds only the node's name. Every write is a +// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the +// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part, +// which composes the user `enrol.` from a key at live and the vault's hash (§4). These constants are the seat's +// contract: every reader takes the names from here. +const ( + // EnrolmentModule is the module holding the seat mesh-enrolment. + EnrolmentModule = catalogue.EnrolmentSeat + // EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares. + EnrolmentsState = "enrolments" + EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState + EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState + + // The stages of an open enrolment (§2, §4, §5). + EnrolmentAsked = "asked" + EnrolmentLive = "live" + EnrolmentRedeeming = "redeeming" + + // DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4). + DefaultTokenLife = time.Hour + MostTokenLife = 24 * time.Hour + + // EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.` (§4). + EnrolmentUserPrefix = "enrol." + + enrolmentNodePrefix = "node." + enrolmentAddressPrefix = "address." +) + +// Enrolment is one key `node.` of `enrolments`: the node's name, the private-network address chosen at issue, the +// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and +// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token. +type Enrolment struct { + V int `json:"v"` + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Caller string `json:"caller"` + Ask string `json:"ask,omitempty"` + Stage string `json:"stage"` + Issued time.Time `json:"issued"` + Expires time.Time `json:"expires"` + KeysDigest string `json:"keys-digest,omitempty"` +} + +// EnrolmentAddress is one key `address.
`: the node the address is reserved for, and nothing else. +type EnrolmentAddress struct { + V int `json:"v"` + Node string `json:"node"` +} + +// EnrolmentNodeKey is a node's key in `enrolments`. +func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node } + +// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens). +func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address } + +// EnrolmentKeyNode is the node a key `node.` names, and whether it is one. +func EnrolmentKeyNode(key string) (string, bool) { + node, ok := strings.CutPrefix(key, enrolmentNodePrefix) + return node, ok && checkName("node", node) == nil +} + +// EnrolmentKeyAddress is the address a key `address.
` names, and whether it is one. +func EnrolmentKeyAddress(key string) (string, bool) { + address, ok := strings.CutPrefix(key, enrolmentAddressPrefix) + if !ok { + return "", false + } + _, err := netip.ParseAddr(address) + return address, err == nil +} + +// EnrolmentUser is the bus user a node enrols as. +func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node } + +func (e Enrolment) check() error { + if err := checkName("node", e.Node); err != nil { + return err + } + if _, err := netip.ParseAddr(e.Address); err != nil { + return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address) + } + if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" { + return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node) + } + switch e.Stage { + case EnrolmentAsked, EnrolmentLive: + if e.KeysDigest != "" { + return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage) + } + case EnrolmentRedeeming: + if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil { + return err + } + default: + return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage) + } + if e.Issued.IsZero() || !e.Expires.After(e.Issued) { + return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node) + } + return nil +} + +// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses. +func EncodeEnrolment(e Enrolment) ([]byte, error) { + if err := e.check(); err != nil { + return nil, err + } + e.V = Version + return json.Marshal(e) +} + +// DecodeEnrolment reads a key `node.`; it refuses an unknown version (ErrUnknownVersion), a node that is not a +// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not +// after the issue. +func DecodeEnrolment(data []byte) (Enrolment, error) { + var e Enrolment + if err := decode(data, &e); err != nil { + return Enrolment{}, err + } + if err := e.check(); err != nil { + return Enrolment{}, err + } + return e, nil +} + +// EncodeEnrolmentAddress is a key `address.
` reserving it for node. +func EncodeEnrolmentAddress(node string) ([]byte, error) { + if err := checkName("node", node); err != nil { + return nil, err + } + return json.Marshal(EnrolmentAddress{V: Version, Node: node}) +} + +// DecodeEnrolmentAddress reads a key `address.
`. +func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) { + var a EnrolmentAddress + if err := decode(data, &a); err != nil { + return EnrolmentAddress{}, err + } + if err := checkName("node", a.Node); err != nil { + return EnrolmentAddress{}, err + } + return a, nil +} + +// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not +// positive or longer than MostTokenLife. +func TokenLife(expires string) (time.Duration, error) { + if strings.TrimSpace(expires) == "" { + return DefaultTokenLife, nil + } + d, err := time.ParseDuration(strings.TrimSpace(expires)) + if err != nil || d <= 0 || d > MostTokenLife { + return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires) + } + return d, nil +} + +// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the +// key's content, by its sequence on the events' stream, since the bus deleted the key before the holder heard of it. +type EnrolmentExpiry struct { + V int `json:"v"` + Node string `json:"node"` + Issued uint64 `json:"issued-event"` +} + +// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, +// which live in two repositories. + +// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over +// RedeemProof. No token: the bus's login check verified it. +type Redeem struct { + Node string `json:"node"` + PublicKey string `json:"public_key"` + OverlayKey string `json:"overlay_key,omitempty"` + SealingKey string `json:"sealing_key"` + ServingKey string `json:"serving_key,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Tunnel json.RawMessage `json:"tunnel,omitempty"` + Proof string `json:"proof"` +} + +// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it +// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes. +func RedeemProof(node, public, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + +// KeysDigest is the digest of the public keys a redeem presents, `sha256:`: what the key at redeeming and the +// events carry, the one hash an event carries (§1). +func KeysDigest(public, overlay, sealing, serving string) string { + sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00"))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// KeysDigest is r's keys' digest. +func (r Redeem) KeysDigest() string { + return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey) +} + +// ProofBytes is r's proof decoded from base64. +func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) } + +// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it +// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over +// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries. +type RedeemAnswer struct { + Node string `json:"node"` + Login string `json:"login"` + Account string `json:"account"` + Certificate string `json:"certificate"` + Signer string `json:"signer"` + Signature string `json:"signature,omitempty"` +} + +// SignedBytes is what the vault signs of a: a without its signature, as JSON. +func (a RedeemAnswer) SignedBytes() []byte { + a.Signature = "" + raw, _ := json.Marshal(a) + return raw +} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 1e985e9..0a39b1a 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -5,6 +5,7 @@ import ( "regexp" "strings" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -13,7 +14,7 @@ import ( // check (ADR 0201 §13) refuses such a field in a state every node reads. var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) -// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and +// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and // neither it nor the placement file names a field like a secret. func TestNodeRecord(t *testing.T) { hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", @@ -68,6 +69,62 @@ func TestNodeRecord(t *testing.T) { } } walk(reflect.TypeOf(NodeRecord{}), "NodeRecord") + // 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the + // grants part reads them (ADR 0587 §2). + issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + for _, e := range []Enrolment{ + {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive, + Issued: issued, Expires: issued.Add(DefaultTokenLife)}, + {Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming, + Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")}, + } { + raw, err := EncodeEnrolment(e) + if err != nil { + t.Fatal(err) + } + back, err := DecodeEnrolment(raw) + e.V = Version + if err != nil || !reflect.DeepEqual(back, e) { + t.Errorf("enrolment round trip: %+v, %v", back, err) + } + } + if raw, err := EncodeEnrolmentAddress("lab"); err != nil { + t.Fatal(err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" { + t.Errorf("address round trip: %+v, %v", back, err) + } + for name, e := range map[string]Enrolment{ + "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive, + Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")}, + "no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming, + Issued: issued, Expires: issued.Add(time.Hour)}, + "no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued, Expires: issued.Add(time.Hour)}, + "other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", Issued: issued, + Expires: issued.Add(time.Hour)}, + } { + if _, err := EncodeEnrolment(e); err == nil { + t.Errorf("enrolment with %s: taken", name) + } + } + if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" { + t.Errorf("address key: %q %v", key, ok) + } + if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" { + t.Errorf("node key: %q %v", node, ok) + } + for _, life := range []string{"", "1h", "24h"} { + if _, err := TokenLife(life); err != nil { + t.Errorf("a life of %q: %v", life, err) + } + } + for _, life := range []string{"25h", "-1h", "a day"} { + if _, err := TokenLife(life); err == nil { + t.Errorf("a life of %q: taken", life) + } + } + walk(reflect.TypeOf(Enrolment{}), "Enrolment") + walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") + walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. walk(reflect.TypeOf(Placement{}), "Placement") }