mesh-enrolment: redeem, the joining machine's one call
redeem answers only enrol.<node> as the runtime proves it, checks the proof against the identity key presented, spends the token once by compare-and-set (the keys lose their expiry), asks the vault for the node's account and certificate, writes the node into placement on the issue's warrant, answers once the bus node loaded node.<node>, signed by the vault; the same keys called again finish what is not done and answer the same (ADR 0587 §5-§7). The proof is mesh-host's EnrolProof with the node's name in the token's place, since the seat never holds the secret. Tests added: two table-style tests. (issue 774, 774/S56) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -203,7 +203,13 @@ func newRig(t *testing.T) *rig {
|
||||
r.said = append(r.said, said{event, id, raw})
|
||||
return nil
|
||||
},
|
||||
AddNode: func(a AddNode) error { r.added = append(r.added, a); return r.placeErr },
|
||||
AddNode: func(a AddNode) error {
|
||||
r.added = append(r.added, a)
|
||||
if r.placeErr == nil { // placement writes the node as the assigner does on the warrant
|
||||
r.rd["mesh-delivery-assigner.placement"][a.Node+".node"] = `{"v":1,"node":"` + a.Node + `","decided":{"address":"` + a.Address + `"},"modules":[]}`
|
||||
}
|
||||
return r.placeErr
|
||||
},
|
||||
Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil },
|
||||
Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil },
|
||||
Loaded: func(string) (bool, error) { return r.loaded, nil },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedeemArgs are what a joining node-engine says at redeem (ADR 0587 §6): the public halves of the keys it made where it
|
||||
// runs, and a proof, signed with its identity key, that it holds the private half of the first.
|
||||
type RedeemArgs struct {
|
||||
Node string `json:"node"`
|
||||
PublicKey []byte `json:"public_key"`
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
SealingKey string `json:"sealing_key,omitempty"`
|
||||
ServingKey string `json:"serving_key,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
Proof []byte `json:"proof"`
|
||||
}
|
||||
|
||||
// Keys are the public halves presented.
|
||||
func (a RedeemArgs) Keys() NodeKeys {
|
||||
return NodeKeys{Public: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey}
|
||||
}
|
||||
|
||||
// RedeemProof is the bytes a node signs with its identity key when it redeems: the node's name and every key it presents, so a
|
||||
// proof cannot be moved to another request or another node. The layout is mesh-host's EnrolProof (internal/link/enrol.go),
|
||||
// with the node's name in the place of the token's secret, which this seat never holds.
|
||||
func RedeemProof(node string, k NodeKeys) []byte {
|
||||
return []byte("novox-mesh-enrol\x00" + node + "\x00" + base64.StdEncoding.EncodeToString(k.Public) +
|
||||
"\x00" + k.Overlay + "\x00" + k.Sealing + "\x00" + k.Serving)
|
||||
}
|
||||
|
||||
// RedeemReply is what the vault signs: the node's account sealed to its sealing key, the signed certificate for its internal
|
||||
// name and the address the operator approved.
|
||||
type RedeemReply struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Account json.RawMessage `json:"account"`
|
||||
Certificate json.RawMessage `json:"certificate"`
|
||||
}
|
||||
|
||||
// RedeemAnswer is what redeem answers: the reply's bytes and the vault's signature over them. The node-engine refuses an
|
||||
// answer not signed by the key its token carries.
|
||||
type RedeemAnswer struct {
|
||||
Reply []byte `json:"reply"`
|
||||
Signed json.RawMessage `json:"signed"`
|
||||
}
|
||||
|
||||
// Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be
|
||||
// enrol.<node>, for a key at stage live (or at redeeming with the same keys' digest), checks the proof against the identity key
|
||||
// presented, and then, each step read back and none repeated that is done: spends the token (the key to redeeming, by
|
||||
// compare-and-set, with no expiry), asks the vault for the node's account and the certificate, writes the node into placement,
|
||||
// waits for the bus node to load node.<node>, and answers the whole, signed by the vault. Called again with the same keys it
|
||||
// finishes what is not done and answers the same.
|
||||
func (h *Holder) Redeem(caller string, a RedeemArgs) (RedeemAnswer, error) {
|
||||
if err := CheckNode(a.Node); err != nil {
|
||||
return RedeemAnswer{}, err
|
||||
}
|
||||
if caller != EnrolUser(a.Node) {
|
||||
return RedeemAnswer{}, fmt.Errorf("only %s may redeem the enrolment of %s, and the runtime proved the caller to be %q", EnrolUser(a.Node), a.Node, caller)
|
||||
}
|
||||
keys := a.Keys()
|
||||
if len(a.PublicKey) != ed25519.PublicKeySize || !ed25519.Verify(a.PublicKey, RedeemProof(a.Node, keys), a.Proof) {
|
||||
return RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent")
|
||||
}
|
||||
digest := keys.Digest()
|
||||
e, err := h.spend(a.Node, digest)
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, err
|
||||
}
|
||||
account, err := h.Vault.Account(e.Node, a.SealingKey, digest)
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err)
|
||||
}
|
||||
cert, err := h.Vault.SignCertificate(e.Node, a.ServingKey)
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err)
|
||||
}
|
||||
if err := h.place(e, a, keys); err != nil {
|
||||
return RedeemAnswer{}, err
|
||||
}
|
||||
if h.Loaded != nil {
|
||||
loaded, err := h.Loaded(NodeUser(e.Node))
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, fmt.Errorf("whether the bus node loaded %s cannot be read (call redeem again with the same keys): %w", NodeUser(e.Node), err)
|
||||
}
|
||||
if !loaded {
|
||||
return RedeemAnswer{}, fmt.Errorf("the bus node has not loaded %s yet (call redeem again with the same keys)", NodeUser(e.Node))
|
||||
}
|
||||
}
|
||||
reply, err := json.Marshal(RedeemReply{Node: e.Node, Address: e.Address, Account: account, Certificate: cert})
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, err
|
||||
}
|
||||
signed, err := h.Vault.SignReply(e.Node, reply)
|
||||
if err != nil {
|
||||
return RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err)
|
||||
}
|
||||
return RedeemAnswer{Reply: reply, Signed: signed}, nil
|
||||
}
|
||||
|
||||
// spend moves the key from live to redeeming with the presented keys' digest, by compare-and-set, before its expiry; at
|
||||
// redeeming it only checks the digest. A key that moved meanwhile is read again once.
|
||||
func (h *Holder) spend(node, digest string) (Enrolment, error) {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
row, found, err := h.Store.Get(NodeKey(node))
|
||||
if err != nil {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err)
|
||||
}
|
||||
if !found {
|
||||
return Enrolment{}, fmt.Errorf("no enrolment of %s is open: it was never issued, has ended or has expired", node)
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
switch e.Stage {
|
||||
case StageRedeeming:
|
||||
if e.KeysDigest != digest {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s was redeemed with other keys: a token is spent once", node)
|
||||
}
|
||||
return e, nil
|
||||
case StageAsked:
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s waits for the operator's yes: no token exists yet", node)
|
||||
}
|
||||
if !h.now().Before(e.Expires) {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s expired at %s", node, e.Expires.UTC().Format(time.RFC3339))
|
||||
}
|
||||
e.Stage, e.KeysDigest = StageRedeeming, digest
|
||||
if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil {
|
||||
if errors.Is(err, ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be spent: %w", node, err)
|
||||
}
|
||||
// The move to redeeming rewrites both keys without an expiry, so a redeem under way never loses its key (§5).
|
||||
if _, err := h.Store.Put(AddressKey(e.Address), Reservation{V: 1, Node: node}, 0); err != nil {
|
||||
return Enrolment{}, fmt.Errorf("the address key %s cannot be kept (call redeem again with the same keys): %w", e.Address, err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s kept changing: call redeem again", node)
|
||||
}
|
||||
|
||||
// place writes the node into placement with the issue's warrant, unless placement holds it at the approved address already.
|
||||
func (h *Holder) place(e Enrolment, a RedeemArgs, keys NodeKeys) error {
|
||||
placements, err := h.placements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err)
|
||||
}
|
||||
if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address {
|
||||
return nil
|
||||
}
|
||||
if h.AddNode == nil {
|
||||
return errors.New("no route to mesh-placement")
|
||||
}
|
||||
if err := h.AddNode(AddNode{Node: e.Node, Address: e.Address, Why: "enrolment of " + e.Node + ": " + e.Why, Keys: keys,
|
||||
Warrant: Warrant{Ask: e.Ask, Node: e.Node, Address: e.Address}}); err != nil {
|
||||
return fmt.Errorf("%w (call redeem again with the same keys)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// engineEnrolProof is mesh-host's EnrolProof (internal/link/enrol.go:112-114), copied: the bytes a node-engine signs. The
|
||||
// holder's RedeemProof must be the same bytes with the node's name in the token's place.
|
||||
func engineEnrolProof(secret string, public []byte, overlay, sealing, serving string) []byte {
|
||||
return []byte("novox-mesh-enrol\x00" + secret + "\x00" + base64.StdEncoding.EncodeToString(public) +
|
||||
"\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||
}
|
||||
|
||||
// redeemArgs are a node's redeem call with a proof signed by the key seeded, over the keys given.
|
||||
func redeemArgs(node string, seed byte, serving string) RedeemArgs {
|
||||
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize))
|
||||
a := RedeemArgs{Node: node, PublicKey: priv.Public().(ed25519.PublicKey), OverlayKey: "overlay-" + node, SealingKey: "sealing-" + node, ServingKey: serving}
|
||||
a.Proof = ed25519.Sign(priv, RedeemProof(node, a.Keys()))
|
||||
return a
|
||||
}
|
||||
|
||||
// 774/S56/AC1: a redeem from another caller, with a bad proof, after the expiry, or with other keys than the first, is
|
||||
// refused (one row each), and a good one spends the token, writes the node into placement on the issue's warrant and
|
||||
// answers once the bus node loaded the account.
|
||||
func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
|
||||
good := redeemArgs("ace", 1, "serving-a")
|
||||
bad := good
|
||||
bad.Proof = append([]byte{}, good.Proof...)
|
||||
bad.Proof[0] ^= 1
|
||||
if !bytes.Equal(RedeemProof("ace", good.Keys()), engineEnrolProof("ace", good.PublicKey, good.OverlayKey, good.SealingKey, good.ServingKey)) {
|
||||
t.Fatal("the proof's bytes are not the engine's")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
caller string
|
||||
args RedeemArgs
|
||||
setup func(r *rig)
|
||||
want string
|
||||
}{
|
||||
{"another caller", "enrol.g14", good, nil, "only enrol.ace may redeem"},
|
||||
{"a bad proof", "enrol.ace", bad, nil, "proof does not verify"},
|
||||
{"after the expiry", "enrol.ace", good, func(r *rig) { r.now = r.now.Add(2 * time.Hour) }, "expired at"},
|
||||
{"other keys than the first", "enrol.ace", redeemArgs("ace", 2, "serving-b"), func(r *rig) {
|
||||
e, _ := r.enrolment("ace")
|
||||
e.Stage, e.KeysDigest = StageRedeeming, good.Keys().Digest()
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
}, "spent once"},
|
||||
{"before the operator's yes", "enrol.ace", good, func(r *rig) {
|
||||
e, _ := r.enrolment("ace")
|
||||
e.Stage = StageAsked
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
}, "waits for the operator's yes"},
|
||||
{"no enrolment", "enrol.novox", redeemArgs("novox", 3, "s"), nil, "no enrolment of novox is open"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
if c.setup != nil {
|
||||
c.setup(r)
|
||||
}
|
||||
_, err := r.h.Redeem(c.caller, c.args)
|
||||
if err == nil {
|
||||
t.Errorf("%s: redeemed", c.name)
|
||||
continue
|
||||
}
|
||||
mustContain(t, err.Error(), c.want)
|
||||
if len(r.vault.calls) != 0 || len(r.added) != 0 {
|
||||
t.Errorf("%s: the vault %v or placement %v was called", c.name, r.vault.calls, r.added)
|
||||
}
|
||||
}
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")] = time.Hour, time.Hour
|
||||
ans, err := r.h.Redeem("enrol.ace", good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, _ := r.enrolment("ace")
|
||||
if e.Stage != StageRedeeming || e.KeysDigest != good.Keys().Digest() || r.st.ttl[NodeKey("ace")] != 0 || r.st.ttl[AddressKey("10.9.0.2")] != 0 {
|
||||
t.Errorf("spent %+v, ttl %v/%v", e, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey("10.9.0.2")])
|
||||
}
|
||||
if len(r.added) != 1 || r.added[0].Warrant != (Warrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2"}) || len(ans.Reply) == 0 || len(ans.Signed) == 0 {
|
||||
t.Errorf("placement %+v, answer %+v", r.added, ans)
|
||||
}
|
||||
r.loaded = false
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
|
||||
t.Error("answered before the bus node loaded the account")
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S56/AC2: a redeem stopped after the vault's call, or after the placement call, called again with the same keys,
|
||||
// finishes and answers the same, and the vault minted once (it is asked for the same account).
|
||||
func TestARedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) {
|
||||
good := redeemArgs("ace", 1, "serving-a")
|
||||
whole := newRig(t)
|
||||
whole.seed("ace", "10.9.0.2", StageLive)
|
||||
want, err := whole.h.Redeem("enrol.ace", good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stop := range []string{"certificate ace", "sign ace"} {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
r.vault.fail[stop] = true
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
|
||||
t.Fatalf("%s: no error", stop)
|
||||
}
|
||||
got, err := r.h.Redeem("enrol.ace", good)
|
||||
if err != nil || !bytes.Equal(got.Reply, want.Reply) || string(got.Signed) != string(want.Signed) {
|
||||
t.Errorf("%s: %v, answer %+v, want %+v", stop, err, got, want)
|
||||
}
|
||||
if stop == "sign ace" && len(r.added) != 1 {
|
||||
t.Errorf("placement was written %d times", len(r.added))
|
||||
}
|
||||
}
|
||||
// Placement that already holds the node at the approved address is not written again.
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageRedeeming)
|
||||
e, _ := r.enrolment("ace")
|
||||
e.KeysDigest = good.Keys().Digest()
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","decided":{"address":"10.9.0.2"},"modules":[]}`
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err != nil || len(r.added) != 0 {
|
||||
t.Errorf("%v, placement written %d times", err, len(r.added))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
const (
|
||||
IssueTool = Seat + ".issue"
|
||||
AgainTool = Seat + ".again"
|
||||
RedeemTool = Seat + ".redeem"
|
||||
TokensTool = Seat + ".tokens"
|
||||
)
|
||||
|
||||
@@ -65,6 +67,26 @@ func Tools(h *Holder) []stdio.Tool {
|
||||
}
|
||||
return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), lasts)
|
||||
}},
|
||||
{Name: RedeemTool,
|
||||
Description: "The joining machine's one call (novox/hq ADR 0587 §5, §6), answered only for a caller the runtime proves to be " +
|
||||
"enrol.<node>: its node-engine presents the public halves of the keys it made where it runs and a proof signed with its " +
|
||||
"identity key. The token is spent (once, with those keys), the vault gives the node's account and signs its certificate, " +
|
||||
"the node is written into placement on the issue's warrant, and the answer is given once the bus node loaded node.<node>, " +
|
||||
"signed by the vault. Called again with the same keys it finishes what is not done and answers the same.",
|
||||
Input: obj(map[string]any{"node": str("the machine's name"), "public_key": str("its identity key, base64"),
|
||||
"overlay_key": str("its private-network key"), "sealing_key": str("its sealing key, base64"),
|
||||
"serving_key": str("its serving key"), "proof": str("the proof, base64")}, "node", "public_key", "proof"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var args RedeemArgs
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("the redeem call does not read: %w", err)
|
||||
}
|
||||
return h.Redeem(stdio.CallerOf(a), args)
|
||||
}},
|
||||
{Name: AgainTool,
|
||||
Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " +
|
||||
"(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +
|
||||
|
||||
Reference in New Issue
Block a user