diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index d93eced..24b624e 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/netip" + "strconv" "strings" "time" @@ -15,7 +16,7 @@ import ( // **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.`, one per open enrolment, holds an Enrolment; `address.
` -// reserves one private-network address for one open enrolment and holds only the node's name. Every write is a +// 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.` from a key at live and the vault's hash (§4). These constants are the seat's @@ -63,10 +64,31 @@ type Enrolment struct { OverlayKey string `json:"overlay-key"` } -// EnrolmentAddress is one key `address.
`: the node the address is reserved for, and nothing else. +// EnrolmentAddress is one key `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"` + 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..`, 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`. @@ -148,21 +170,24 @@ func DecodeEnrolment(data []byte) (Enrolment, error) { return e, nil } -// EncodeEnrolmentAddress is a key `address.
` reserving it for node. -func EncodeEnrolmentAddress(node string) ([]byte, error) { - if err := checkName("node", node); err != nil { +// EncodeEnrolmentAddress is a key `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(EnrolmentAddress{V: Version, Node: node}) + return json.Marshal(a) } -// DecodeEnrolmentAddress reads a key `address.
`. +// DecodeEnrolmentAddress reads a key `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 := checkName("node", a.Node); err != nil { + if err := a.check(); err != nil { return EnrolmentAddress{}, err } return a, nil diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 1aadc1c..a896628 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "reflect" "regexp" + "strconv" "strings" "testing" "time" @@ -91,10 +92,28 @@ func TestNodeRecord(t *testing.T) { t.Errorf("enrolment round trip: %+v, %v", back, err) } } - if raw, err := EncodeEnrolmentAddress("lab"); err != nil { + // ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both. + if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil { t.Fatal(err) - } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" { - t.Errorf("address round trip: %+v, %v", back, err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") { + t.Errorf("address round trip: %+v, %v (%s)", back, err, raw) + } + if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil { + t.Fatal(err) + } else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) { + t.Errorf("address at the ask round trip: %+v, %v", back, err) + } + for name, raw := range map[string]string{ + "no node": `{"v":1}`, + "an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`, + "an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`, + } { + if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil { + t.Errorf("an address with %s: taken", name) + } + } + if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) { + t.Errorf("the issued event's id: %q", id) } for name, e := range map[string]Enrolment{ "keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,