diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go new file mode 100644 index 0000000..65a3ed0 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment.go @@ -0,0 +1,38 @@ +package meshnodeenrollerclient + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +const ( + // DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (ADR 0587 §4). + DefaultTokenLife = time.Hour + MostTokenLife = 24 * time.Hour + + // UserPrefix is the bus user a joining machine dials as, `enrol.` (ADR 0587 §4). + UserPrefix = "enrol." +) + +// User is the bus user a joining machine dials as. +func User(node string) string { return UserPrefix + node } + +// IssuedID is the id of the enrolment-issued event of node's enrolment issued at issued: +// `enrolment-issued..`, what enrolment-expired names. +func IssuedID(node string, issued time.Time) string { + return "enrolment-issued." + node + "." + strconv.FormatInt(issued.UnixNano(), 10) +} + +// TokenLife is the life an issue's `expires` asks for: a duration, one hour when empty, at most MostTokenLife. +func TokenLife(expires string) (time.Duration, error) { + if strings.TrimSpace(expires) == "" { + return DefaultTokenLife, nil + } + d, err := time.ParseDuration(strings.TrimSpace(expires)) + if err != nil || d <= 0 || d > MostTokenLife { + return 0, fmt.Errorf("meshnodeenrollerclient: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires) + } + return d, nil +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go new file mode 100644 index 0000000..41098fe --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/enrolment_test.go @@ -0,0 +1,30 @@ +package meshnodeenrollerclient + +import ( + "testing" + "time" +) + +func TestATokenLifeAndTheRedeemProofAreAsTheHolderMakesThem(t *testing.T) { + for life, ok := range map[string]bool{"": true, "1h": true, "24h": true, "25h": false, "0s": false, "-1h": false, "soon": false} { + if _, err := TokenLife(life); (err == nil) != ok { + t.Errorf("TokenLife(%q) = %v, want ok %v", life, err, ok) + } + } + if d, _ := TokenLife(""); d != DefaultTokenLife { + t.Errorf("an empty life is %s", d) + } + if got := IssuedID("g14", time.Unix(0, 42)); got != "enrolment-issued.g14.42" { + t.Errorf("IssuedID = %s", got) + } + if User("g14") != "enrol.g14" { + t.Error("the joining machine dials as enrol.") + } + r := Redeem{Node: "g14", PublicKey: "p", OverlayKey: "o", SealingKey: "s", ServingKey: "v"} + if string(RedeemProof("g14", "p", "o", "s", "v")) != "novox-mesh-redeem v1\x00g14\x00p\x00o\x00s\x00v" || r.KeysDigest() != KeysDigest("p", "o", "s", "v") { + t.Error("the proof bytes or the keys' digest changed") + } + if got := (RedeemAnswer{Node: "g14", Signature: "x"}).SignedBytes(); string(got) == "" || string(got) == "x" { + t.Errorf("SignedBytes = %s", got) + } +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/go.mod b/identity-and-access/go/clients/meshnodeenrollerclient/go.mod new file mode 100644 index 0000000..4b99275 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient + +go 1.26.0 diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go b/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go new file mode 100644 index 0000000..9ad4ac1 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/redeem.go @@ -0,0 +1,65 @@ +package meshnodeenrollerclient + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "strings" +) + +// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, +// which live in two repositories. + +// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over +// RedeemProof. No token: the bus's login check verified it. +type Redeem struct { + Node string `json:"node"` + PublicKey string `json:"public_key"` + OverlayKey string `json:"overlay_key,omitempty"` + SealingKey string `json:"sealing_key"` + ServingKey string `json:"serving_key,omitempty"` + Profile map[string]any `json:"profile,omitempty"` + Tunnel json.RawMessage `json:"tunnel,omitempty"` + Proof string `json:"proof"` +} + +// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it +// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes. +func RedeemProof(node, public, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + +// KeysDigest is the digest of the public keys a redeem presents, `sha256:`: what the key at redeeming and the +// events carry, the one hash an event carries (§1). +func KeysDigest(public, overlay, sealing, serving string) string { + sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00"))) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// KeysDigest is r's keys' digest. +func (r Redeem) KeysDigest() string { + return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey) +} + +// ProofBytes is r's proof decoded from base64. +func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) } + +// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it +// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over +// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries. +type RedeemAnswer struct { + Node string `json:"node"` + Login string `json:"login"` + Account string `json:"account"` + Certificate string `json:"certificate"` + Signer string `json:"signer"` + Signature string `json:"signature,omitempty"` +} + +// SignedBytes is what the vault signs of a: a without its signature, as JSON. +func (a RedeemAnswer) SignedBytes() []byte { + a.Signature = "" + raw, _ := json.Marshal(a) + return raw +} diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/token.go b/identity-and-access/go/clients/meshnodeenrollerclient/token.go new file mode 100644 index 0000000..d8d44d6 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/token.go @@ -0,0 +1,118 @@ +package meshnodeenrollerclient + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate +// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the +// machine's tunnel key, the machine's first tunnel (ADR 0169). +// +// Shared so the vault, which makes the +// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the +// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue` +// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of +// ADR 0587 §3, 2026-11-02). +type Token struct { + Version int `json:"v"` + + // Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before + // the mesh can say anything. + Node string `json:"node,omitempty"` + + Broker string `json:"broker,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Signer []byte `json:"signer,omitempty"` + Secret string `json:"secret"` + + // Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node. + Adopted bool `json:"adopted,omitempty"` + + // Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own + // address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet. + Tunnel *TokenTunnel `json:"tunnel,omitempty"` +} + +// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes. +type TokenTunnel struct { + Key string `json:"key"` + Address string `json:"address"` + Range string `json:"range"` + HubKey string `json:"hub_key"` + HubEndpoint string `json:"hub_endpoint"` +} + +// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong +// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token +// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects +// to whatever answers, without the signing key it cannot tell a declaration from a forgery. +func ParseToken(encoded string) (Token, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + var t Token + if err := json.Unmarshal(raw, &t); err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + if t.Version != 1 { + return Token{}, fmt.Errorf( + "this token says it is version %d, and this host understands version 1", t.Version) + } + if missing := t.Missing(); len(missing) > 0 { + return Token{}, fmt.Errorf( + "this token is missing %s, so it cannot be used to join anything", + strings.Join(missing, ", ")) + } + return t, nil +} + +// Missing names every part of t a joining machine needs and t lacks. +func (t Token) Missing() []string { + var missing []string + if strings.TrimSpace(t.Broker) == "" { + missing = append(missing, "the broker's address") + } + if strings.TrimSpace(t.Fingerprint) == "" { + missing = append(missing, "the broker certificate's fingerprint") + } + if len(t.Signer) != ed25519.PublicKeySize { + missing = append(missing, "the vault's signing key") + } + if strings.TrimSpace(t.Secret) == "" { + missing = append(missing, "the one-time secret") + } + if tt := t.Tunnel; tt != nil { + for _, part := range []struct{ value, says string }{ + {tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"}, + {tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"}, + {tt.HubEndpoint, "where the hub's tunnel is dialled"}, + } { + if strings.TrimSpace(part.value) == "" { + missing = append(missing, part.says) + } + } + } + return missing +} + +// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the +// controller's `token issue` writes it. Refused when a part is missing. +func (t Token) Encode() (string, error) { + t.Version = 1 + if missing := t.Missing(); len(missing) > 0 { + return "", fmt.Errorf("meshnodeenrollerclient: the token is missing %s", strings.Join(missing, ", ")) + } + raw, err := json.Marshal(t) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after. +func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go b/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go new file mode 100644 index 0000000..97e5832 --- /dev/null +++ b/identity-and-access/go/clients/meshnodeenrollerclient/token_test.go @@ -0,0 +1,53 @@ +package meshnodeenrollerclient + +import ( + "encoding/hex" + "reflect" + "strings" + "testing" +) + +// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9, +// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519 +// key of the seed 00 01 .. 1f. Copied, never written by hand. +const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19" + +// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it +// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens. +func TestATokenTheControllerMadeReads(t *testing.T) { + signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") + want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true, + Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24", + HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}} + got, err := ParseToken(" " + controllerToken + "\n") + if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 { + t.Fatalf("parsed %+v, %v", got, err) + } + if again, err := got.Encode(); err != nil || again != controllerToken { + t.Errorf("encoded again: %q, %v", again, err) + } + for _, c := range []struct { + cut func(*Token) + expect string + }{ + {func(t *Token) { t.Broker = "" }, "broker's address"}, + {func(t *Token) { t.Fingerprint = "" }, "fingerprint"}, + {func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"}, + {func(t *Token) { t.Secret = "" }, "one-time secret"}, + {func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"}, + } { + cut := want + cut.Tunnel = &TokenTunnel{} + *cut.Tunnel = *want.Tunnel + c.cut(&cut) + if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) { + t.Errorf("a token without %s: %v", c.expect, err) + } + } + for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} { + if _, err := ParseToken(bad); err == nil { + t.Errorf("%q parsed as a token", bad) + } + } +} diff --git a/identity-and-access/go/clients/meshvaultclient/client_test.go b/identity-and-access/go/clients/meshvaultclient/client_test.go new file mode 100644 index 0000000..2c00bf5 --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/client_test.go @@ -0,0 +1,73 @@ +package meshvaultclient + +import ( + "crypto/ed25519" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" +) + +func TestSigningNodeLoginsAndOwnDrift(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + k := NewSignerKey(pub, time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)) + if got, err := SignerPublicKey(k); err != nil || !got.Equal(pub) { + t.Fatalf("the key round trips: %v", err) + } + k.Fingerprint = "sha256:00" + if _, err := SignerPublicKey(k); err == nil { + t.Error("a key with another fingerprint was taken") + } + ctx, err := SignContext(KindEnrolment) + if err != nil || ctx != "novox enrolment v1\n" { + t.Fatalf("SignContext = %q, %v", ctx, err) + } + if _, err := SignContext("other"); err == nil { + t.Error("an unknown kind has a context") + } + sig := SignMessage(priv, ctx, []byte("m")) + if s, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, ctx, []byte("m"), sig); err != nil || s.Name != SignerVault { + t.Errorf("VerifyBy = %v, %v", s, err) + } + other, _ := SignContext(KindAuthority) + if _, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, other, []byte("m"), sig); err == nil { + t.Error("a signature made for one kind verified as another's") + } + + // A node's two logins: the first, the second, a rotation to the other only, a stage only with a rotation. + since := "2026-11-02T01:00:00Z" + for name, c := range map[string]struct { + l seat.NodeLoginsByNodeValue + ok bool + }{ + "first login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}, true}, + "rotating to other": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationBegun, Since: since}, true}, + "rotating to itself": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun, Since: since}, false}, + "stage without rotate": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Stage: RotationReady, Since: since}, false}, + "another's login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.ace", Since: since}, false}, + } { + if err := CheckNodeLogin(c.l); (err == nil) != c.ok { + t.Errorf("%s: %v", name, err) + } + } + if raw, err := EncodeNodeLogin(seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}); err != nil { + t.Fatal(err) + } else if back, err := DecodeNodeLogin(raw); err != nil || Rotates(back) { + t.Errorf("round trip: %+v %v", back, err) + } + if _, err := DecodeNodeLogin([]byte(`{"v":9,"node":"g14","login":"node.g14","since":"` + since + `"}`)); err == nil { + t.Error("version 9 was taken") + } + if n, ok := LoginNode("node.g14--b"); !ok || n != "g14" { + t.Errorf("LoginNode = %q %v", n, ok) + } + + masked := []ModuleBodies{{Module: "nats", Bodies: []Body{{ID: "nats.needs-jwt", Composed: "x " + MaskedAnotherValue}, {ID: "nats.conf", Running: MaskedAnotherValue}}}} + if own := OwnValuesDiffer(masked); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) || ValuesDiffer(masked) != 2 { + t.Errorf("OwnValuesDiffer = %v, ValuesDiffer = %d", own, ValuesDiffer(masked)) + } + if !strings.Contains(MaskedAnotherValue, "another value") { + t.Error("the mask word changed") + } +} diff --git a/identity-and-access/go/clients/meshvaultclient/go.mod b/identity-and-access/go/clients/meshvaultclient/go.mod new file mode 100644 index 0000000..39ad4cc --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-20261105000000-000000000000 +) diff --git a/identity-and-access/go/clients/meshvaultclient/nodelogins.go b/identity-and-access/go/clients/meshvaultclient/nodelogins.go new file mode 100644 index 0000000..314af7f --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/nodelogins.go @@ -0,0 +1,124 @@ +package meshvaultclient + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" +) + +// **A node's two logins** (novox/hq ADR 0583): a node's bus account rotates between the login `node.` and +// `node.--b`. The layout of the state `node-logins` is the seat's contract's; this is the logic every reader of it +// shares. +const ( + // The stages of a rotation (ADR 0583 §2-§4). + RotationBegun = "begun" + RotationReady = "ready" + RotationConfirmed = "confirmed" + + nodeLoginPrefix = "node." + // SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114). + SecondLoginSuffix = "--b" + + // Version is the version of every value of the seat's states. + Version = 1 +) + +// ErrUnknownVersion is what a decode returns for a value whose version is not Version. +var ErrUnknownVersion = errors.New("meshvaultclient: unknown value version") + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +func checkName(kind, name string) error { + if name == "" || !nameOK.MatchString(name) { + return fmt.Errorf("meshvaultclient: the %s name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", kind, name) + } + return nil +} + +// FirstLogin is a node's first login, `node.`; SecondLogin its other. +func FirstLogin(node string) string { return nodeLoginPrefix + node } +func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix } + +// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second. +func OtherLogin(node, login string) string { + if login == SecondLogin(node) { + return FirstLogin(node) + } + return SecondLogin(node) +} + +// LoginNode is the node a node login names, either of its two, and whether login is one. +func LoginNode(login string) (string, bool) { + rest, ok := strings.CutPrefix(login, nodeLoginPrefix) + rest = strings.TrimSuffix(rest, SecondLoginSuffix) + return rest, ok && checkName("node", rest) == nil +} + +// Rotates says the node is rotating: a login rotating is named. +func Rotates(l seat.NodeLoginsByNodeValue) bool { return l.Rotating != "" } + +// CheckNodeLogin refuses a login that is not one of the node's two, a rotation to anything but the other, and a stage +// without a rotation or an unknown one. +func CheckNodeLogin(l seat.NodeLoginsByNodeValue) error { + if err := checkName("node", l.Node); err != nil { + return err + } + if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) { + return fmt.Errorf("meshvaultclient: the node %s logs in as %q, neither of its two logins", l.Node, l.Login) + } + for _, act := range l.Acts { + if strings.TrimSpace(act) == "" { + return fmt.Errorf("meshvaultclient: the node %s names an empty hand act among its rotations' warrants", l.Node) + } + } + if !Rotates(l) { + if l.Stage != "" || l.ListRevision != 0 { + return fmt.Errorf("meshvaultclient: the node %s rotates to nothing and names stage %q", l.Node, l.Stage) + } + return nil + } + if l.Rotating != OtherLogin(l.Node, l.Login) { + return fmt.Errorf("meshvaultclient: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating) + } + switch l.Stage { + case RotationBegun, RotationReady, RotationConfirmed: + default: + return fmt.Errorf("meshvaultclient: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage) + } + return nil +} + +// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses. +func EncodeNodeLogin(l seat.NodeLoginsByNodeValue) ([]byte, error) { + if err := CheckNodeLogin(l); err != nil { + return nil, err + } + l.V = Version + return json.Marshal(l) +} + +// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion) and what +// CheckNodeLogin refuses. +func DecodeNodeLogin(data []byte) (seat.NodeLoginsByNodeValue, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.NodeLoginsByNodeValue{}, fmt.Errorf("meshvaultclient: value is not JSON: %w", err) + } + if probe.V == nil || *probe.V != Version { + return seat.NodeLoginsByNodeValue{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + l, err := seat.DecodeNodeLoginsByNodeValue(data) + if err != nil { + return l, fmt.Errorf("meshvaultclient: value does not fit its shape: %w", err) + } + if err := CheckNodeLogin(l); err != nil { + return seat.NodeLoginsByNodeValue{}, err + } + return l, nil +} diff --git a/identity-and-access/go/clients/meshvaultclient/owndrift.go b/identity-and-access/go/clients/meshvaultclient/owndrift.go new file mode 100644 index 0000000..ebf5f9d --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/owndrift.go @@ -0,0 +1,95 @@ +package meshvaultclient + +import ( + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The own secrets a node's shadow answer finds the vault holds another value for than the node runs (issue 1126, cause +// C4): the vault's `move-in` never replaces a value it holds, so a value made anew later runs while the vault keeps the +// first one it was handed. The node opens both seals with its own key and masks the value as MaskedAnotherValue; this +// reads which own secrets those are, for the node-engine (which hands the vault what runs) and for the vault's own +// drift tool, so both read the answer one way. + +// MaskedAnotherValue is the word a shown body masks a value with when the node opened both sides with its key and they +// are two values. +const MaskedAnotherValue = "" + +// OwnValueDiffers is one own secret of a module whose value composed (the vault's) is another than the one that runs. +type OwnValueDiffers struct { + Module string `json:"module"` + Name string `json:"name"` +} + +// Body is one differing resource of a module as a node's answer shows it: the resource's id and both sides, masked. +type Body struct { + ID string + Composed string + Running string +} + +// ModuleBodies is the shown bodies of one module whose composition differs. +type ModuleBodies struct { + Module string + Bodies []Body +} + +var ownNeedPrefix = catalogue.NeedID("") + +// OwnNeedName is the name of module's own secret whose file a resource id names, and whether it names one. On a node a +// resource's id carries its module's name (`only-office.needs-jwt`); the bare form a declaration composes +// (`needs-jwt`, catalogue.NeedID) is read too. An id of another module's is not this module's. +func OwnNeedName(module, id string) (string, bool) { + if module != "" { + id = strings.TrimPrefix(id, module+".") + } + name, own := strings.CutPrefix(id, ownNeedPrefix) + return name, own && name != "" && !strings.Contains(name, ".") +} + +// OwnValuesDiffer is every own secret whose shown bodies say it holds another value composed than running, sorted by +// module and name, each once. A body the answer did not show is not read. +func OwnValuesDiffer(differs []ModuleBodies) []OwnValueDiffers { + seen := map[OwnValueDiffers]bool{} + var out []OwnValueDiffers + for _, d := range differs { + for _, b := range d.Bodies { + name, own := OwnNeedName(d.Module, b.ID) + if !own || !anotherValue(b) { + continue + } + o := OwnValueDiffers{Module: d.Module, Name: name} + if !seen[o] { + seen[o] = true + out = append(out, o) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Module != out[j].Module { + return out[i].Module < out[j].Module + } + return out[i].Name < out[j].Name + }) + return out +} + +// ValuesDiffer is how many shown bodies hold a value masked MaskedAnotherValue, own secrets and every other resource +// alike. +func ValuesDiffer(differs []ModuleBodies) int { + n := 0 + for _, d := range differs { + for _, b := range d.Bodies { + if anotherValue(b) { + n++ + } + } + } + return n +} + +func anotherValue(b Body) bool { + return strings.Contains(b.Composed, MaskedAnotherValue) || strings.Contains(b.Running, MaskedAnotherValue) +} diff --git a/identity-and-access/go/clients/meshvaultclient/signers.go b/identity-and-access/go/clients/meshvaultclient/signers.go new file mode 100644 index 0000000..1ef79ce --- /dev/null +++ b/identity-and-access/go/clients/meshvaultclient/signers.go @@ -0,0 +1,296 @@ +package meshvaultclient + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" + "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. A node trusts the vault's key alone since the +// controller ended (ADR 0590). What a node trusts now it states in its record, the node record's trusts. + +const ( + // The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign, + // SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1). + + // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. + KindNodeCertificate = "node-certificate" + // KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for the seat mesh-node-enroller alone (ADR 0587 + // §3, §6). + KindEnrolment = "enrolment" + // KindAuthority is the vault's own authority certificate, which the vault signs for itself. + KindAuthority = "authority" + + // SignerVault names the signer a node trusts. + SignerVault = "vault" + + // CertificateLife is how long a certificate the vault signs for a node lasts: ten years, 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("meshvaultclient: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", ")) + } + return "novox " + kind + " v1\n", nil +} + +// 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("meshvaultclient: 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) seat.SignersByKeyValue { + return seat.SignersByKeyValue{V: 1, Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC().Format(time.RFC3339Nano)} +} + +// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names. +func SignerPublicKey(k seat.SignersByKeyValue) (ed25519.PublicKey, error) { + raw, err := base64.StdEncoding.DecodeString(k.Key) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil, errors.New("meshvaultclient: the signers' key is not an ed25519 public key in base64") + } + if fp := SignerFingerprint(raw); fp != k.Fingerprint { + return nil, fmt.Errorf("meshvaultclient: 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) (seat.SignersByKeyValue, error) { + k, err := seat.DecodeSignersByKeyValue(raw) + if err != nil { + return k, fmt.Errorf("meshvaultclient: signers %s: %w", "key", err) + } + if _, err := SignerPublicKey(k); err != nil { + return seat.SignersByKeyValue{}, 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) (seat.SignersByAuthorityValue, error) { + a, err := seat.DecodeSignersByAuthorityValue(raw) + if err != nil { + return a, fmt.Errorf("meshvaultclient: signers %s: %w", "authority", err) + } + c, err := ParseCertificate(a.Certificate) + if err != nil { + return seat.SignersByAuthorityValue{}, err + } + if !c.IsCA { + return seat.SignersByAuthorityValue{}, errors.New("meshvaultclient: the signers' authority is not an authority certificate") + } + if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint { + return seat.SignersByAuthorityValue{}, fmt.Errorf("meshvaultclient: 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 (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("meshvaultclient: 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("meshvaultclient: 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...)) +} + +// 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 { + pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds + } + if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("meshvaultclient: 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("meshvaultclient: 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 certificate 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). An +// authority given is split into its certificates, so a bundle given again (the one a node kept from its last +// composition) adds nothing twice; a text that holds no PEM block is taken whole. +func AuthorityBundle(authorities ...string) string { + var out []string + for _, a := range authorities { + for _, piece := range pemPieces(a) { + if !slices.Contains(out, piece) { + out = append(out, piece) + } + } + } + if len(out) == 0 { + return "" + } + return strings.Join(out, "\n") + "\n" +} + +// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none. +func pemPieces(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + var out []string + rest := []byte(text) + for { + block, more := pem.Decode(rest) + if block == nil { + break + } + out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block)))) + rest = more + } + if len(out) == 0 { + return []string{text} + } + return out +} + +// Trusts is what a node states it trusts now (the node record's 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) }