delivery: a composition carries no signature, since the node applies only what it composes itself (ADR 0593 §6)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 19:02:33 +01:00
parent 1c684743f7
commit f4bbcd7e5e
4 changed files with 16 additions and 306 deletions
+7
View File
@@ -101,6 +101,13 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) {
if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) {
t.Error("a plan change is not a new composition value")
}
signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1)
if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" {
t.Fatalf("a composition the ended controller signed does not decode: %v", err)
}
if strings.Contains(string(raw), "signature") {
t.Error("a composition is written with a signature")
}
refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"}
if raw, err = EncodeComposed(refused); err != nil {
t.Fatal(err)
-100
View File
@@ -1,100 +0,0 @@
package delivery
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
)
// A composition's signature (novox/hq ADR 0539 clause 3): the signer of a composition signs it with its key (the ended
// controller signed those it wrote, the operator one made by hand). The node-engine trusts the signers it is given and
// applies nothing of a composition whose signature fails or whose signer is none of them. The signature is an
// ed25519 signature, as a declaration's is, of the composition's digest (ComposedDigestOf) after a context line, so a
// signature made for anything else never verifies here.
const composedSigningContext = "novox composition v1\n"
// Signature is who signed a composition and the signature: Signer the signer's ed25519 public key and Sig the
// signature, both in standard base64.
type Signature struct {
Signer string `json:"signer"`
Sig string `json:"sig"`
}
func (s Signature) check() error {
if key, err := base64.StdEncoding.DecodeString(s.Signer); err != nil || len(key) != ed25519.PublicKeySize {
return fmt.Errorf("delivery: a composition's signer %q is not an ed25519 public key in base64", s.Signer)
}
if sig, err := base64.StdEncoding.DecodeString(s.Sig); err != nil || len(sig) != ed25519.SignatureSize {
return fmt.Errorf("delivery: a composition's signature is not an ed25519 signature in base64")
}
return nil
}
// SignComposed is c signed with key: its digest signed, and the key's public half named as the signer. A signature c
// already carries is replaced.
func SignComposed(c Composed, key ed25519.PrivateKey) (Composed, error) {
if len(key) != ed25519.PrivateKeySize {
return Composed{}, fmt.Errorf("delivery: the key to sign a composition with is not an ed25519 private key")
}
return SignComposedBy(c, key.Public().(ed25519.PublicKey), func(message []byte) ([]byte, error) {
return ed25519.Sign(key, message), nil
})
}
// SignComposedBy is SignComposed for a signer that keeps its private half to itself (the controller's identity store
// signs a message and hands back the signature): sign signs the message it is given with the key whose public half is
// signer. A signature that does not verify against signer is refused, so a composition is never written signed by a
// key other than the one it names.
func SignComposedBy(c Composed, signer ed25519.PublicKey, sign func(message []byte) ([]byte, error)) (Composed, error) {
if len(signer) != ed25519.PublicKeySize {
return Composed{}, fmt.Errorf("delivery: the signer of a composition is not an ed25519 public key")
}
c.Signature = nil
digest, err := ComposedDigestOf(c)
if err != nil {
return Composed{}, err
}
message := []byte(composedSigningContext + digest)
sig, err := sign(message)
if err != nil {
return Composed{}, err
}
if !ed25519.Verify(signer, message, sig) {
return Composed{}, fmt.Errorf("delivery: the signature made for the composition of %q on %s does not verify against its signer", c.Module, c.Node)
}
c.Signature = &Signature{Signer: base64.StdEncoding.EncodeToString(signer), Sig: base64.StdEncoding.EncodeToString(sig)}
return c, nil
}
// VerifyComposed is c's digest when c is signed by one of the trusted keys and its signature verifies against its
// digest; otherwise it refuses, saying why, and nothing of c is to be applied: an unsigned composition, one whose
// signer is not a trusted key, and one changed after it was signed.
func VerifyComposed(c Composed, trusted []ed25519.PublicKey) (string, error) {
if c.Signature == nil {
return "", fmt.Errorf("delivery: the composition of %q on %s is unsigned, and only a signed one is applied", c.Module, c.Node)
}
if err := c.Signature.check(); err != nil {
return "", err
}
signer, _ := base64.StdEncoding.DecodeString(c.Signature.Signer)
var key ed25519.PublicKey
for _, t := range trusted {
if t.Equal(ed25519.PublicKey(signer)) {
key = t
}
}
if key == nil {
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the vault's key or the operator's)",
c.Module, c.Node, c.Signature.Signer)
}
digest, err := ComposedDigestOf(c)
if err != nil {
return "", err
}
sig, _ := base64.StdEncoding.DecodeString(c.Signature.Sig)
if !ed25519.Verify(key, []byte(composedSigningContext+digest), sig) {
return "", fmt.Errorf("delivery: the composition of %q on %s does not match its signature: it was changed after it was signed, or signed for another",
c.Module, c.Node)
}
return digest, nil
}
-190
View File
@@ -1,190 +0,0 @@
package delivery
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"strings"
"testing"
"time"
)
func aSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return pub, priv
}
func aComposition() Composed {
return Composed{Node: "ace", Module: "m", FP: fp2of('a'), Resources: []json.RawMessage{
json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x"}`)}}
}
// 1943/AC5: a composition signed by the controller's key or by the operator's verifies against the two trusted keys,
// keeps its digest, and survives its encoding; one signed by another key, unsigned, or changed by one byte after it was
// signed does not verify, and nothing of it is to be applied.
func TestACompositionIsSignedByATrustedKey(t *testing.T) {
controllerPub, controller := aSigningKey(t)
operatorPub, operator := aSigningKey(t)
_, stranger := aSigningKey(t)
trusted := []ed25519.PublicKey{controllerPub, operatorPub}
c := aComposition()
want, _ := ComposedDigestOf(c)
for name, key := range map[string]ed25519.PrivateKey{"controller": controller, "operator": operator} {
signed, err := SignComposed(c, key)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
raw, err := EncodeComposed(signed)
if err != nil {
t.Fatal(err)
}
back, err := DecodeComposed(raw)
if err != nil {
t.Fatal(err)
}
digest, err := VerifyComposed(back, trusted)
if err != nil || digest != want {
t.Errorf("%s's signature: %q, %v; want the digest %s", name, digest, err, want)
}
if d, _ := ComposedDigestOf(back); d != want {
t.Errorf("%s's signature changed the digest: %s", name, d)
}
changed := strings.Replace(string(raw), `"content":"x"`, `"content":"y"`, 1)
if changed == string(raw) {
t.Fatal("the test changed nothing")
}
if tampered, err := DecodeComposed([]byte(changed)); err != nil {
t.Fatal(err)
} else if _, err := VerifyComposed(tampered, trusted); err == nil || !strings.Contains(err.Error(), "signature") {
t.Errorf("%s: a composition changed by one byte verified: %v", name, err)
}
}
byStranger, _ := SignComposed(c, stranger)
if _, err := VerifyComposed(byStranger, trusted); err == nil || !strings.Contains(err.Error(), "not a trusted") {
t.Errorf("a stranger's signature = %v, want refused as no trusted signer", err)
}
if _, err := VerifyComposed(c, trusted); err == nil || !strings.Contains(err.Error(), "unsigned") {
t.Errorf("an unsigned composition = %v, want refused", err)
}
signed, _ := SignComposed(c, controller)
forged := signed
forged.Signature = &Signature{Signer: signed.Signature.Signer, Sig: byStranger.Signature.Sig}
if _, err := VerifyComposed(forged, trusted); err == nil {
t.Errorf("a stranger's signature under the controller's name verified")
}
if _, err := EncodeComposed(Composed{Node: "ace", Module: "m", FP: fp2of('a'), Resources: c.Resources,
Signature: &Signature{Signer: "not base64!", Sig: "x"}}); err == nil {
t.Errorf("a signature naming no key was encoded")
}
}
// 1943/AC5: a signer that keeps its private half signs through a function, and the composition verifies as one signed
// with the key; a function that signs with another key than the one named is refused.
func TestACompositionIsSignedByAKeptKey(t *testing.T) {
pub, priv := aSigningKey(t)
_, other := aSigningKey(t)
c := aComposition()
signed, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(priv, m), nil })
if err != nil {
t.Fatal(err)
}
if _, err := VerifyComposed(signed, []ed25519.PublicKey{pub}); err != nil {
t.Errorf("a composition signed through the function does not verify: %v", err)
}
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: "operator", 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, "operator"},
{"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, "operator", SignerFingerprint(controllerPub)) {
t.Errorf("trusts: %v", got)
}
// A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that
// authority, and not for another name, another key, or against the controller's alone; a bundle given again adds no
// certificate twice.
authority := func(pub ed25519.PublicKey, priv ed25519.PrivateKey) string {
tpl := AuthorityTemplate(pub, time.Now().Add(-time.Hour))
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, priv)
if err != nil {
t.Fatal(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
vaultCA, controllerCA := authority(vaultPub, vault), authority(controllerPub, controller)
parent, _ := ParseCertificate(vaultCA)
serving, _ := aSigningKey(t)
der, err := x509.CreateCertificate(nil, NodeCertificateTemplate("ace.internal", serving, time.Now().Add(-time.Hour)), parent, serving, vault)
if err != nil {
t.Fatal(err)
}
leaf := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
bundle := AuthorityBundle(controllerCA, AuthorityBundle(controllerCA, vaultCA))
if n := strings.Count(bundle, "BEGIN CERTIFICATE"); n != 2 {
t.Errorf("the bundle holds %d certificates, want 2", n)
}
now := time.Now()
if err := CheckNodeCertificate(leaf, []string{bundle}, "ace.internal", serving, now); err != nil {
t.Errorf("the vault's certificate does not check: %v", err)
}
for name, err := range map[string]error{
"another name": CheckNodeCertificate(leaf, []string{vaultCA}, "g14.internal", serving, now),
"another key": CheckNodeCertificate(leaf, []string{vaultCA}, "ace.internal", controllerPub, now),
"the controller's alone": CheckNodeCertificate(leaf, []string{controllerCA}, "ace.internal", serving, now),
} {
if err == nil {
t.Errorf("%s: checked", name)
}
}
}
+9 -16
View File
@@ -57,9 +57,6 @@ type Composed struct {
// (ADR 0593 §6): a refused composition holds no resource, filled file, data or plan, and has no digest
// (ComposedDigestOf refuses it); only on a module's key. The node keeps running what it ran of the module.
Refused string
// Signature is the controller's or, made by hand, the operator's (ADR 0539 clause 3, SignComposed); it signs the
// digest and is never part of it.
Signature *Signature
}
// composedWire is a composition as it is written.
@@ -74,7 +71,9 @@ type composedWire struct {
Own *NodeOwn `json:"own,omitempty"`
Plan *catalogue.Plan `json:"plan,omitempty"`
Refused string `json:"refused,omitempty"`
Signature *Signature `json:"signature,omitempty"`
// Signature is read and dropped: a composition is unsigned (ADR 0593 §6), and a value the ended controller signed
// still decodes. Nothing writes it.
Signature json.RawMessage `json:"signature,omitempty"`
}
// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written.
@@ -165,11 +164,6 @@ func (c Composed) check() error {
}
}
}
if c.Signature != nil {
if err := c.Signature.check(); err != nil {
return err
}
}
return nil
}
@@ -185,7 +179,7 @@ func (c Composed) wire() (composedWire, error) {
return composedWire{}, err
}
w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
Plan: c.Plan, Refused: c.Refused, Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))}
Plan: c.Plan, Refused: c.Refused, Resources: make([]json.RawMessage, len(c.Resources))}
for i, r := range c.Resources {
canon, err := canonicalJSON(r)
if err != nil {
@@ -224,7 +218,7 @@ func DecodeComposed(data []byte) (Composed, error) {
return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err)
}
c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
Filled: w.Filled, Plan: w.Plan, Refused: w.Refused, Signature: w.Signature}
Filled: w.Filled, Plan: w.Plan, Refused: w.Refused}
if w.Own != nil {
c.Own = *w.Own
}
@@ -234,15 +228,14 @@ func DecodeComposed(data []byte) (Composed, error) {
return c, nil
}
// ComposedDigestOf is a composition's digest, "sha256:<hex>", of its encoding without its signature and its plan. A want names it
// (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it.
// ComposedDigestOf is a composition's digest, "sha256:<hex>", of its encoding without its plan. A want names it (ADR 0539
// clause 1), and the node's engine writes it beside the composition (ADR 0593 §6).
//
// A refused composition has no digest: it is refused, naming the module (ADR 0593 §6).
func ComposedDigestOf(c Composed) (string, error) {
if c.Refused != "" {
return "", fmt.Errorf("delivery: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused)
}
c.Signature = nil
c.Plan = nil
raw, err := EncodeComposed(c)
if err != nil {
@@ -252,8 +245,8 @@ func ComposedDigestOf(c Composed) (string, error) {
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// SameComposed says whether two compositions are the same value, their signatures included, so the controller writes
// only when they differ.
// SameComposed says whether two compositions are the same value, their plans included, so the engine writes only when
// they differ.
func SameComposed(a, b Composed) bool {
ra, errA := EncodeComposed(a)
rb, errB := EncodeComposed(b)