ADR 0587 §3 and §5 (changing ADR 0561 §5) have the vault make the token and its hash (enrolment-token), answer it sealed to the operator key for tokens to relay (sealed, §8), drop both at every end (drop), and mint, answer again and retire a joining node's account (node-account), with mesh-enrolment their only caller; sign names mesh-enrolment for a joining node's certificate (with the serving key it presented, since the node has no record yet) and for a redeem answer. Both verbs optional until the vault that runs serves them. Their answers' shapes are in go/delivery. Tested as rows of TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function. (issue 774, 774/S37) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
269 lines
11 KiB
Go
269 lines
11 KiB
Go
package delivery
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/netip"
|
|
"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 only the node's name. 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"`
|
|
}
|
|
|
|
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for, and nothing else.
|
|
type EnrolmentAddress struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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.
|
|
func EncodeEnrolmentAddress(node string) ([]byte, error) {
|
|
if err := checkName("node", node); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(EnrolmentAddress{V: Version, Node: node})
|
|
}
|
|
|
|
// DecodeEnrolmentAddress reads a key `address.<address>`.
|
|
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
|
var a EnrolmentAddress
|
|
if err := decode(data, &a); err != nil {
|
|
return EnrolmentAddress{}, err
|
|
}
|
|
if err := checkName("node", a.Node); 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, by its sequence on the events' stream, since the bus deleted the key before the holder heard of it.
|
|
type EnrolmentExpiry struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
Issued uint64 `json:"issued-event"`
|
|
}
|
|
|
|
// **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"`
|
|
}
|