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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user