V-3: a licence's token goes only to a machine a binding names (issue 1409)

The vault's get judges a licence's secret by a rule of its own, before the
asker rule: a grant goes to any proven caller, and an access token to a
machine on which a consumer's binding in licence-bindings names that
licence's key; a refusal names the rule and is recorded.

Builds: design 39 bus-and-switch §16, §17, A1, B3, B8, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:42:11 +01:00
parent 256a412590
commit 110c631b5f
4 changed files with 96 additions and 1 deletions
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -229,3 +230,43 @@ func watchLicenceBindings(lb LicenceBindings) bool {
}
return true
}
// licenceRefusal answers whether name is a licence's secret and, if it is, why the vault does not hand it to caller,
// empty when it does (§16, §17, A1, B3, B8, B25): a grant to any caller (B3), an access token to a machine on which a
// consumer's binding names that licence's key. The rule that a value goes only to a module under a bus account of
// its own does not apply to a licence's secrets (§17).
func (v *Vault) licenceRefusal(caller, name string) (bool, string) {
rest, ok := strings.CutPrefix(name, "licence.")
if !ok {
return false, ""
}
key, part := rest, ""
for _, p := range []string{LicenceGrant, LicenceAccess} {
if k, cut := strings.CutSuffix(rest, "."+p); cut {
key, part = k, p
}
}
if part == "" || !licenceKey.MatchString(key) {
return false, ""
}
if part == LicenceGrant {
return true, ""
}
machine, _, _ := strings.Cut(caller, ".")
if v.LicenceBindings == nil {
return true, "the vault keeps no licence bindings, so no binding names the licence " + key
}
consumers, err := v.LicenceBindings.Keys()
if err != nil {
return true, "the licence bindings cannot be read: " + err.Error()
}
for _, consumer := range consumers {
if c, _, _ := strings.Cut(consumer, "."); c != machine {
continue
}
if b, err := v.LicenceBindings.Get(consumer); err == nil && b != nil && b.Key == key {
return true, ""
}
}
return true, fmt.Sprintf("no consumer on %s is bound to the licence %s, and its token goes only to a machine a binding names", machine, key)
}
@@ -56,3 +56,45 @@ func TestLicencePutKeepsTheNewestGenerationAndRefusesAnOlder(t *testing.T) {
t.Error("licence-put took an unproven caller")
}
}
// design 39 bus-and-switch §17/AC1, §16, B3, B8, B25: a licence's access token is refused to a machine no binding names
// and for a licence its binding does not name, and given after a binding.changed naming it, to any consumer of that
// machine; the grant goes to the manager's tool runner, with no machine guard.
func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) {
v := newVault(t, nil)
lb, _ := memLicenceBindings()
v.LicenceBindings = &lb
const manager = "novox.node-tools"
for _, key := range []string{"jane_at_example.org", "api"} {
for _, part := range []string{LicenceGrant, LicenceAccess} {
if _, err := putLicence(t, v, manager, key, part, 1, key+"-"+part); err != nil {
t.Fatal(err)
}
}
}
access := "licence.jane_at_example.org.access"
if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") {
t.Fatalf("a machine no binding names was handed the token: %v", err)
}
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"api","key":"api","generation":4}`)); err != nil {
t.Fatal(err)
}
if _, _, err := asks(v, "g14.node-tools", access); err == nil {
t.Fatal("a licence its binding does not name was handed to g14")
}
if got, _, err := asks(v, "g14.node-tools", "licence.api.access"); err != nil || got != "api-access" {
t.Fatalf("g14's own licence: %q %v", got, err)
}
if err := lb.Heard([]byte(`{"consumer":"g14.worker-1","licence":"jane@example.org","key":"jane_at_example.org","generation":2}`)); err != nil {
t.Fatal(err)
}
if got, _, err := asks(v, "g14.node-tools", access); err != nil || got != "jane_at_example.org-access" {
t.Fatalf("after a binding.changed naming it: %q %v", got, err)
}
if _, _, err := asks(v, "ace.node-tools", access); err == nil {
t.Fatal("another machine was handed g14's token")
}
if got, _, err := asks(v, manager, "licence.jane_at_example.org.grant"); err != nil || got != "jane_at_example.org-grant" {
t.Fatalf("the manager's grant: %q %v", got, err)
}
}
+2 -1
View File
@@ -58,7 +58,8 @@ func build() *Env {
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
assigned = &WatchedAssignments{Fallback: readPlanRaw}
watchedPlan := func(key string) ([]byte, error) { raw, err := assigned.Read(key); return asAssignment(key, raw, err) }
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store},
licenceBindings := busLicenceBindings()
env.Vault = &Vault{Store: store, Own: own, LicenceBindings: &licenceBindings, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, WantRead: readWant,
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Assigner: seatAssignments, Signers: busSigners{},
@@ -28,6 +28,9 @@ const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// LicenceBindings is the vault's own state of the licence bindings (licencebindings.go): which machine a licence's
// access token goes to. Nil hands it to no machine.
LicenceBindings *LicenceBindings
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
@@ -386,8 +389,16 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
isLicence, licenceWhy := v.licenceRefusal(caller, name)
if isLogin {
// Judged above: the node's own engine, sealed to its own key.
} else if isLicence {
// A licence's secret, by the licence rule (design 39 bus-and-switch §16, §17).
if licenceWhy != "" {
v.log("refused %s to %s: %s", name, caller, licenceWhy)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: licenceWhy})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, licenceWhy)
}
} else if ok, why := askers.May(caller, name); !ok {
if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet {
// Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it.