mesh-vault and mesh-node-enroller clients: the signing, the node's two logins and the own-secret drift; the token and the redeem proof (ADR 0634 §4; C-10; issue 1966)

This commit is contained in:
2026-11-05 14:16:00 +01:00
parent c5fc93644f
commit fb5451f694
11 changed files with 903 additions and 0 deletions
@@ -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.<node>` (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.<node>.<issue time in Unix nanoseconds>`, 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
}
@@ -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.<node>")
}
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)
}
}
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient
go 1.26.0
@@ -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:<hex>`: 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
}
@@ -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) }
@@ -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)
}
}
}
@@ -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")
}
}
@@ -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
)
@@ -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.<n>` and
// `node.<n>--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.<n>`; 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
}
@@ -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 = "<masked: another value>"
// 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)
}
@@ -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:<hex>` 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:<hex>` 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:<hex>` 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): `<who> key <fingerprint>` for each signer and
// `<who> authority <fingerprint>` for each authority certificate, by who holds it, each once, sorted. The gate before the
// lease write reads `vault key <fingerprint>` 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) }