292 lines
13 KiB
Go
292 lines
13 KiB
Go
package main
|
|
|
|
// A value a provider's software issued for each holder apart (design 57's V2c, issue 749; ADR 0349 clauses 10 and
|
|
// 11; ADR 0365 clause 4, where the forge mints one read token per agent account): the vault asks the provider's tool
|
|
// naming the holders, keeps each value the tool answers as that holder's own, and hands it, sealed, to that holder
|
|
// alone. A provider that answers one value keeps the provision's one credential, as before.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"golang.org/x/crypto/nacl/box"
|
|
)
|
|
|
|
// The two agents' tokens the stand-in forge mints: one per holder, each different.
|
|
var agentTokens = map[string]string{
|
|
"anchor/claude-code": "gta_anchor-agent-read-token-0001",
|
|
"laptop/claude-code": "gta_laptop-agent-read-token-0002",
|
|
}
|
|
|
|
// perHolderProvider is a provider's tool that issues one value per holder (the forge's read token per agent
|
|
// account): it answers the vault's account alone, each asked holder's value sealed to the call's key.
|
|
func perHolderProvider(tokens map[string]string, asked *[]map[string]any) func(string, map[string]any) (map[string]any, error) {
|
|
return func(caller string, args map[string]any) (map[string]any, error) {
|
|
if asked != nil {
|
|
*asked = append(*asked, args)
|
|
}
|
|
if !strings.HasSuffix(caller, ".mesh-vault") {
|
|
return nil, errors.New("this tool answers the vault alone")
|
|
}
|
|
key, _ := args["seal_to"].(string)
|
|
raw, err := base64.StdEncoding.DecodeString(key)
|
|
if err != nil || len(raw) != 32 {
|
|
return nil, errors.New("seal_to is not a sealing key")
|
|
}
|
|
var to [32]byte
|
|
copy(to[:], raw)
|
|
list, _ := args["holders"].([]any)
|
|
var out []map[string]any
|
|
for _, h := range list {
|
|
m, _ := h.(map[string]any)
|
|
node, _ := m["node"].(string)
|
|
module, _ := m["module"].(string)
|
|
value, ok := tokens[node+"/"+module]
|
|
if !ok {
|
|
continue
|
|
}
|
|
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, map[string]any{"node": node, "module": module,
|
|
"sealed": base64.StdEncoding.EncodeToString(sealed)})
|
|
}
|
|
return map[string]any{"holders": out}, nil
|
|
}
|
|
}
|
|
|
|
var twoAgents = []Holder{{Node: "anchor", Module: "claude-code"}, {Node: "laptop", Module: "claude-code"}}
|
|
|
|
// The holders' secrets' names in the vault's store.
|
|
const (
|
|
anchorsToken = "provision/novox/gitea-read/anchor/claude-code"
|
|
laptopsToken = "provision/novox/gitea-read/laptop/claude-code"
|
|
)
|
|
|
|
// bothMayAsk is a stand-in rule that would let both machines' clients ask for both values: the vault's own rule
|
|
// for a holder's value is what keeps each to its own.
|
|
var bothMayAsk = AllowList{
|
|
"provisioner.anchor": {anchorsToken, laptopsToken},
|
|
"provisioner.laptop": {anchorsToken, laptopsToken},
|
|
"laptop.claude-code": {anchorsToken, laptopsToken},
|
|
}
|
|
|
|
func forgeMesh(provider func(string, map[string]any) (map[string]any, error)) *aMesh {
|
|
return &aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){
|
|
"gitea.gitea_agent_token@novox": provider}}
|
|
}
|
|
|
|
// Two holders on two machines: the vault asks the tool naming both, keeps a value for each, and hands each only to
|
|
// that holder (its machine's provisioner client, or the module under an account of its own), sealed to its key.
|
|
func TestAProviderIssuesAValueForEachHolderAndEachHolderGetsOnlyItsOwn(t *testing.T) {
|
|
v := newVault(t, bothMayAsk)
|
|
var asked []map[string]any
|
|
mesh := forgeMesh(perHolderProvider(agentTokens, &asked))
|
|
v.Ask = mesh.ask
|
|
got, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(asked) != 1 || fmt.Sprint(asked[0]["holders"]) != "[map[module:claude-code node:anchor] map[module:claude-code node:laptop]]" {
|
|
t.Fatalf("the tool was asked %v", asked)
|
|
}
|
|
if len(got.Holders) != 2 || got.Holders[0].Secret != anchorsToken || got.Holders[1].Secret != laptopsToken ||
|
|
got.Holders[0].Generation != 1 || got.Unchanged {
|
|
t.Fatalf("answered %+v", got)
|
|
}
|
|
if _, held, _ := v.Store.Read("provision/novox/gitea-read"); held {
|
|
t.Error("a value per holder was also kept as the provision's one credential")
|
|
}
|
|
// Each holder gets its own, by its machine's client and by itself under an account of its own.
|
|
if got := handedTo(t, v, "provisioner.anchor", anchorsToken); got != agentTokens["anchor/claude-code"] {
|
|
t.Errorf("anchor got %q", got)
|
|
}
|
|
if got := handedTo(t, v, "provisioner.laptop", laptopsToken); got != agentTokens["laptop/claude-code"] {
|
|
t.Errorf("laptop got %q", got)
|
|
}
|
|
if got := handedTo(t, v, "laptop.claude-code", laptopsToken); got != agentTokens["laptop/claude-code"] {
|
|
t.Errorf("laptop's agent got %q", got)
|
|
}
|
|
// Neither gets the other's, though the stand-in rule would allow it.
|
|
for caller, name := range map[string]string{"provisioner.laptop": anchorsToken, "provisioner.anchor": laptopsToken,
|
|
"laptop.claude-code": anchorsToken} {
|
|
if _, err := v.Get(caller, name, mustKey(t)); err == nil || !strings.Contains(err.Error(), "alone") {
|
|
t.Errorf("%s was handed %s: %v", caller, name, err)
|
|
}
|
|
}
|
|
handed, _ := v.Store.Handed(20)
|
|
refused := 0
|
|
for _, h := range handed {
|
|
if h.Outcome == "refused" {
|
|
refused++
|
|
}
|
|
}
|
|
if refused != 3 {
|
|
t.Errorf("%d refusals recorded, want 3: %+v", refused, handed)
|
|
}
|
|
for _, token := range agentTokens {
|
|
if strings.Contains(mesh.said(), token) || strings.Contains(everything(t, v, got, handed), token) {
|
|
t.Fatal("an issued value crossed the bus, or is in something the vault said or wrote, in the clear")
|
|
}
|
|
}
|
|
rec, _, _ := v.Store.Read(anchorsToken)
|
|
if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller || rec.Operator.Sealed == "" {
|
|
t.Errorf("the record: %+v", rec)
|
|
}
|
|
|
|
// The same values again write nothing; one holder's new value changes that holder alone (ADR 0305 §5).
|
|
again, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents)
|
|
if err != nil || !again.Unchanged || again.Holders[0].Generation != 1 || again.Holders[1].Generation != 1 {
|
|
t.Errorf("again: %+v %v", again, err)
|
|
}
|
|
renewed := map[string]string{"anchor/claude-code": agentTokens["anchor/claude-code"],
|
|
"laptop/claude-code": "gta_laptop-agent-read-token-0003"}
|
|
v.Ask = forgeMesh(perHolderProvider(renewed, nil)).ask
|
|
moved, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents)
|
|
if err != nil || moved.Unchanged || !moved.Holders[0].Unchanged || moved.Holders[1].Unchanged ||
|
|
moved.Holders[1].Generation != 2 {
|
|
t.Errorf("renewed: %+v %v", moved, err)
|
|
}
|
|
// A holder's value is issued by the software: never rotated, never accepted.
|
|
if _, err := v.Rotate(ControllerCaller, laptopsToken, "jochen", "test"); err == nil {
|
|
t.Error("a holder's issued value was rotated")
|
|
}
|
|
if _, err := v.AcceptOpen(ControllerCaller, laptopsToken, "jochen", ""); err == nil {
|
|
t.Error("a holder's issued value was opened for accept")
|
|
}
|
|
}
|
|
|
|
// A holder the tool was not asked about, a holder twice, a value not sealed to the call, or an answer of both
|
|
// shapes: nothing is kept, for any holder. A holder the tool did not answer is said, not kept.
|
|
func TestAPerHolderAnswerIsTakenWholeOrNotAtAll(t *testing.T) {
|
|
sealedTo := func(args map[string]any, value string) string {
|
|
raw, _ := base64.StdEncoding.DecodeString(args["seal_to"].(string))
|
|
var to [32]byte
|
|
copy(to[:], raw)
|
|
s, _ := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
|
return base64.StdEncoding.EncodeToString(s)
|
|
}
|
|
var stranger [32]byte
|
|
_, _ = rand.Read(stranger[:])
|
|
notToTheCall, _ := box.SealAnonymous(nil, []byte("forged"), &stranger, rand.Reader)
|
|
answers := map[string]func(args map[string]any) map[string]any{
|
|
"a holder not asked about": func(args map[string]any) map[string]any {
|
|
return map[string]any{"holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")},
|
|
map[string]any{"node": "ace", "module": "claude-code", "sealed": sealedTo(args, "b")}}}
|
|
},
|
|
"a holder twice": func(args map[string]any) map[string]any {
|
|
return map[string]any{"holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")},
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "b")}}}
|
|
},
|
|
"a value not sealed to the call": func(args map[string]any) map[string]any {
|
|
return map[string]any{"holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")},
|
|
map[string]any{"node": "laptop", "module": "claude-code",
|
|
"sealed": base64.StdEncoding.EncodeToString(notToTheCall)}}}
|
|
},
|
|
"both shapes": func(args map[string]any) map[string]any {
|
|
return map[string]any{"sealed": sealedTo(args, "one"), "holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}}}
|
|
},
|
|
"an empty value": func(args map[string]any) map[string]any {
|
|
return map[string]any{"holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, " ")}}}
|
|
},
|
|
}
|
|
for what, answer := range answers {
|
|
v := newVault(t, nil)
|
|
answer := answer
|
|
v.Ask = forgeMesh(func(_ string, args map[string]any) (map[string]any, error) { return answer(args), nil }).ask
|
|
if _, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents); err == nil {
|
|
t.Errorf("%s was taken", what)
|
|
}
|
|
if all, _ := v.Store.List(); len(all) != 0 {
|
|
t.Errorf("%s kept %d values", what, len(all))
|
|
}
|
|
if len(v.pending) != 0 {
|
|
t.Errorf("%s left a call's key kept", what)
|
|
}
|
|
}
|
|
// Values per holder when the controller named no holder: refused, nothing kept.
|
|
v := newVault(t, nil)
|
|
v.Ask = forgeMesh(func(_ string, args map[string]any) (map[string]any, error) {
|
|
return map[string]any{"holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code", "sealed": sealedTo(args, "a")}}}, nil
|
|
}).ask
|
|
if _, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", nil); err == nil {
|
|
t.Error("values per holder were taken for no holder named")
|
|
}
|
|
// A holder the tool did not answer is said and keeps nothing; the others are kept.
|
|
w := newVault(t, nil)
|
|
w.Ask = forgeMesh(perHolderProvider(map[string]string{"anchor/claude-code": "only-anchors"}, nil)).ask
|
|
got, err := w.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents)
|
|
if err != nil || len(got.Holders) != 1 || len(got.NotAnswered) != 1 || got.NotAnswered[0] != twoAgents[1] {
|
|
t.Errorf("a holder not answered: %+v %v", got, err)
|
|
}
|
|
if _, held, _ := w.Store.Read(laptopsToken); held {
|
|
t.Error("a holder the tool did not answer holds a value")
|
|
}
|
|
// The holders the controller names are names the mesh writes, each once.
|
|
for _, bad := range [][]Holder{{{Node: "anchor", Module: ""}}, {{Node: "../x", Module: "claude-code"}},
|
|
{twoAgents[0], twoAgents[0]}} {
|
|
if _, err := w.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", bad); err == nil {
|
|
t.Errorf("holders %v were asked for", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A provider that answers one value keeps the provision's one credential, though the controller named holders:
|
|
// the per-provision path stays as it is.
|
|
func TestAProviderAnsweringOneValueKeepsTheProvisionsOneCredential(t *testing.T) {
|
|
v := newVault(t, AllowList{"provisioner.anchor": {"provision/anchor/plex-api"}})
|
|
software := "plex-token-from-the-vendor"
|
|
v.Ask = (&aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){
|
|
"plex.plex_issued_credential@anchor": standInProvider(&software)}}).ask
|
|
got, err := v.TakeIssuedFor(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential",
|
|
[]Holder{{Node: "anchor", Module: "kometa"}, {Node: "anchor", Module: "tautulli"}})
|
|
if err != nil || got.Secret != "provision/anchor/plex-api" || got.Generation != 1 || len(got.Holders) != 0 {
|
|
t.Fatalf("%+v %v", got, err)
|
|
}
|
|
if handedTo(t, v, "provisioner.anchor", "provision/anchor/plex-api") != software {
|
|
t.Error("the provision's one credential is not what the software issued")
|
|
}
|
|
}
|
|
|
|
// The seat's verb takes the holders as the controller sends them, and refuses a list it cannot read.
|
|
func TestTheIssuedVerbTakesTheHolders(t *testing.T) {
|
|
v := newVault(t, nil)
|
|
v.Ask = forgeMesh(perHolderProvider(agentTokens, nil)).ask
|
|
defs := definitions(&Env{Vault: v.Vault})
|
|
issued := toolNamed(t, defs, Seat+".issued")
|
|
answer, err := issued.runAs(ControllerCaller, map[string]any{"provision": "gitea-read", "provider": "novox",
|
|
"module": "gitea", "tool": "gitea_agent_token", "holders": []any{
|
|
map[string]any{"node": "anchor", "module": "claude-code"},
|
|
map[string]any{"node": "laptop", "module": "claude-code"}}})
|
|
if err != nil || len(answer.(Issued).Holders) != 2 {
|
|
t.Fatalf("%+v %v", answer, err)
|
|
}
|
|
for _, bad := range []any{"anchor", []any{"anchor"}, []any{map[string]any{"node": 1}}} {
|
|
if _, err := issued.runAs(ControllerCaller, map[string]any{"provision": "gitea-read", "provider": "novox",
|
|
"module": "gitea", "tool": "gitea_agent_token", "holders": bad}); err == nil {
|
|
t.Errorf("holders %v were read", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustKey(t *testing.T) string {
|
|
t.Helper()
|
|
key, err := seal.GenerateKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return key.PublicKey
|
|
}
|