From d8da8f1d3613f605067de3e01af33dc36c576dd0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 16:36:14 +0200 Subject: [PATCH] Sign a composition with a trusted key and compose one by hand with mesh-compose (issue 1943, S5) The node-engine applies only a composition signed by the controller or the operator, and when the controller cannot compose its own fix the operator composes, signs and writes one at the terminal (ADR 0539 clauses 3 and 10). --- go/cmd/mesh-compose/main.go | 209 +++++++++++++++++++++++++ go/cmd/mesh-compose/main_test.go | 145 +++++++++++++++++ go/compose/composed.go | 94 +++++++++++ go/compose/composed_test.go | 72 +++++++++ go/delivery/composed_signature.go | 80 ++++++++++ go/delivery/composed_signature_test.go | 85 ++++++++++ go/delivery/composed_value.go | 20 ++- 7 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 go/cmd/mesh-compose/main.go create mode 100644 go/cmd/mesh-compose/main_test.go create mode 100644 go/compose/composed.go create mode 100644 go/compose/composed_test.go create mode 100644 go/delivery/composed_signature.go create mode 100644 go/delivery/composed_signature_test.go diff --git a/go/cmd/mesh-compose/main.go b/go/cmd/mesh-compose/main.go new file mode 100644 index 0000000..0a849d8 --- /dev/null +++ b/go/cmd/mesh-compose/main.go @@ -0,0 +1,209 @@ +// mesh-compose composes one module for one node by hand, from the inputs the controller keeps, and signs it with the +// operator's key, for the hand path of novox/hq ADR 0539 clause 10: a fingerprint the controller never composed (a +// hand build), while the controller cannot compose its own fix. It composes with mesh-sdk's composition, the one the +// controller writes with (compose.Compositions), and prints the composition, its key and its digest; only with -write +// does it write them, with the operator's credential: the composition into the node's bucket composed-, each +// file over 64 KiB into the node's object bucket, and the digest into mesh-controller_composed. The want is the +// operator's next act, naming that digest; each act is recorded in the hand-act log. +// +// mesh-compose -input -node -module -fp -key +// [-write -nats -creds ] +// +// The inputs are a compose.Input as JSON. The key is the operator's ed25519 private key, PKCS#8 in PEM; the +// node-engine trusts it beside the controller's (ADR 0539 clause 3). +package main + +import ( + "context" + "crypto/ed25519" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "mesh-compose:", err) + os.Exit(1) + } +} + +// printed is what mesh-compose prints: where the composition goes, its digest, the composition, and whether it was +// written. +type printed struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Digest string `json:"digest"` + Composition json.RawMessage `json:"composition"` + Objects []string `json:"objects,omitempty"` + Written bool `json:"written"` + Next string `json:"next"` +} + +func run(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError) + input := fs.String("input", "", "the controller's kept inputs, a compose.Input as JSON") + node := fs.String("node", "", "the node to compose for") + module := fs.String("module", "", "the module to compose") + fp := fs.String("fp", "", "the module's fingerprint, src2: and 64 hex digits") + keyFile := fs.String("key", "", "the operator's ed25519 private key, PKCS#8 in PEM") + write := fs.Bool("write", false, "write the composition, its files and its digest, with the operator's credential") + url := fs.String("nats", nats.DefaultURL, "with -write: the bus") + creds := fs.String("creds", "", "with -write: the operator's credential file") + if err := fs.Parse(args); err != nil { + return err + } + switch { + case *input == "" || *node == "" || *module == "" || *fp == "": + return errors.New("-input, -node, -module and -fp are each required") + case *keyFile == "": + return errors.New("-key is required: a composition made by hand is signed by the operator's key, and the node-engine applies no other") + case *write && *creds == "": + return errors.New("-write needs -creds: the operator's credential is the only hand writer of a composition") + } + key, err := readKey(*keyFile) + if err != nil { + return err + } + key2, err := delivery.ComposedKey(*node, *module, *fp) + if err != nil { + return err + } + bucket, err := delivery.ComposedBucketFor(*node) + if err != nil { + return err + } + raw, err := os.ReadFile(*input) + if err != nil { + return err + } + var in compose.Input + if err := json.Unmarshal(raw, &in); err != nil { + return fmt.Errorf("the inputs in %s are not a compose.Input: %w", *input, err) + } + all, _, err := compose.Compositions(in, *node, map[string]string{*module: *fp}, compose.Reading) + if err != nil { + return err + } + c, ok := all[key2] + if !ok { + return fmt.Errorf("%s does not run %s: its composition holds none of it", *node, *module) + } + c, objects, err := c.ByDigest() + if err != nil { + return err + } + if c, err = delivery.SignComposed(c, key); err != nil { + return err + } + encoded, err := delivery.EncodeComposed(c) + if err != nil { + return err + } + digest, err := delivery.ComposedDigestOf(c) + if err != nil { + return err + } + out := printed{Bucket: bucket, Key: key2, Digest: digest, Composition: encoded, + Next: fmt.Sprintf("write want.%s.%s naming fp %s and composed %s with the operator's credential, and record each act in the hand-act log", *node, *module, *fp, digest)} + for d := range objects { + out.Objects = append(out.Objects, d) + } + if *write { + if err := writeIt(*url, *creds, *node, bucket, key2, encoded, digest, objects); err != nil { + return err + } + out.Written = true + } + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func readKey(path string) (ed25519.PrivateKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("%s holds no PEM block", path) + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + key, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("%s is not an ed25519 key", path) + } + return key, nil +} + +// writeIt writes the files by digest, then the composition, then its digest: a reader that finds the digest finds the +// composition and its files already there. +func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string, objects map[string][]byte) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + nc, err := nats.Connect(url, nats.UserCredentials(creds), nats.Name("mesh-compose")) + if err != nil { + return err + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + return err + } + if len(objects) > 0 { + filesBucket, err := declaration.FileBucketFor(node) + if err != nil { + return err + } + store, err := js.ObjectStore(ctx, filesBucket) + if err != nil { + return fmt.Errorf("the node's object bucket %s: %w", filesBucket, err) + } + for d, body := range objects { + name, err := declaration.ObjectName(d) + if err != nil { + return err + } + if _, err := store.PutBytes(ctx, name, body); err != nil { + return fmt.Errorf("the file %s: %w", d, err) + } + } + } + composed, err := js.KeyValue(ctx, bucket) + if err != nil { + return fmt.Errorf("the bucket %s: %w", bucket, err) + } + if _, err := composed.Put(ctx, key, encoded); err != nil { + return fmt.Errorf("%s in %s: %w", key, bucket, err) + } + value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot, + Words: "composed by hand with mesh-compose (ADR 0539 clause 10): the operator writes the want"}) + if err != nil { + return err + } + digests, err := js.KeyValue(ctx, delivery.ControllerComposedBucket) + if err != nil { + return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err) + } + if _, err := digests.Put(ctx, key, value); err != nil { + return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err) + } + return nil +} diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go new file mode 100644 index 0000000..f18c0f0 --- /dev/null +++ b/go/cmd/mesh-compose/main_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// aMesh is the Input of mesh-controller's aMesh, as mesh-sdk's compose tests make it (compose/fixture_test.go): two +// machines on the private network, the module the mesh provides for itself assigned to both. The manifest is the one +// the controller provides (overlay.Manifest), read back as the catalogue reads it. +func aMesh(t *testing.T) compose.Input { + t.Helper() + var m catalogue.Manifest + b, _ := json.Marshal(overlay.Manifest()) + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + in := compose.Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: m}} + for i, name := range []string{"anchor", "laptop"} { + pub, _, _ := ed25519.GenerateKey(rand.Reader) + in.Nodes = append(in.Nodes, compose.Node{Name: name, + Place: compose.Place{Key: base64.StdEncoding.EncodeToString(pub), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, + Assigned: []string{overlay.Name}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) + } + return in +} + +func writeKey(t *testing.T, dir string) ed25519.PublicKey { + t.Helper() + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "operator.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } + return pub +} + +// 1943/AC5: mesh-compose, given the kept inputs, a node, a module and its fingerprint, prints that module's +// composition for that node signed by the operator's key and its digest: its resources are the controller's for that +// module on that node (compose.Compose), and it verifies against the operator's key. Without --write it writes nothing. +func TestMeshComposeGivesTheControllersResourcesSignedByTheOperator(t *testing.T) { + dir := t.TempDir() + in := aMesh(t) + raw, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + input := filepath.Join(dir, "input.json") + if err := os.WriteFile(input, raw, 0o600); err != nil { + t.Fatal(err) + } + operator := writeKey(t, dir) + fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) + + var out bytes.Buffer + if err := run([]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, + "-key", filepath.Join(dir, "operator.key")}, &out); err != nil { + t.Fatal(err) + } + var printed struct { + Key string `json:"key"` + Bucket string `json:"bucket"` + Digest string `json:"digest"` + Composition json.RawMessage `json:"composition"` + Written bool `json:"written"` + } + if err := json.Unmarshal(out.Bytes(), &printed); err != nil { + t.Fatalf("%s: %v", out.String(), err) + } + c, err := delivery.DecodeComposed(printed.Composition) + if err != nil { + t.Fatal(err) + } + if digest, err := delivery.VerifyComposed(c, []ed25519.PublicKey{operator}); err != nil || digest != printed.Digest { + t.Errorf("the composition verifies %q, %v against the operator's key; printed %s", digest, err, printed.Digest) + } + wantKey, _ := delivery.ComposedKey("laptop", overlay.Name, fp) + if printed.Key != wantKey || printed.Bucket != "composed-laptop" || printed.Written { + t.Errorf("printed key %s in %s, written %v", printed.Key, printed.Bucket, printed.Written) + } + + d, _, err := compose.Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + var want, got []string + for _, r := range d.Resources { + want = append(want, r["id"].(string)) + } + for _, r := range c.Resources { + var head struct{ ID string } + _ = json.Unmarshal(r, &head) + got = append(got, head.ID) + } + sort.Strings(want) + sort.Strings(got) + if len(got) == 0 || strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("mesh-compose gives %v, the controller's composition %v", got, want) + } +} + +// 1943/AC5: mesh-compose refuses what it cannot compose, naming why: no key, a fingerprint not of the new form, a +// module the node does not run, a node the mesh does not have. +func TestMeshComposeRefuses(t *testing.T) { + dir := t.TempDir() + raw, _ := json.Marshal(aMesh(t)) + input := filepath.Join(dir, "input.json") + _ = os.WriteFile(input, raw, 0o600) + writeKey(t, dir) + key := filepath.Join(dir, "operator.key") + fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) + for _, c := range []struct { + args []string + why string + }{ + {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp}, "-key"}, + {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", "src2:1", "-key", key}, "fingerprint"}, + {[]string{"-input", input, "-node", "laptop", "-module", "mesh-mail", "-fp", fp, "-key", key}, "does not run mesh-mail"}, + {[]string{"-input", input, "-node", "nowhere", "-module", overlay.Name, "-fp", fp, "-key", key}, "nowhere"}, + {[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, "-key", key, "-write"}, "-creds"}, + } { + var out bytes.Buffer + if err := run(c.args, &out); err == nil || !strings.Contains(err.Error(), c.why) { + t.Errorf("mesh-compose %v = %v, want a refusal saying %q", c.args, err, c.why) + } + } +} diff --git a/go/compose/composed.go b/go/compose/composed.go new file mode 100644 index 0000000..953b896 --- /dev/null +++ b/go/compose/composed.go @@ -0,0 +1,94 @@ +package compose + +import ( + "encoding/json" + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as +// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own +// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. +// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module +// (mesh-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not +// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). +func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { + reading := in + reading.Nodes = append([]Node(nil), in.Nodes...) + found := false + for i := range reading.Nodes { + if reading.Nodes[i].Name == node { + reading.Nodes[i].ReadsModules = true + found = true + } + } + if !found { + return nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) + } + d, problems, err := ComposeIn(reading, node, mode) + if err != nil { + return nil, nil, err + } + if len(problems) > 0 { + return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) + } + body, err := d.Body() + if err != nil { + return nil, nil, err + } + var envelope struct { + Resources []json.RawMessage `json:"resources"` + Adoption *declaration.Adoption `json:"adoption"` + Filled []string `json:"filled_files"` + Provisioner string `json:"provisioner_account"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + var kept []json.RawMessage + keptPaths := map[string]bool{} + left := map[string]bool{} + for _, raw := range envelope.Resources { + var head struct { + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return nil, nil, err + } + if _, ok := fps[head.Module]; head.Module != "" && !ok { + left[head.Module] = true + continue + } + if head.Type == string(declaration.TypeFile) { + keptPaths[head.Path] = true + } + kept = append(kept, raw) + } + var filled []string + for _, p := range envelope.Filled { + if keptPaths[p] { + filled = append(filled, p) + } + } + out, err = delivery.SplitComposed(node, kept, fps, filled, + delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) + if err != nil { + return nil, nil, err + } + for key, c := range out { + if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { + c.Data = len(m.DataItems()) > 0 + out[key] = c + } + } + for m := range left { + skipped = append(skipped, m) + } + sort.Strings(skipped) + return out, skipped, nil +} diff --git a/go/compose/composed_test.go b/go/compose/composed_test.go new file mode 100644 index 0000000..b6dd92e --- /dev/null +++ b/go/compose/composed_test.go @@ -0,0 +1,72 @@ +package compose + +import ( + "encoding/json" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + +// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs +// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on +// it; a module given no fingerprint is named as skipped and composed nowhere. +func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + var want []string + for _, r := range d.Resources { + want = append(want, r["type"].(string)+":"+r["id"].(string)) + } + sort.Strings(want) + + fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')} + out, skipped, err := Compositions(in, "laptop", fps, Foreseeing) + if err != nil || len(skipped) != 0 { + t.Fatalf("Compositions = %v, skipped %v", err, skipped) + } + var got []string + for key, c := range out { + node, module, fp, isNode, err := delivery.ParseComposedKey(key) + if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { + t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) + } + if !isNode && fp != fps[module] { + t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) + } + if _, err := delivery.EncodeComposed(c); err != nil { + t.Errorf("%s: %v", key, err) + } + for _, raw := range c.Resources { + var r struct{ ID, Type, Module string } + _ = json.Unmarshal(raw, &r) + if r.Module != module { + t.Errorf("%s holds %s of %q", key, r.ID, r.Module) + } + got = append(got, r.Type+":"+r.ID) + } + } + sort.Strings(got) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("the compositions hold %v, the declaration %v", got, want) + } + + one, skipped, err := Compositions(in, "laptop", map[string]string{"files": fpFor('b')}, Foreseeing) + if err != nil || strings.Join(skipped, ",") != "mesh-wireguard" { + t.Fatalf("one module: %v, skipped %v", err, skipped) + } + for _, c := range one { + if c.Module == "mesh-wireguard" { + t.Errorf("a module given no fingerprint was composed") + } + } + if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { + t.Errorf("a node the mesh does not have was composed") + } +} diff --git a/go/delivery/composed_signature.go b/go/delivery/composed_signature.go new file mode 100644 index 0000000..df7219f --- /dev/null +++ b/go/delivery/composed_signature.go @@ -0,0 +1,80 @@ +package delivery + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" +) + +// A composition's signature (novox/hq ADR 0539 clause 3): the controller signs each composition it writes with its +// key, and the operator signs one made by hand with mesh-compose with theirs. The node-engine trusts exactly these two +// signers and applies nothing of a composition whose signature fails or whose signer is neither. 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") + } + digest, err := ComposedDigestOf(c) + if err != nil { + return Composed{}, err + } + sig := ed25519.Sign(key, []byte(composedSigningContext+digest)) + c.Signature = &Signature{Signer: base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)), + 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 controller'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 +} diff --git a/go/delivery/composed_signature_test.go b/go/delivery/composed_signature_test.go new file mode 100644 index 0000000..d0606c5 --- /dev/null +++ b/go/delivery/composed_signature_test.go @@ -0,0 +1,85 @@ +package delivery + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "strings" + "testing" +) + +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") + } +} diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go index 8ec92bc..e490425 100644 --- a/go/delivery/composed_value.go +++ b/go/delivery/composed_value.go @@ -46,6 +46,9 @@ type Composed struct { Resources []json.RawMessage Filled []string Own NodeOwn + // 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. @@ -58,6 +61,7 @@ type composedWire struct { Resources []json.RawMessage `json:"resources"` Filled []string `json:"filled_files,omitempty"` Own *NodeOwn `json:"own,omitempty"` + Signature *Signature `json:"signature,omitempty"` } // canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written. @@ -126,6 +130,11 @@ func (c Composed) check() error { } } } + if c.Signature != nil { + if err := c.Signature.check(); err != nil { + return err + } + } return nil } @@ -135,7 +144,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, - Resources: make([]json.RawMessage, len(c.Resources))} + Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} for i, r := range c.Resources { canon, err := canonicalJSON(r) if err != nil { @@ -174,7 +183,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} + Filled: w.Filled, Signature: w.Signature} if w.Own != nil { c.Own = *w.Own } @@ -184,9 +193,10 @@ func DecodeComposed(data []byte) (Composed, error) { return c, nil } -// ComposedDigestOf is a composition's digest, "sha256:", of its encoding. A want names it (ADR 0539 clause 1), -// and the controller writes it beside the composition. +// ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature. A want names it +// (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it. func ComposedDigestOf(c Composed) (string, error) { + c.Signature = nil raw, err := EncodeComposed(c) if err != nil { return "", err @@ -195,7 +205,7 @@ func ComposedDigestOf(c Composed) (string, error) { return "sha256:" + hex.EncodeToString(sum[:]), nil } -// SameComposed says whether two compositions are the same value, so the controller writes +// SameComposed says whether two compositions are the same value, their signatures included, so the controller writes // only when they differ. func SameComposed(a, b Composed) bool { ra, errA := EncodeComposed(a)