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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user