From d6db3f38e6d20ad19955a57882d5ef43d6b8164b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 09:26:12 +0100 Subject: [PATCH] =?UTF-8?q?The=20vault=20signs:=20the=20contract=20of=20it?= =?UTF-8?q?s=20state=20signers,=20its=20verb=20sign=20and=20its=20event=20?= =?UTF-8?q?signed,=20a=20node's=20grants=20to=20ask=20it=20and=20to=20read?= =?UTF-8?q?=20what=20it=20trusts,=20and=20the=20authority=20composed=20as?= =?UTF-8?q?=20a=20bundle=20(issue=20774,=20ADR=200550=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/buscompose/buscompose_test.go | 17 ++ go/buscompose/nats.go | 22 ++ go/buscompose/users.go | 2 +- go/catalogue/seats.go | 8 + go/compose/frombus/frombus.go | 17 ++ go/compose/frombus/frombus_test.go | 26 +- go/delivery/composed_signature_test.go | 49 ++++ go/delivery/noderecord.go | 4 + go/delivery/noderecord_test.go | 2 +- go/delivery/signers.go | 328 +++++++++++++++++++++++++ 10 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 go/delivery/signers.go diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 7825671..c9d6d2f 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -131,6 +131,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } } +const SignersBucketName = "mesh-vault_signers" + func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { // ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. @@ -251,6 +253,21 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { if !slices.Contains(perms.Subscribe, VaultSecretChanged) { t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe) } + // L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's + // `signers` and the lease key, never writing either. + signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true}) + signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key" + leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder" + for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true, + VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false, + "$KV." + LeaseBucket + ".holder": false} { + if MayPublish(signs, subject) != want { + t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want) + } + } + if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) { + t.Error("a node asks sign or reads signers while the vault does not serve sign") + } // And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126). users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ "two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}}) diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index d6c35c6..ceed8be 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -217,6 +217,11 @@ type Principal struct { // ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes // itself. Set while the vault's holder serves it. AsksItsOwnSecrets bool + // AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the + // vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's + // certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder + // serves `sign`. + AsksTheVaultToSign bool // ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve // from (`..`, catalogue.ServedSettings): what every consumer is told, and so what a node composing // its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice. @@ -360,6 +365,10 @@ var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"} // key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says. var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} +// VaultSign is the vault's verb a machine's node-engine asks in its own name for its own node's certificate (novox/hq +// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. +var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release @@ -956,6 +965,14 @@ func PermissionsFor(p Principal) (Permissions, error) { // on it rather than at the next unrelated change (ADR 0547 §2). sub = append(sub, VaultSecretChanged) } + // And, while the vault serves `sign`, its own node's certificate in its own name, the vault's public signing + // halves, read and watched, and the controller's lease key, read and watched, which says when the controller's key + // is no longer trusted (ADR 0550 §2). Read only. + if p.AsksTheVaultToSign { + pub = append(pub, VaultSignCall(p)) + pub = append(pub, readGrants(delivery.SignersBucket, "")...) + pub = append(pub, readGrants(LeaseBucket, leaseKey)...) + } // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { @@ -1848,6 +1865,11 @@ func VaultOwnCall(p Principal) string { return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username()) } +// VaultSignCall is the subject a machine principal's node-engine asks the vault's `sign` on, in its own name. +func VaultSignCall(p Principal) string { + return "mesh.seat." + VaultSign.Seat + "." + CallKind + "." + VaultSign.Verb + "." + CallerToken(p.Username()) +} + // VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a // node composing its own hears to compose again (issue 1126, ADR 0547 §2). var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed" diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 15cbd14..2424fcf 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -165,7 +165,7 @@ func Users(r Records) ([]Principal, error) { } out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 4ab81dc..69343ac 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -228,6 +228,14 @@ var defaultSeats = append([]Seat{ "ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " + "controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})}, + {Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " + + "kind node-certificate signs the certificate of the calling machine's internal name for the serving key " + + "its record states, built from bus state alone and asked by that machine's node-engine only; kind " + + "enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " + + "Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + Input: schema(map[string]string{"kind": "node-certificate or enrolment", + "node": "the machine, for a node certificate: the caller's own", + "reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})}, }}, // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 979502c..f0f095e 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -83,6 +83,16 @@ type Reader interface { Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) } +// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, +// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the +// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. +type SignersReader interface { + // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the + // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the + // state yet composes as before rather than not at all. + VaultAuthority(ctx context.Context) string +} + // Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the // internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take // compose's defaults. @@ -219,6 +229,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module } } } + // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks + // against the bundle. The controller's stays while a certificate it issued is in a node's record. + if sr, ok := r.(SignersReader); ok { + if vault := sr.VaultAuthority(ctx); vault != "" { + in.Authority = delivery.AuthorityBundle(in.Authority, vault) + } + } parse := func(raw []byte) (catalogue.Manifest, error) { if mesh.Predefined != nil { return catalogue.ParseManifestWith(raw, mesh.Predefined) diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 32a9d72..122349e 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -25,14 +25,15 @@ import ( // bus is a Reader over values held in memory, counting whose config values were read. type bus struct { - placements map[string]delivery.Placement - records map[string]delivery.NodeRecord - modules map[string]delivery.Module - manifests map[string][]byte - values map[string]map[string]any // by node.module - valuesRead []string - own []compose.OwnSecretRow - ownErr error + vaultAuthority string + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + manifests map[string][]byte + values map[string]map[string]any // by node.module + valuesRead []string + own []compose.OwnSecretRow + ownErr error // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming // module its placement does not put on the node. asked []compose.CredentialKey @@ -74,6 +75,7 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { return b.placements, nil } +func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { @@ -216,6 +218,14 @@ func TestInput(t *testing.T) { wj, _ := json.MarshalIndent(want, "", " ") t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) } + // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's + // and the vault's; with none, the controller's alone, as above. + signing := mk() + signing.vaultAuthority = "the vault's certificate" + if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || + bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { + t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) + } if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) } diff --git a/go/delivery/composed_signature_test.go b/go/delivery/composed_signature_test.go index 816e8a8..4d411bd 100644 --- a/go/delivery/composed_signature_test.go +++ b/go/delivery/composed_signature_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "strings" "testing" + "time" ) func aSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { @@ -100,4 +101,52 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) { if _, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(other, m), nil }); err == nil { t.Errorf("a signature by another key than the one named was accepted") } + + // L3 s1 (ADR 0550 §2): a message the vault or the controller signed under a kind's context verifies against the + // signers a node trusts and names which; an unknown key and another kind's context are refused. + vaultPub, vault := aSigningKey(t) + controllerPub, controller := aSigningKey(t) + trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}} + enrol, _ := SignContext(KindEnrolment) + certCtx, _ := SignContext(KindNodeCertificate) + msg := []byte("the reply") + for _, row := range []struct { + name string + key ed25519.PrivateKey + signed string + checked string + want string + }{ + {"the vault's key verifies", vault, enrol, enrol, SignerVault}, + {"the controller's key verifies", controller, enrol, enrol, SignerController}, + {"an unknown key is refused", other, enrol, enrol, ""}, + {"the wrong context is refused", vault, certCtx, enrol, ""}, + } { + got, err := VerifyBy(trusted, row.checked, msg, SignMessage(row.key, row.signed, msg)) + if row.want == "" && err == nil { + t.Errorf("%s: verified as %s", row.name, got.Name) + } + if row.want != "" && (err != nil || got.Name != row.want) { + t.Errorf("%s: %+v, %v", row.name, got.Name, err) + } + } + if _, err := SignContext("hand-over"); err == nil { + t.Error("a kind the vault does not sign has a context") + } + // The signers' key reads back only when its fingerprint is its key's, and what a node trusts names both holders. + k := NewSignerKey(vaultPub, time.Unix(0, 0)) + raw, _ := json.Marshal(k) + if back, err := DecodeSignerKey(raw); err != nil || back.Fingerprint != SignerFingerprint(vaultPub) { + t.Errorf("the signers' key: %+v, %v", back, err) + } + k.Fingerprint = SignerFingerprint(controllerPub) + if raw, _ := json.Marshal(k); true { + if _, err := DecodeSignerKey(raw); err == nil { + t.Error("a signers' key naming another fingerprint was taken") + } + } + if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) || + !TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) { + t.Errorf("trusts: %v", got) + } } diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go index c22f8b2..423cf6b 100644 --- a/go/delivery/noderecord.go +++ b/go/delivery/noderecord.go @@ -55,6 +55,10 @@ type NodeRecord struct { // Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where // a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority). Authority string `json:"authority,omitempty"` + // Trusts is what this machine trusts a mesh signature from now (Trusts in signers.go, ADR 0550 §2): the controller's + // key until the lease is handed away, and the vault's key and authority once it reads them from `signers`. The gate + // before the lease write reads every node's from the bus. + Trusts []string `json:"trusts,omitempty"` } func (r NodeRecord) check() error { diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index e49b327..1e985e9 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -18,7 +18,7 @@ var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|privat func TestNodeRecord(t *testing.T) { hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, - NetworksSaid: true, Contract: 3} + NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}} raw, err := EncodeNodeRecord(hub) if err != nil { t.Fatal(err) diff --git a/go/delivery/signers.go b/go/delivery/signers.go new file mode 100644 index 0000000..52834a2 --- /dev/null +++ b/go/delivery/signers.go @@ -0,0 +1,328 @@ +package delivery + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "slices" + "strings" + "time" +) + +// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509 +// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its +// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a +// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the +// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key. +// +// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may +// write `signers`, the same trust the wants and the placement rest on. Until the controller's lease is handed away the +// node trusts the controller's key beside it; from then on the vault's alone (LeaseHandedAway). What a node trusts now +// it states in its record, NodeRecord.Trusts, so the gate before the lease write reads it from the bus. + +const ( + // VaultModule is the module holding the seat mesh-vault, whose state `signers` is. + VaultModule = "mesh-vault" + // SignersState is the vault's state of its public signing halves, and SignersBucket its bucket. + SignersState = "signers" + SignersBucket = VaultModule + "_" + SignersState + // SignersRead is the state as a manifest's `reads` names it. + SignersRead = VaultModule + "." + SignersState + // SignersKeyKey holds the signing key's public half (SignerKey); SignersAuthorityKey the authority (SignerAuthority). + SignersKeyKey = "key" + SignersAuthorityKey = "authority" + + // SignVerb is the vault's verb that signs. + SignVerb = "sign" + // SignedEvent is what the vault says when it signed (Signed), on SignedSubject. + SignedEvent = "signed" + SignedSubject = "mesh.mod." + VaultModule + ".event." + SignedEvent + + // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. + KindNodeCertificate = "node-certificate" + // KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record + // (ADR 0561 §5). + KindEnrolment = "enrolment" + // KindAuthority is the vault's own authority certificate, which the vault signs for itself. + KindAuthority = "authority" + + // SignerController and SignerVault name the two signers a node may trust. + SignerController = "controller" + SignerVault = "vault" + + // CertificateLife is how long a certificate the vault signs for a node lasts: the controller's ten years + // (identity.forever), renewed by asking again, never by a clock running out unseen. + CertificateLife = 10 * 365 * 24 * time.Hour +) + +// SignKinds are the kinds `sign` knows. +var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority} + +// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as +// another's (as the composition's and the asks' contexts are kept apart). +func SignContext(kind string) (string, error) { + if !slices.Contains(SignKinds, kind) { + return "", fmt.Errorf("delivery: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", ")) + } + return "novox " + kind + " v1\n", nil +} + +// SignerKey is the key `key` of `signers`: the vault's signing key's public half in standard base64, its fingerprint, +// and when it was made. +type SignerKey struct { + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` + Made time.Time `json:"made"` +} + +// SignerAuthority is the key `authority` of `signers`: the vault's authority certificate as PEM, its fingerprint, and +// when it was made. +type SignerAuthority struct { + Certificate string `json:"certificate"` + Fingerprint string `json:"fingerprint"` + Made time.Time `json:"made"` +} + +// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:` of its 32 bytes. +func SignerFingerprint(key ed25519.PublicKey) string { + sum := sha256.Sum256(key) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:` of its DER; refused when certificate is +// not one PEM certificate. +func AuthorityFingerprint(certificate string) (string, error) { + c, err := ParseCertificate(certificate) + if err != nil { + return "", err + } + sum := sha256.Sum256(c.Raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// ParseCertificate is the first certificate of a PEM text. +func ParseCertificate(text string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(strings.TrimSpace(text))) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("delivery: not a PEM certificate") + } + return x509.ParseCertificate(block.Bytes) +} + +// NewSignerKey is the value of `key` for pub. +func NewSignerKey(pub ed25519.PublicKey, made time.Time) SignerKey { + return SignerKey{Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC()} +} + +// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names. +func (k SignerKey) PublicKey() (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.DecodeString(k.Key) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil, errors.New("delivery: the signers' key is not an ed25519 public key in base64") + } + if fp := SignerFingerprint(raw); fp != k.Fingerprint { + return nil, fmt.Errorf("delivery: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint) + } + return ed25519.PublicKey(raw), nil +} + +// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check. +func DecodeSignerKey(raw []byte) (SignerKey, error) { + var k SignerKey + if err := json.Unmarshal(raw, &k); err != nil { + return SignerKey{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersKeyKey, err) + } + if _, err := k.PublicKey(); err != nil { + return SignerKey{}, err + } + return k, nil +} + +// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves +// alone) whose fingerprint is the one named. +func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) { + var a SignerAuthority + if err := json.Unmarshal(raw, &a); err != nil { + return SignerAuthority{}, fmt.Errorf("delivery: %s %s: %w", SignersRead, SignersAuthorityKey, err) + } + c, err := ParseCertificate(a.Certificate) + if err != nil { + return SignerAuthority{}, err + } + if !c.IsCA { + return SignerAuthority{}, errors.New("delivery: the signers' authority is not an authority certificate") + } + if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint { + return SignerAuthority{}, fmt.Errorf("delivery: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint) + } + return a, nil +} + +// Signer is one key a node trusts, by who holds it (SignerController, SignerVault, or the operator's). +type Signer struct { + Name string + Key ed25519.PublicKey +} + +// Fingerprint is the signer's key's fingerprint. +func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) } + +// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying +// so, and when a key is not an ed25519 key. A signature made under another context never verifies. +func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) { + if len(sig) != ed25519.SignatureSize { + return Signer{}, errors.New("delivery: not an ed25519 signature") + } + message := append([]byte(context), msg...) + for _, s := range trusted { + if len(s.Key) != ed25519.PublicKeySize { + continue + } + if ed25519.Verify(s.Key, message, sig) { + return s, nil + } + } + names := make([]string, 0, len(trusted)) + for _, s := range trusted { + names = append(names, s.Name) + } + return Signer{}, fmt.Errorf("delivery: the signature verifies against no trusted key (%s)", strings.Join(names, ", ")) +} + +// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given. +func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte { + return ed25519.Sign(key, append([]byte(context), msg...)) +} + +// Signed is the vault's `signed`: who asked, the kind, the node, and the sha256 digest of what was signed. Never a key. +type Signed struct { + Caller string `json:"caller"` + Kind string `json:"kind"` + Node string `json:"node,omitempty"` + Digest string `json:"digest"` + Signer string `json:"signer"` + At time.Time `json:"at"` +} + +// SignAnswer is what `sign` answers: for a node certificate the certificate as PEM and the authority that signed it, +// for an enrolment the signature in base64; always the signer's fingerprint and the digest. Never a key's private half. +type SignAnswer struct { + Kind string `json:"kind"` + Node string `json:"node,omitempty"` + Certificate string `json:"certificate,omitempty"` + Authority string `json:"authority,omitempty"` + Signature string `json:"signature,omitempty"` + Signer string `json:"signer"` + Digest string `json:"digest"` +} + +// DigestOf is the digest a `signed` names: `sha256:` of the bytes signed. +func DigestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority +// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the +// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore. +func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: notBefore.UTC().Truncate(time.Second), + NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } +} + +// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no +// other authority, as the controller's. +func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate { + sum := sha256.Sum256(append([]byte("authority\n"), key...)) + return &x509.Certificate{ + SerialNumber: new(big.Int).SetBytes(sum[:16]), + Subject: pkix.Name{CommonName: "the mesh's vault"}, + NotBefore: made.UTC().Truncate(time.Second), + NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + } +} + +// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or +// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to +// check one the vault signed (ADR 0550 §2's live check). +func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error { + c, err := ParseCertificate(certificate) + if err != nil { + return err + } + pool := x509.NewCertPool() + for _, a := range authorities { + if ac, err := ParseCertificate(a); err == nil { + pool.AddCert(ac) + } + } + if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("delivery: the certificate of %s is not one a trusted authority signed for it: %w", name, err) + } + held, ok := c.PublicKey.(ed25519.PublicKey) + if !ok || !held.Equal(servingKey) { + return fmt.Errorf("delivery: the certificate of %s binds another key than the node serves with", name) + } + return nil +} + +// AuthorityBundle is the authorities given as one PEM text, each once, in the order given, blanks left out: what a +// module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). +func AuthorityBundle(authorities ...string) string { + var out []string + for _, a := range authorities { + a = strings.TrimSpace(a) + if a == "" || slices.Contains(out, a) { + continue + } + out = append(out, a) + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + +// Trusts is what a node states it trusts now (NodeRecord.Trusts): ` key ` for each signer and +// ` authority ` for each authority certificate, by who holds it, each once, sorted. The gate before the +// lease write reads `vault key ` in every node's record. +func Trusts(signers []Signer, authorities map[string]string) []string { + var out []string + for _, s := range signers { + if len(s.Key) == ed25519.PublicKeySize { + out = append(out, s.Name+" key "+s.Fingerprint()) + } + } + for who, a := range authorities { + if fp, err := AuthorityFingerprint(a); err == nil { + out = append(out, who+" authority "+fp) + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// TrustsKey says whether trusts names the key with fingerprint fp held by who. +func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) }