diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go new file mode 100644 index 0000000..6ff3dd0 --- /dev/null +++ b/go/catalogue/enrolment_seat.go @@ -0,0 +1,92 @@ +package catalogue + +// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by +// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs +// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token: +// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and +// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go). + +// EnrolmentSeat is the seat mesh-enrolment. +const EnrolmentSeat = "mesh-enrolment" + +// The seat's six verbs (ADR 0587 §1, §4-§8). +const ( + EnrolmentVerbIssue = "issue" + EnrolmentVerbRedeem = "redeem" + EnrolmentVerbConfirm = "confirm" + EnrolmentVerbRevoke = "revoke" + EnrolmentVerbTokens = "tokens" + EnrolmentVerbAgain = "again" +) + +// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole +// content except enrolment-expired, which names the node and the enrolment-issued event that carried it. +const ( + EnrolmentIssuedEvent = "enrolment-issued" + NodeEnrolledEvent = "node-enrolled" + EnrolmentRefusedEvent = "enrolment-refused" + EnrolmentRevokedEvent = "enrolment-revoked" + EnrolmentExpiredEvent = "enrolment-expired" +) + +// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one. +var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent, + EnrolmentExpiredEvent} + +// enrolmentDecision is the record that made the seat. +const enrolmentDecision = "novox/hq ADR 0587" + +// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional: +// the seat is new with its holder, so no holder of before serves fewer. +func enrolmentVerbs() []Verb { + str := func(description string) map[string]any { + return map[string]any{"type": "string", "description": description} + } + return []Verb{ + {Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " + + "refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " + + "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + + "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + + "warrant, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + Input: schema(map[string]string{ + "node": "the new machine's name in the mesh", + "why": "why it joins, shown to the operator with the ask", + "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", + }, []string{"node", "why"})}, + {Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + + "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + + "vault for the node's account and certificate, writes the node into placement, and answers the account sealed " + + "to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " + + "it finishes what is not done and answers the same; with other keys it is refused.", + Input: map[string]any{"type": "object", "additionalProperties": false, + "required": []string{"node", "public_key", "sealing_key", "proof"}, + "properties": map[string]any{ + "node": str("the node the token was issued for"), + "public_key": str("the identity key's public half, base64"), + "overlay_key": str("the private network key's public half"), + "sealing_key": str("the sealing key's public half, which the account is sealed to"), + "serving_key": str("the serving key's public half, which the certificate binds"), + "profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"}, + "tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"}, + "proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"), + }}}, + {Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " + + "node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " + + "enrolment.", + Input: schema(map[string]string{}, nil)}, + {Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " + + "an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " + + "it, records the act as a hand act and says enrolment-revoked.", + Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"}, + []string{"node", "why"})}, + {Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " + + "the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " + + "token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " + + "history, the ended enrolments from the seat's events, as far back as the events are kept.", + Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"}, + nil, "history")}, + {Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " + + "and nothing more.", + Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})}, + } +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 4dc4536..79b48f0 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -305,6 +305,8 @@ var defaultSeats = append([]Seat{ // The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to // itself and back on the operator's warrant. {Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()}, + // Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment. + {Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()}, // The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go). {Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"}, Serves: artifactSweepVerbs()}, diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index bf90983..a75b064 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -25,6 +25,26 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) { // Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs. func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { + // 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused. + enrolment, ok := SeatNamed(EnrolmentSeat) + if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 { + t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment) + } + six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"} + holder := func(serves ...string) Manifest { + return Manifest{Module: "mesh-enrolment", Tools: serves, + Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}} + } + if err := CanHold(holder(six...), enrolment); err != nil { + t.Errorf("the holder serving the six verbs was refused: %v", err) + } + if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") { + t.Errorf("a holder missing again: %v", err) + } + if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") { + t.Errorf("a holder saying it serves mint: %v", err) + } + was := Seats() t.Cleanup(func() { UseSeats(was) }) UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",