catalogue's working set starts empty and a host gives it the seats it read (UseSeats over SeatsFromDefinitions); the node-engine's verbs and events take the seat's name from the caller. Tests take their seats from seatlisttest. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
309 lines
13 KiB
Go
309 lines
13 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 = "mesh-enrolment"
|
|
// 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, and their answers are the
|
|
// vault's contract's (mesh-contracts meshvault: VerbEnrolmentToken, EnrolmentTokenAnswer, VerbNodeAccount,
|
|
// NodeAccountAnswer; novox/hq ADR 0627 §1).
|