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