ADR 0589 §6: an address key gains the issue's ask and time, written when the ask is raised. The key is written at the reservation, before the ask, so both are empty then and both set from the ask; one without the other is refused, as is no node. EncodeEnrolmentAddress takes node, ask and issued. EnrolmentIssuedID builds the enrolment-issued event's id, enrolment-issued.<node>.<issue time in Unix nanoseconds>, as the holder names it today. Tested as rows of TestNodeRecord; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
333 lines
14 KiB
Go
333 lines
14 KiB
Go
package delivery
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
|
|
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
|
|
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
|
|
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
|
|
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
|
|
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
|
|
// contract: every reader takes the names from here.
|
|
const (
|
|
// EnrolmentModule is the module holding the seat mesh-enrolment.
|
|
EnrolmentModule = catalogue.EnrolmentSeat
|
|
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
|
|
EnrolmentsState = "enrolments"
|
|
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
|
|
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
|
|
|
|
// The stages of an open enrolment (§2, §4, §5).
|
|
EnrolmentAsked = "asked"
|
|
EnrolmentLive = "live"
|
|
EnrolmentRedeeming = "redeeming"
|
|
|
|
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
|
|
DefaultTokenLife = time.Hour
|
|
MostTokenLife = 24 * time.Hour
|
|
|
|
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
|
|
EnrolmentUserPrefix = "enrol."
|
|
|
|
enrolmentNodePrefix = "node."
|
|
enrolmentAddressPrefix = "address."
|
|
)
|
|
|
|
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
|
|
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
|
|
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
|
|
type Enrolment struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
Address string `json:"address"`
|
|
Why string `json:"why"`
|
|
Caller string `json:"caller"`
|
|
Ask string `json:"ask,omitempty"`
|
|
Stage string `json:"stage"`
|
|
Issued time.Time `json:"issued"`
|
|
Expires time.Time `json:"expires"`
|
|
KeysDigest string `json:"keys-digest,omitempty"`
|
|
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
|
|
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
|
|
OverlayKey string `json:"overlay-key"`
|
|
}
|
|
|
|
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
|
|
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
|
|
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
|
|
// from the ask both set; one without the other is refused.
|
|
type EnrolmentAddress struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
Ask string `json:"ask,omitempty"`
|
|
Issued time.Time `json:"issued,omitzero"`
|
|
}
|
|
|
|
func (a EnrolmentAddress) check() error {
|
|
if err := checkName("node", a.Node); err != nil {
|
|
return err
|
|
}
|
|
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
|
|
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnrolmentIssuedID 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 (EnrolmentExpiry).
|
|
func EnrolmentIssuedID(node string, issued time.Time) string {
|
|
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
|
}
|
|
|
|
// EnrolmentNodeKey is a node's key in `enrolments`.
|
|
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
|
|
|
|
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
|
|
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
|
|
|
|
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
|
|
func EnrolmentKeyNode(key string) (string, bool) {
|
|
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
|
|
return node, ok && checkName("node", node) == nil
|
|
}
|
|
|
|
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
|
|
func EnrolmentKeyAddress(key string) (string, bool) {
|
|
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
_, err := netip.ParseAddr(address)
|
|
return address, err == nil
|
|
}
|
|
|
|
// EnrolmentUser is the bus user a node enrols as.
|
|
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
|
|
|
|
func (e Enrolment) check() error {
|
|
if err := checkName("node", e.Node); err != nil {
|
|
return err
|
|
}
|
|
if _, err := netip.ParseAddr(e.Address); err != nil {
|
|
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
|
|
}
|
|
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
|
|
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
|
|
}
|
|
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
|
|
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
|
|
}
|
|
switch e.Stage {
|
|
case EnrolmentAsked, EnrolmentLive:
|
|
if e.KeysDigest != "" {
|
|
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
|
|
}
|
|
case EnrolmentRedeeming:
|
|
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
|
|
}
|
|
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
|
|
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
|
|
func EncodeEnrolment(e Enrolment) ([]byte, error) {
|
|
if err := e.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
e.V = Version
|
|
return json.Marshal(e)
|
|
}
|
|
|
|
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
|
|
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
|
|
// after the issue.
|
|
func DecodeEnrolment(data []byte) (Enrolment, error) {
|
|
var e Enrolment
|
|
if err := decode(data, &e); err != nil {
|
|
return Enrolment{}, err
|
|
}
|
|
if err := e.check(); err != nil {
|
|
return Enrolment{}, err
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
|
|
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
|
|
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
|
|
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
|
|
if err := a.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
|
|
// the reverse.
|
|
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
|
var a EnrolmentAddress
|
|
if err := decode(data, &a); err != nil {
|
|
return EnrolmentAddress{}, err
|
|
}
|
|
if err := a.check(); err != nil {
|
|
return EnrolmentAddress{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
|
|
// positive or longer than 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("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
|
|
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
|
|
// event be named), since the bus deleted the key before the holder heard of it.
|
|
type EnrolmentExpiry struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
IssuedID string `json:"issued-event"`
|
|
}
|
|
|
|
func (x EnrolmentExpiry) check() error {
|
|
if err := checkName("node", x.Node); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(x.IssuedID) == "" {
|
|
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
|
|
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
|
|
if err := x.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
x.V = Version
|
|
return json.Marshal(x)
|
|
}
|
|
|
|
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
|
|
// no issued event named.
|
|
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
|
|
var x EnrolmentExpiry
|
|
if err := decode(data, &x); err != nil {
|
|
return EnrolmentExpiry{}, err
|
|
}
|
|
if err := x.check(); err != nil {
|
|
return EnrolmentExpiry{}, err
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// **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
|
|
}
|
|
|
|
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
|
const (
|
|
EnrolmentTokenVerb = "enrolment-token"
|
|
NodeAccountVerb = "node-account"
|
|
)
|
|
|
|
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
|
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
|
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
|
// nor its hash.
|
|
type EnrolmentTokenAnswer struct {
|
|
Node string `json:"node"`
|
|
Broker string `json:"broker,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
Signer string `json:"signer,omitempty"`
|
|
Sealed string `json:"sealed,omitempty"`
|
|
Dropped bool `json:"dropped,omitempty"`
|
|
}
|
|
|
|
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
|
// presented (base64), never in the clear; Retired says a retire left no account.
|
|
type NodeAccountAnswer struct {
|
|
Node string `json:"node"`
|
|
Login string `json:"login,omitempty"`
|
|
Account string `json:"account,omitempty"`
|
|
Retired bool `json:"retired,omitempty"`
|
|
}
|