diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go index b4d2f2b..79e75c2 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/end.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end.go @@ -6,6 +6,7 @@ import ( "strings" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Revoke ends an enrolment at any stage (ADR 0587 §5), by a person's call: at asked it withdraws the ask; at redeeming it also @@ -81,13 +82,13 @@ func (h *Holder) Heard(op, key string, value []byte) error { case stdio.OpExpire: e, known := h.seen[node] delete(h.seen, node) - body := Event{Node: node, Enrolment: e} id := "enrolment-expired." + node if known { - body.Issued = eventID(EventIssued, e) id = eventID(EventExpired, e) } - body.Reason = "the token's expiry passed unredeemed" + // The sequence of the enrolment-issued event on the events' stream is not given to a bundle that publishes (the runtime + // answers no sequence), so issued-event is 0 and issued-id names the message id that carried the content. + body := Expired{EnrolmentExpiry: delivery.EnrolmentExpiry{V: delivery.Version, Node: node}, IssuedID: eventID(EventIssued, e)} if err := h.say(EventExpired, body, id); err != nil { return fmt.Errorf("enrolment-expired of %s cannot be said (offered again): %w", node, err) } @@ -95,6 +96,13 @@ func (h *Holder) Heard(op, key string, value []byte) error { return nil } +// Expired is what enrolment-expired carries (delivery.EnrolmentExpiry) and the message id of the enrolment-issued event that +// carried the key's content, which the bus's sequence number cannot be known for. +type Expired struct { + delivery.EnrolmentExpiry + IssuedID string `json:"issued-id,omitempty"` +} + // Ended is the durable consumer's act on one of the holder's own ending events (ADR 0587 §5): the vault is asked to drop the // node's token and its hash, and the event is acknowledged only when the vault answers that both are gone, so a drop that // fails is delivered again by the bus. It never runs before an end: the consumer hears only the end events. diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go index 92cd90f..9bde32a 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/end_test.go @@ -42,7 +42,7 @@ func TestRevokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) { t.Fatal(err) } last := r.said[len(r.said)-1] - mustContain(t, string(last.Body), `"issued-event":"enrolment-issued.novox.`) + mustContain(t, string(last.Body), `"issued-id":"enrolment-issued.novox.`) if last.Event != EventExpired { t.Errorf("said %s", last.Event) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go index f23eaae..f492b5d 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/enrolment.go @@ -6,6 +6,9 @@ import ( "fmt" "regexp" "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587 §1), held once for the mesh. A Go bundle served @@ -15,75 +18,54 @@ import ( // Module and Seat are the module's name and the seat it holds. const ( - Module = "mesh-enrolment" - Seat = "mesh-enrolment" + Module = catalogue.EnrolmentSeat + Seat = catalogue.EnrolmentSeat // State is the holder's bucket of open enrolments; its keys are node. and address.
(ADR 0587 §2). - State = "enrolments" + State = delivery.EnrolmentsState ) // The seat's six verbs and five events (ADR 0587 §1). -var Verbs = []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"} +var Verbs = []string{catalogue.EnrolmentVerbIssue, catalogue.EnrolmentVerbRedeem, catalogue.EnrolmentVerbConfirm, catalogue.EnrolmentVerbRevoke, + catalogue.EnrolmentVerbTokens, catalogue.EnrolmentVerbAgain} const ( - EventIssued = "enrolment-issued" - EventEnrolled = "node-enrolled" - EventRefused = "enrolment-refused" - EventRevoked = "enrolment-revoked" - EventExpired = "enrolment-expired" + EventIssued = catalogue.EnrolmentIssuedEvent + EventEnrolled = catalogue.NodeEnrolledEvent + EventRefused = catalogue.EnrolmentRefusedEvent + EventRevoked = catalogue.EnrolmentRevokedEvent + EventExpired = catalogue.EnrolmentExpiredEvent ) // EndEvents are the events that end an enrolment; the holder's durable consumer hears each (ADR 0587 §5) and asks the vault // to drop the token. A seat's event is heard as .. var EndEvents = []string{EventEnrolled, EventRefused, EventRevoked, EventExpired} -// Stage is how far an open enrolment has come (ADR 0587 §2). -type Stage string - +// The stages of an open enrolment (ADR 0587 §2), as the seat's contract names them (delivery.Enrolment). const ( // StageAsked: the operator's Approve is asked, no token exists. - StageAsked Stage = "asked" + StageAsked = delivery.EnrolmentAsked // StageLive: the Approve is given, the token exists and expires on the bus with the key. - StageLive Stage = "live" + StageLive = delivery.EnrolmentLive // StageRedeeming: the token is spent; the key has no expiry and ends only by confirm or revoke. - StageRedeeming Stage = "redeeming" + StageRedeeming = delivery.EnrolmentRedeeming +) + +// Enrolment and Reservation are the seat's state contract (delivery.Enrolment, delivery.EnrolmentAddress). +type ( + Enrolment = delivery.Enrolment + Reservation = delivery.EnrolmentAddress ) const ( - // DefaultExpiry and MaxExpiry bound a token's life (ADR 0587 §4). - DefaultExpiry = time.Hour - MaxExpiry = 24 * time.Hour - // EnrolUser is the bus user a joining machine dials as: enrol. (ADR 0587 §4). - enrolPrefix = "enrol." - nodePrefix = "node." + nodePrefix = "node." ) -// Enrolment is the value of the key node. (ADR 0587 §2): no secret, 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 Stage `json:"stage"` - Issued time.Time `json:"issued"` - Expires time.Time `json:"expires"` - // KeysDigest is the digest of the public keys the first redeem presented (ADR 0587 §5), empty before it. - KeysDigest string `json:"keys-digest,omitempty"` -} - -// Reservation is the value of the key address.
: the node's name alone (ADR 0587 §2). -type Reservation struct { - V int `json:"v"` - Node string `json:"node"` -} - // NodeKey and AddressKey are the two keys of an enrolment. -func NodeKey(node string) string { return "node." + node } -func AddressKey(address string) string { return "address." + address } +func NodeKey(node string) string { return delivery.EnrolmentNodeKey(node) } +func AddressKey(address string) string { return delivery.EnrolmentAddressKey(address) } // EnrolUser and NodeUser are the two bus users of a node: the one it joins with, and its own. -func EnrolUser(node string) string { return enrolPrefix + node } +func EnrolUser(node string) string { return delivery.EnrolmentUser(node) } func NodeUser(node string) string { return nodePrefix + node } var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) @@ -96,20 +78,17 @@ func CheckNode(node string) error { return nil } -func decodeEnrolment(raw []byte) (Enrolment, error) { - var e Enrolment - if err := json.Unmarshal(raw, &e); err != nil { - return Enrolment{}, fmt.Errorf("an enrolment key does not read: %w", err) +func decodeEnrolment(raw []byte) (Enrolment, error) { return delivery.DecodeEnrolment(raw) } + +// Encode is a value of the bucket as the seat's contract writes it: an Enrolment or a Reservation checked and versioned. +func Encode(v any) (json.RawMessage, error) { + switch x := v.(type) { + case Enrolment: + return delivery.EncodeEnrolment(x) + case Reservation: + return delivery.EncodeEnrolmentAddress(x.Node) } - if e.Node == "" || e.Address == "" { - return Enrolment{}, errors.New("an enrolment key names no node or no address") - } - switch e.Stage { - case StageAsked, StageLive, StageRedeeming: - default: - return Enrolment{}, fmt.Errorf("the enrolment of %s is in no stage %q", e.Node, e.Stage) - } - return e, nil + return json.Marshal(v) } // Row is a key as the store holds it. diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go index b2490d0..7da3442 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -18,20 +18,22 @@ import ( // Vault is the vault's side of enrolment (ADR 0587 §3, §5): the token and the node's account, and the signatures. The // holder keeps nothing of what it answers. type Vault interface { - // Token asks the vault to make the token and its hash for node, expiring at expires, and answers the public fields - // the joining machine must trust. No token is in the answer. - Token(node string, expires time.Time) (json.RawMessage, error) + // Token asks the vault to make the token and its hash for node, expiring at expires, and answers what the joining + // machine must trust (the bus's address and fingerprint, the vault's signing key). No token is in the answer. + Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) // Sealed asks the vault for node's token sealed to the operator key, as the vault sealed it. - Sealed(node string) (json.RawMessage, error) + Sealed(node string) (string, error) // Drop asks the vault to drop node's token and its hash; it answers only once both are gone. Drop(node string) error // Account asks for node's account sealed to the sealing key; the same node and keys again answer the same account. - Account(node, sealingKey, keysDigest string) (json.RawMessage, error) + Account(node, sealingKey, keysDigest string) (delivery.NodeAccountAnswer, error) // Retire asks the vault to retire the account it minted for node. Retire(node string) error // SignCertificate and SignReply sign through the vault's `sign`. - SignCertificate(node, servingKey string) (json.RawMessage, error) - SignReply(node string, reply []byte) (json.RawMessage, error) + SignCertificate(node, servingKey string) (delivery.SignAnswer, error) + SignReply(node string, reply []byte) (delivery.SignAnswer, error) + // Signer is the vault's public signing key in base64, which the redeem answer names for the node to check it against its token. + Signer() (string, error) } // Asker is the operator's channel as the holder asks it (asker.Client is one). @@ -99,12 +101,14 @@ type AddNode struct { Warrant Warrant `json:"warrant"` } -// NodeKeys are the public halves of a joining node's keys: identity, private network, sealing and serving. +// NodeKeys are the public halves of a joining node's keys: identity, private network, sealing and serving, and the +// tunnel it found, as delivery.Redeem presents them. type NodeKeys struct { - Public []byte `json:"public_key"` - Overlay string `json:"overlay_key,omitempty"` - Sealing string `json:"sealing_key,omitempty"` - Serving string `json:"serving_key,omitempty"` + Public string `json:"public_key"` + Overlay string `json:"overlay_key,omitempty"` + Sealing string `json:"sealing_key,omitempty"` + Serving string `json:"serving_key,omitempty"` + Tunnel json.RawMessage `json:"tunnel,omitempty"` } // Warrant is the issue's warrant as the proposal carries it: the ask the operator answered, the node and the address it @@ -115,13 +119,6 @@ type Warrant struct { Address string `json:"address"` } -// Digest is the digest of the keys a node presented, the one hash an event carries (ADR 0587 §1). -func (k NodeKeys) Digest() string { return delivery.DigestOf(k.bytes()) } - -func (k NodeKeys) bytes() []byte { - return []byte("novox-mesh-enrol-keys\x00" + string(k.Public) + "\x00" + k.Overlay + "\x00" + k.Sealing + "\x00" + k.Serving) -} - // issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587 // §4), and why either way. func (h *Holder) issuing() (bool, string) { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go index e701e00..86b414b 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go @@ -11,6 +11,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Event is what each of the seat's events carries (ADR 0587 §2): the key's whole content, which holds no secret and nothing @@ -59,10 +60,10 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw findings = append(findings, "no why is given: the operator is asked why this machine joins") } if lasts == 0 { - lasts = DefaultExpiry + lasts = delivery.DefaultTokenLife } - if lasts < 0 || lasts > MaxExpiry { - findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, MaxExpiry)) + if lasts < 0 || lasts > delivery.MostTokenLife { + findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife)) } if on, reason := h.issuing(); !on { findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+ @@ -89,7 +90,7 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; ")) } now := h.now() - e := Enrolment{V: 1, Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts)} + e := Enrolment{Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts)} e.Ask = "enrol-" + node + "-" + now.UTC().Format("20060102t150405") skip := map[string]bool{} for { @@ -101,7 +102,7 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw if err != nil { return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %w", node, err) } - switch err := h.Store.Create(AddressKey(addr), Reservation{V: 1, Node: node}, 0); { + switch err := h.Store.Create(AddressKey(addr), Reservation{Node: node}, 0); { case err == nil: e.Address = addr case errors.Is(err, ErrExists): @@ -242,7 +243,7 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error { if _, err := h.Store.Put(NodeKey(e.Node), e, ttl); err != nil { return fmt.Errorf("the enrolment of %s cannot be moved to live: %w", e.Node, err) } - if _, err := h.Store.Put(AddressKey(e.Address), Reservation{V: 1, Node: e.Node}, ttl); err != nil { + if _, err := h.Store.Put(AddressKey(e.Address), Reservation{Node: e.Node}, ttl); err != nil { return fmt.Errorf("the address key %s cannot be given its expiry: %w", e.Address, err) } return h.sayEvent(EventIssued, e, "") diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index d42413b..4325087 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -12,6 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // memStore is the holder's bucket in memory: revisions, and the expiry each key was written with. @@ -29,7 +30,10 @@ func newMem() *memStore { } func (m *memStore) write(key string, v any, ttl time.Duration) uint64 { - raw, _ := json.Marshal(v) + raw, err := Encode(v) + if err != nil { + panic(err) // a test wrote a value the contract refuses + } m.rev++ m.rows[key] = Row{Value: raw, Revision: m.rev} m.ttl[key] = ttl @@ -123,23 +127,24 @@ func (v *fakeVault) do(what string) error { return nil } -func (v *fakeVault) Token(node string, expires time.Time) (json.RawMessage, error) { - return json.RawMessage(`{"broker":"b","fingerprint":"f","signer":"s"}`), v.do("token " + node) +func (v *fakeVault) Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) { + return delivery.EnrolmentTokenAnswer{Node: node, Broker: "b", Fingerprint: "f", Signer: "s"}, v.do("token " + node) } -func (v *fakeVault) Sealed(node string) (json.RawMessage, error) { - return json.RawMessage(`"sealed-for-` + node + `"`), v.do("sealed " + node) +func (v *fakeVault) Sealed(node string) (string, error) { + return "sealed-for-" + node, v.do("sealed " + node) } func (v *fakeVault) Drop(node string) error { return v.do("drop " + node) } -func (v *fakeVault) Account(node, sealing, digest string) (json.RawMessage, error) { - return json.RawMessage(`{"account":"` + node + `"}`), v.do("account " + node) +func (v *fakeVault) Account(node, sealing, digest string) (delivery.NodeAccountAnswer, error) { + return delivery.NodeAccountAnswer{Node: node, Login: "node." + node, Account: "account-" + node}, v.do("account " + node) } func (v *fakeVault) Retire(node string) error { return v.do("retire " + node) } -func (v *fakeVault) SignCertificate(node, serving string) (json.RawMessage, error) { - return json.RawMessage(`{"certificate":"c"}`), v.do("certificate " + node) +func (v *fakeVault) SignCertificate(node, serving string) (delivery.SignAnswer, error) { + return delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM-" + serving}, v.do("certificate " + node) } -func (v *fakeVault) SignReply(node string, reply []byte) (json.RawMessage, error) { - return json.RawMessage(`{"signature":"sig","signer":"fp"}`), v.do("sign " + node) +func (v *fakeVault) SignReply(node string, reply []byte) (delivery.SignAnswer, error) { + return delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node, Signature: "sig-" + delivery.DigestOf(reply)[7:15], Signer: "fp"}, v.do("sign " + node) } +func (v *fakeVault) Signer() (string, error) { return "vault-signer", nil } type fakeAsker struct { mu sync.Mutex diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go index 3ff7ccd..99cb6b0 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/main.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/main.go @@ -30,11 +30,14 @@ func (runtimeStore) Get(key string) (Row, bool, error) { } func (runtimeStore) Create(key string, v any, ttl time.Duration) error { - var err error + raw, err := Encode(v) + if err != nil { + return err + } if ttl > 0 { - _, err = stdio.State(State).CreateTTL(key, v, ttl) + _, err = stdio.State(State).CreateTTL(key, raw, ttl) } else { - _, err = stdio.State(State).Create(key, v) + _, err = stdio.State(State).Create(key, raw) } if errors.Is(err, stdio.ErrChanged) { return ErrExists @@ -43,7 +46,11 @@ func (runtimeStore) Create(key string, v any, ttl time.Duration) error { } func (runtimeStore) Update(key string, v any, revision uint64) (uint64, error) { - rev, err := stdio.State(State).Update(key, v, revision) + raw, err := Encode(v) + if err != nil { + return 0, err + } + rev, err := stdio.State(State).Update(key, raw, revision) if errors.Is(err, stdio.ErrChanged) { return 0, ErrChanged } @@ -51,10 +58,14 @@ func (runtimeStore) Update(key string, v any, revision uint64) (uint64, error) { } func (runtimeStore) Put(key string, v any, ttl time.Duration) (uint64, error) { - if ttl > 0 { - return stdio.State(State).PutTTL(key, v, ttl) + raw, err := Encode(v) + if err != nil { + return 0, err } - return stdio.State(State).Put(key, v) + if ttl > 0 { + return stdio.State(State).PutTTL(key, raw, ttl) + } + return stdio.State(State).Put(key, raw) } func (runtimeStore) Delete(key string) error { return stdio.State(State).Delete(key) } @@ -82,7 +93,7 @@ func newHolder() *Holder { rec := handacts.Recorder{Ask: stdio.Ask} rd := runtimeReader{} return &Holder{ - Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call}, Asker: asker.New("asked"), + Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: asker.New("asked"), Say: func(event string, body any, id string) error { return stdio.SeatPublish(SeatEventSubject(event), body, id) }, diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go index d714e4f..e2a01a0 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem.go @@ -3,51 +3,16 @@ package main import ( "crypto/ed25519" "encoding/base64" - "encoding/json" "errors" "fmt" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// 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"` +// keysOf are the public halves a redeem presented. +func keysOf(a delivery.Redeem) NodeKeys { + return NodeKeys{Public: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey, Tunnel: a.Tunnel} } // Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be @@ -56,51 +21,61 @@ type RedeemAnswer struct { // 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., 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) { +func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer, error) { if err := CheckNode(a.Node); err != nil { - return RedeemAnswer{}, err + return delivery.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) + return delivery.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") + pub, err := base64.StdEncoding.DecodeString(a.PublicKey) + proof, perr := a.ProofBytes() + if err != nil || perr != nil || len(pub) != ed25519.PublicKeySize || + !ed25519.Verify(pub, delivery.RedeemProof(a.Node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey), proof) { + return delivery.RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent") } - digest := keys.Digest() + if a.SealingKey == "" { + return delivery.RedeemAnswer{}, errors.New("no sealing key is presented, so the account cannot be sealed to the node: the enrolment is not spent") + } + digest := a.KeysDigest() e, err := h.spend(a.Node, digest) if err != nil { - return RedeemAnswer{}, err + return delivery.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) + return delivery.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) + return delivery.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 err := h.place(e, keysOf(a)); err != nil { + return delivery.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) + return delivery.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)) + return delivery.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}) + signer, err := h.Vault.Signer() if err != nil { - return RedeemAnswer{}, err + return delivery.RedeemAnswer{}, fmt.Errorf("the vault's signing key cannot be named (call redeem again with the same keys): %w", err) } - signed, err := h.Vault.SignReply(e.Node, reply) + out := delivery.RedeemAnswer{Node: e.Node, Login: account.Login, Account: account.Account, Certificate: cert.Certificate, Signer: signer} + if out.Login == "" { + out.Login = NodeUser(e.Node) + } + signed, err := h.Vault.SignReply(e.Node, out.SignedBytes()) if err != nil { - return RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err) + return delivery.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 + out.Signature = signed.Signature + return out, nil } // spend moves the key from live to redeeming with the presented keys' digest, by compare-and-set, before its expiry; at @@ -138,7 +113,7 @@ func (h *Holder) spend(node, digest string) (Enrolment, error) { 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 { + if _, err := h.Store.Put(AddressKey(e.Address), Reservation{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 @@ -147,7 +122,7 @@ func (h *Holder) spend(node, digest string) (Enrolment, error) { } // 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 { +func (h *Holder) place(e Enrolment, 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) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go index 0d3e2e5..5d173d9 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/redeem_test.go @@ -6,20 +6,16 @@ import ( "encoding/base64" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// 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 { +func redeemArgs(node string, seed byte, serving string) delivery.Redeem { 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())) + a := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)), + OverlayKey: "overlay-" + node, SealingKey: "sealing-" + node, ServingKey: serving} + a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof(node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey))) return a } @@ -29,15 +25,13 @@ func redeemArgs(node string, seed byte, serving string) RedeemArgs { 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") - } + raw, _ := good.ProofBytes() + raw[0] ^= 1 + bad.Proof = base64.StdEncoding.EncodeToString(raw) cases := []struct { name string caller string - args RedeemArgs + args delivery.Redeem setup func(r *rig) want string }{ @@ -46,7 +40,7 @@ func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) { {"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() + e.Stage, e.KeysDigest = StageRedeeming, good.KeysDigest() _, _ = r.st.Put(NodeKey("ace"), e, 0) }, "spent once"}, {"before the operator's yes", "enrol.ace", good, func(r *rig) { @@ -80,10 +74,10 @@ func TestRedeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) { 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 { + if e.Stage != StageRedeeming || e.KeysDigest != good.KeysDigest() || 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 { + if len(r.added) != 1 || r.added[0].Warrant != (Warrant{Ask: "ask-ace", Node: "ace", Address: "10.9.0.2"}) || ans.Signature == "" || ans.Account != "account-ace" || ans.Login != "node.ace" || ans.Signer != "vault-signer" || ans.Certificate != "PEM-serving-a" { t.Errorf("placement %+v, answer %+v", r.added, ans) } r.loaded = false @@ -110,7 +104,7 @@ func TestARedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) { 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) { + if err != nil || got != want { t.Errorf("%s: %v, answer %+v, want %+v", stop, err, got, want) } if stop == "sign ace" && len(r.added) != 1 { @@ -121,7 +115,7 @@ func TestARedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) { r := newRig(t) r.seed("ace", "10.9.0.2", StageRedeeming) e, _ := r.enrolment("ace") - e.KeysDigest = good.Keys().Digest() + e.KeysDigest = good.KeysDigest() _, _ = 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 { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go index df23f1e..a153797 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens.go @@ -10,18 +10,18 @@ import ( // TokenRow is one open enrolment as `tokens` answers it (ADR 0587 §8): its stage, expiry, ask and revision, and for a node // at stage live the token sealed to the operator key as the vault sealed it (the holder never reads it and keeps it nowhere). type TokenRow struct { - Node string `json:"node"` - Address string `json:"address"` - Why string `json:"why"` - Caller string `json:"caller"` - Stage Stage `json:"stage"` - Ask string `json:"ask,omitempty"` - Issued time.Time `json:"issued"` - Expires time.Time `json:"expires"` - Age string `json:"age"` - Revision uint64 `json:"revision"` - Keys string `json:"keys-digest,omitempty"` - Sealed json.RawMessage `json:"sealed,omitempty"` + Node string `json:"node"` + Address string `json:"address"` + Why string `json:"why"` + Caller string `json:"caller"` + Stage string `json:"stage"` + Ask string `json:"ask,omitempty"` + Issued time.Time `json:"issued"` + Expires time.Time `json:"expires"` + Age string `json:"age"` + Revision uint64 `json:"revision"` + Keys string `json:"keys-digest,omitempty"` + Sealed string `json:"sealed,omitempty"` // Unsealed says why no sealed token is given for a node that should have one. Unsealed string `json:"unsealed,omitempty"` } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index 0ca284c..ab4b890 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -7,17 +7,19 @@ import ( "slices" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // seed writes an open enrolment of node at stage, with its address key, as the holder would have. -func (r *rig) seed(node, address string, stage Stage) { - e := Enrolment{V: 1, Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node, Stage: stage, +func (r *rig) seed(node, address string, stage string) { + e := Enrolment{Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node, Stage: stage, Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour)} if stage == StageRedeeming { - e.KeysDigest = "sha256:keys" + e.KeysDigest = delivery.KeysDigest("pk", "ov", "se", "sv") } _, _ = r.st.Put(NodeKey(node), e, 0) - _, _ = r.st.Put(AddressKey(address), Reservation{V: 1, Node: node}, 0) + _, _ = r.st.Put(AddressKey(address), Reservation{Node: node}, 0) } // 774/S54/AC1 and AC2: tokens answers each stage with its expiry, ask and revision, the fill and whether issuing is on; @@ -34,7 +36,7 @@ func TestTokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) { var stages []string for _, row := range all.Open { stages = append(stages, row.Node+":"+string(row.Stage)) - if row.Revision == 0 || row.Expires.IsZero() || row.Ask == "" || row.Sealed != nil { + if row.Revision == 0 || row.Expires.IsZero() || row.Ask == "" || row.Sealed != "" { t.Errorf("row %+v lacks revision, expiry or ask, or relays a token for a list of all", row) } } @@ -42,7 +44,7 @@ func TestTokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) { t.Errorf("tokens %v, fill %+v, issuing %+v", stages, all.Fill, all.Issuing) } one, err := r.h.Tokens("g14", false) - if err != nil || len(one.Open) != 1 || string(one.Open[0].Sealed) != `"sealed-for-g14"` { + if err != nil || len(one.Open) != 1 || one.Open[0].Sealed != "sealed-for-g14" { t.Fatalf("one node: %+v, %v", one, err) } for k, row := range r.st.rows { diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index 1c1ca47..23a73dc 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -7,6 +7,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8). @@ -38,17 +39,8 @@ func flag(a map[string]any, k string) bool { return a[k] == true || strings.EqualFold(arg(a, k), "true") } -// lastsOf reads an expiry as a duration; none is the default. -func lastsOf(s string) (time.Duration, error) { - if s == "" { - return 0, nil - } - d, err := time.ParseDuration(s) - if err != nil { - return 0, fmt.Errorf("the expiry %q is no duration (for example 1h): %w", s, err) - } - return d, nil -} +// lastsOf reads an expiry as a duration (delivery.TokenLife); none is the default. +func lastsOf(s string) (time.Duration, error) { return delivery.TokenLife(s) } // Tools are what the runtime serves. func Tools(h *Holder) []stdio.Tool { @@ -79,7 +71,7 @@ func Tools(h *Holder) []stdio.Tool { "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 + var args delivery.Redeem raw, err := json.Marshal(a) if err != nil { return nil, err diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go index b96269f..29c1f65 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/vault.go @@ -3,6 +3,7 @@ package main import ( "encoding/base64" "encoding/json" + "errors" "fmt" "time" @@ -12,47 +13,83 @@ import ( // Seats the holder calls (ADR 0587 §3, §5, §7). The vault's two verbs are the ones its manifest names this seat the only // caller of; its `sign` names this seat for a node's certificate and a redeem answer. const ( - vaultToken = "seat:mesh-vault.enrolment-token" - vaultAccount = "seat:mesh-vault.node-account" - vaultSign = "seat:mesh-vault.sign" + vaultToken = "seat:mesh-vault." + delivery.EnrolmentTokenVerb + vaultAccount = "seat:mesh-vault." + delivery.NodeAccountVerb + vaultSign = "seat:mesh-vault." + delivery.SignVerb placementAdd = "seat:mesh-placement.propose" ) // Call asks a seat verb by its address (stdio.Ask through the runtime, or a test's). type Call func(key string, body any) (json.RawMessage, error) -// vaultOver is the vault as the holder reaches it: the seat's verbs over a Call. -type vaultOver struct{ call Call } - -func (v vaultOver) Token(node string, expires time.Time) (json.RawMessage, error) { - return v.call(vaultToken, map[string]any{"node": node, "expires": expires.UTC().Format(time.RFC3339)}) +// vaultOver is the vault as the holder reaches it: the seat's verbs over a Call, and its public signing key from its state. +type vaultOver struct { + call Call + read Reader } -func (v vaultOver) Sealed(node string) (json.RawMessage, error) { - return v.call(vaultToken, map[string]any{"node": node, "sealed": true}) +func answer[T any](raw json.RawMessage, err error) (T, error) { + var out T + if err != nil { + return out, err + } + if uerr := json.Unmarshal(raw, &out); uerr != nil { + return out, fmt.Errorf("the vault's answer does not read: %w", uerr) + } + return out, nil +} + +func (v vaultOver) Token(node string, expires time.Time) (delivery.EnrolmentTokenAnswer, error) { + return answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "expires": expires.UTC().Format(time.RFC3339)})) +} + +func (v vaultOver) Sealed(node string) (string, error) { + a, err := answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "sealed": "true"})) + if err == nil && a.Sealed == "" { + err = errors.New("the vault gave no sealed token") + } + return a.Sealed, err } func (v vaultOver) Drop(node string) error { - _, err := v.call(vaultToken, map[string]any{"node": node, "drop": true}) + a, err := answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "drop": "true"})) + if err == nil && !a.Dropped { + err = errors.New("the vault did not say the token and its hash are gone") + } return err } -func (v vaultOver) Account(node, sealingKey, keysDigest string) (json.RawMessage, error) { - return v.call(vaultAccount, map[string]any{"node": node, "sealing_key": sealingKey, "keys_digest": keysDigest}) +func (v vaultOver) Account(node, sealingKey, keysDigest string) (delivery.NodeAccountAnswer, error) { + return answer[delivery.NodeAccountAnswer](v.call(vaultAccount, map[string]any{"node": node, "sealing_key": sealingKey, "keys_digest": keysDigest})) } func (v vaultOver) Retire(node string) error { - _, err := v.call(vaultAccount, map[string]any{"node": node, "retire": true}) + a, err := answer[delivery.NodeAccountAnswer](v.call(vaultAccount, map[string]any{"node": node, "retire": "true"})) + if err == nil && !a.Retired { + err = errors.New("the vault did not say the account is retired") + } return err } -func (v vaultOver) SignCertificate(node, servingKey string) (json.RawMessage, error) { - return v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node, "serving_key": servingKey}) +func (v vaultOver) SignCertificate(node, servingKey string) (delivery.SignAnswer, error) { + return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node, "serving_key": servingKey})) } -func (v vaultOver) SignReply(node string, reply []byte) (json.RawMessage, error) { - return v.call(vaultSign, map[string]any{"kind": delivery.KindEnrolment, "node": node, - "reply": base64.StdEncoding.EncodeToString(reply)}) +func (v vaultOver) SignReply(node string, reply []byte) (delivery.SignAnswer, error) { + return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindEnrolment, "node": node, + "reply": base64.StdEncoding.EncodeToString(reply)})) +} + +func (v vaultOver) Signer() (string, error) { + raw, found, err := v.read.Get(delivery.SignersRead, delivery.SignersKeyKey) + if err != nil || !found { + return "", fmt.Errorf("the vault's signing key is not readable in %s: %v", delivery.SignersRead, err) + } + k, err := delivery.DecodeSignerKey(raw) + if err != nil { + return "", err + } + return k.Key, nil } // addNodeOver asks mesh-placement to add the node with the issue's warrant (ADR 0587 §7). diff --git a/modules/mesh-enrolment/module.json b/modules/mesh-enrolment/module.json index 4504a55..29c1455 100644 --- a/modules/mesh-enrolment/module.json +++ b/modules/mesh-enrolment/module.json @@ -37,6 +37,7 @@ "mesh-controller.lease", "mesh-delivery-assigner.placement", "mesh-grants.user-list", + "mesh-vault.signers", "node-engine.have" ], "state": [