From e85cf002a4b8a5fd428fe22b46fb5537238029e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:07:44 +0100 Subject: [PATCH] V-1: the vault serves licence-put, keeping the caller's newest generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A licence's grant and access token move into the vault (ADR 0550 §3), handed in two steps with a key made for the call, as move-in does; the caller's generation is the record's, an older one is refused, and secret-changed names the secret and its generation. Builds: design 39 bus-and-switch §15, B3, B4, B5, B6 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/mesh-vault/cmd/mesh-vault/intake.go | 10 ++- modules/mesh-vault/cmd/mesh-vault/licences.go | 80 +++++++++++++++++++ .../cmd/mesh-vault/licences_test.go | 58 ++++++++++++++ modules/mesh-vault/cmd/mesh-vault/tools.go | 22 +++++ modules/mesh-vault/cmd/mesh-vault/vault.go | 10 +++ modules/mesh-vault/module.json | 3 +- 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/licences.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/licences_test.go diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index 9b5648add..38d49d00a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -91,10 +91,12 @@ type callKey struct { // move is what `move-in` takes: the row of the controller's store (move.go). move *MoveOf // why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go). - why string - public [32]byte - private [32]byte - until time.Time + why string + // generation is the licence's generation `licence-put` keeps its value at (licences.go). + generation int + public [32]byte + private [32]byte + until time.Time } // Opened is a first step's answer: the call, and the key to seal its value to. Never a value. diff --git a/modules/mesh-vault/cmd/mesh-vault/licences.go b/modules/mesh-vault/cmd/mesh-vault/licences.go new file mode 100644 index 000000000..6eee48ee2 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licences.go @@ -0,0 +1,80 @@ +package main + +// A licence's two secrets (design 39 bus-and-switch §15, B3 to B6): `licence..grant` and +// `licence..access`, handed to the vault by the licence manager through `licence-put` in two steps — name +// the licence, the part and the generation and get a call and a key made for it, then send the call and the value sealed +// to that key. The caller's generation is the record's; the vault keeps the newest and refuses an older one, and says +// `secret-changed` with the secret's name and generation, never the value. + +import ( + "errors" + "fmt" + "regexp" +) + +// LicencePutVerb is the verb that hands a licence's secret to the vault. +const LicencePutVerb = "licence-put" + +// The parts of a licence the vault holds (§15). +const ( + LicenceGrant = "grant" + LicenceAccess = "access" +) + +// licenceKey is a licence's key form (§10, B6): lower case, letters, digits, `-`, `.` and `_`. +var licenceKey = regexp.MustCompile(`^[a-z0-9_.-]{1,120}$`) + +// LicenceSecret is the name of one part of a licence in the vault. +func LicenceSecret(key, part string) (string, error) { + if !licenceKey.MatchString(key) { + return "", fmt.Errorf("%q is not a licence's key: lower-case letters, digits, -, . and _", key) + } + if part != LicenceGrant && part != LicenceAccess { + return "", fmt.Errorf("the part %q is not a part of a licence: grant or access", part) + } + return "licence." + key + "." + part, nil +} + +// LicencePutOpen opens a call to take one part of a licence at a generation. +func (v *Vault) LicencePutOpen(caller, licence, part string, generation int) (Opened, error) { + if err := fromAGrantedCaller(caller, LicencePutVerb); err != nil { + return Opened{}, err + } + secret, err := LicenceSecret(licence, part) + if err != nil { + return Opened{}, err + } + if generation < 1 { + return Opened{}, errors.New("licence-put takes the licence's generation, one or more; nothing is taken") + } + id, key, err := v.openCall(LicencePutVerb, secret, caller, nil) + if err != nil { + return Opened{}, err + } + v.callsMu.Lock() + if c := v.pending[id]; c != nil { + c.generation = generation + } + v.callsMu.Unlock() + return Opened{Call: id, SealTo: key, Secret: secret}, nil +} + +// LicencePutTake opens the value sealed to the call's key and keeps it at the call's generation. +func (v *Vault) LicencePutTake(caller, call, sealed string) (Changed, error) { + c, err := v.takeCall(LicencePutVerb, call) + if err != nil { + return Changed{}, err + } + if c.by != caller { + return Changed{}, fmt.Errorf("the licence-put call %q was opened by another caller; nothing was taken", call) + } + value, err := c.open(sealed) + if err != nil { + return Changed{}, err + } + defer zero(value) + if len(value) == 0 { + return Changed{}, errors.New("the value handed in is empty; nothing was taken") + } + return v.keepWith(c.secret, string(value), caller, "", OriginAccepted, keepOptions{generation: c.generation}) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/licences_test.go b/modules/mesh-vault/cmd/mesh-vault/licences_test.go new file mode 100644 index 000000000..dafc5dc0c --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/licences_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" +) + +// putLicence hands one part of a licence to the vault in licence-put's two steps. +func putLicence(t *testing.T, v testVault, caller, key, part string, generation int, value string) (Changed, error) { + t.Helper() + o, err := v.LicencePutOpen(caller, key, part, generation) + if err != nil { + return Changed{}, err + } + return v.LicencePutTake(caller, o.Call, sealToCall(t, o.SealTo, value)) +} + +// design 39 bus-and-switch §15, B3, B5: the caller's generation is the record's; an older one is refused; the +// event names the secret and its generation, never the value. +func TestLicencePutKeepsTheNewestGenerationAndRefusesAnOlder(t *testing.T) { + v := newVault(t, nil) + const manager = "claude-licence-manager" + if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 3, `{"refreshToken":"rt-3"}`); err != nil { + t.Fatal(err) + } + rec, held, err := v.Store.Read("licence.jane_at_example.org.grant") + if err != nil || !held || rec.Generation != 3 { + t.Fatalf("held %v at generation %d (%v), want generation 3", held, rec.Generation, err) + } + if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 2, `{"refreshToken":"rt-2"}`); err == nil || + !strings.Contains(err.Error(), "not newer") { + t.Fatalf("an older generation was taken: %v", err) + } + if c, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 4, `{"refreshToken":"rt-4"}`); err != nil || + c.Generation != 4 { + t.Fatalf("generation 4: %+v %v", c, err) + } + said := 0 + for _, raw := range v.bus.raw { + if strings.Contains(raw, "rt-") { + t.Fatalf("an event carries the value: %s", raw) + } + if strings.Contains(raw, `"secret-changed"`) && strings.Contains(raw, "licence.jane_at_example.org.grant") { + said++ + } + } + if said != 2 { + t.Fatalf("secret-changed said %d times, want 2: %v", said, v.bus.raw) + } + for _, bad := range []struct{ key, part string }{{"Jane@x", LicenceGrant}, {"jane", "refresh"}} { + if _, err := v.LicencePutOpen(manager, bad.key, bad.part, 1); err == nil { + t.Errorf("licence-put took %s/%s", bad.key, bad.part) + } + } + if _, err := v.LicencePutOpen("", "jane", LicenceAccess, 1); err == nil { + t.Error("licence-put took an unproven caller") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 7349c63d9..5e23494bf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -208,6 +208,28 @@ func definitions(env *Env) []toolDef { } return v.OwnDrift() }}, + {Name: Seat + "." + LicencePutVerb, + Description: "Hand the vault one of a licence's two secrets (design 39 bus-and-switch §15), in two steps: name " + + "the licence by its key, the part (grant or access) and its generation, and get a call and a key made for " + + "it; then send the call and the value sealed to that key. Keeps the newest generation and refuses an older " + + "one; says secret-changed with the secret's name and generation, never the value.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "licence": str("the first step: the licence's key"), + "part": str("the first step: grant or access"), + "generation": map[string]any{"type": "number", "description": "the first step: the licence's generation"}, + "call": str("the second step: the call the first step answered"), + "sealed": str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")}}, + run: func(caller string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + if call := arg(a, "call"); call != "" { + return v.LicencePutTake(caller, call, arg(a, "sealed")) + } + g, _ := a["generation"].(float64) + return v.LicencePutOpen(caller, arg(a, "licence"), arg(a, "part"), int(g)) + }}, {Name: Seat + ".move-in", Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " + "node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " + diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 85f694226..85c0c6ccf 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -150,6 +150,9 @@ type keepOptions struct { replaceOnly bool keepPrevious bool refuse func(before Record) string + // generation, when one or more, is the record's generation as the caller numbers it (licence-put, licences.go): a + // value at a generation no newer than the one held is refused. + generation int } // keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept @@ -234,6 +237,10 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) return Changed{}, errors.New(why) } } + if opt.generation > 0 && opt.generation <= before.Generation { + return Changed{}, fmt.Errorf("%s is held at generation %d, and generation %d is not newer: nothing was taken", + name, before.Generation, opt.generation) + } if opt.neverReplace { return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy, ChangedAt: before.ChangedAt, Origin: before.Origin} @@ -266,6 +273,9 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) } else if !opt.madeAt.IsZero() { rec.MadeAt = opt.madeAt.UTC() } + if opt.generation > 0 { + rec.Generation = opt.generation + } if opt.movedFrom != nil { moved := *opt.movedFrom moved.At = now diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 79a24ea6e..385095d3c 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -238,7 +238,8 @@ "rotate-node", "node-account-confirm", "abandon", - "retire" + "retire", + "licence-put" ] } ],