Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb958f668e | ||
|
|
63b7aec065 | ||
|
|
efddb634e5 | ||
|
|
eba300794f | ||
|
|
2abe1f95e8 |
@@ -112,6 +112,54 @@ of its own, for what its node's declaration names, and a module under its own ac
|
||||
exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes
|
||||
behind the `Askers` interface in `askers.go` (design 57's V4 and P3).
|
||||
|
||||
## Taking values in: `accept`, `secret-ask`, `issued`, `rotate`
|
||||
|
||||
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
|
||||
controller's place). `intake.go`.
|
||||
|
||||
**Two steps of one verb, from the controller alone.** The first step names the secret and answers a `call` and
|
||||
`seal_to`, the public half of an X25519 key the vault made for that one call (raw, base64 — the NaCl anonymous box
|
||||
the desk's prompt and a provider's tool already seal to). The second step sends `call` and `sealed`, and the vault
|
||||
opens it with the private half, which lives in this process only and is forgotten once used or after two minutes.
|
||||
A value sealed to any other key is refused, and a call is answered once.
|
||||
|
||||
**Why the controller carries it.** A desk's prompt (`node-launcher.secret`) and a provider's tool answer the
|
||||
controller alone: the bus lets only the controller ask them, on the subject that names its caller (issue 365). The
|
||||
vault cannot call in a name of its own while it runs in its machine's tool runner, whose one bus account carries
|
||||
that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them with the vault's key and
|
||||
passes the sealed answer on; it cannot open it. The value opens in the vault and nowhere else. The vault refuses
|
||||
these steps from any caller but `controller`, as the runtime proved it.
|
||||
|
||||
- `accept` — node, module, name (a module's own secret) or provider, provision (a provision's one credential),
|
||||
and `by`. The controller's terminal seals the value it read to the call's key before it leaves the terminal.
|
||||
Kept with origin `accepted`; an accepted provision credential is never remade (`rotate` refuses it); a value a
|
||||
provider's software issued is not taken by `accept`.
|
||||
- `secret-ask` — node, module, name, desk and `asked_by` (who asked the controller, as the bus named its caller).
|
||||
The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same secret is
|
||||
open (two minutes at most) or when three were made in the last hour. The answer names who asked as the record
|
||||
has it, which the prompt shows. The second step carries what the prompt sealed, or `outcome` (dismissed,
|
||||
timed-out, empty, refused, failed); how each ask ended is recorded. `secret_asks` reads the record. The hand-act
|
||||
log stays the controller's: it records the act once the vault has taken the value.
|
||||
- `issued` — provision, provider (machine), module. The controller asks the provider's tool with the call's key,
|
||||
as it asks it today; the value is kept as the provision's one credential with origin `issued`. The same value
|
||||
again writes nothing, says nothing and keeps the generation (ADR 0305 §5).
|
||||
- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the
|
||||
software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). Who asked is the proven caller,
|
||||
or the `by` the controller names when it carries the act; both are kept (`changed-by`, `changed-via`).
|
||||
|
||||
Names in the store: `own/<node>/<module>/<name>` and `provision/<provider node>/<provision>`. V3 copies the
|
||||
controller's rows in under these names.
|
||||
|
||||
**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are
|
||||
filled the new way (its machine asks the vault for them, V4); every other module keeps the controller's path, so
|
||||
no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision on issue 675). Until
|
||||
V4 that is no module: these verbs are served and tested, and nothing calls them yet.
|
||||
|
||||
**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault`
|
||||
uses a stand-in provider that refuses every caller but the controller and answers only sealed to the call's key.
|
||||
plex's own tool already answers the controller alone; whether it moves with the media catalogue is for that move,
|
||||
after release 57.
|
||||
|
||||
## Events
|
||||
|
||||
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
|
||||
@@ -151,9 +199,9 @@ file.
|
||||
|
||||
## What comes later
|
||||
|
||||
Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move
|
||||
in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is
|
||||
empty and the controller still serves every secret.
|
||||
Taking values in is built (above); the controller's secrets move in with V3; nodes ask with V4, which also turns
|
||||
the controller's switch on for each module it moves; pair credentials are minted here with V5. Until V3 the
|
||||
vault's store is empty and the controller still serves every secret.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -161,4 +209,8 @@ empty and the controller still serves every secret.
|
||||
|
||||
`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no
|
||||
private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker,
|
||||
sealed to its key; `secret-changed` carries no value.
|
||||
sealed to its key; `secret-changed` carries no value. `intake_test.go` is ADR 0349's `secret-ask` test as far as
|
||||
the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as
|
||||
the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305
|
||||
§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and
|
||||
accept and rotate. `followups_test.go` holds V1's review follow-ups.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
// The four follow-ups V1's third review (review 263 on mesh-catalog#286) left for this package: the tool layer
|
||||
// passes an act's words through; a rolled record of hand-outs is said in the journal; the export's fingerprint
|
||||
// and vault_status are what they say; and an act's unproven `by` is kept beside the proven caller.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
func anyOf(list []string) []any {
|
||||
out := make([]any, len(list))
|
||||
for i, s := range list {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// (1) and (4): the retirement tools pass consumers, why, by, via and confirm through to the custody ledger, and
|
||||
// a `by` naming somebody other than the proven caller is kept beside it.
|
||||
func TestTheRetirementToolsPassTheActThrough(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(5))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(5), 1))
|
||||
c.stable()
|
||||
set := waitingNames(c.Retirement())
|
||||
sort.Strings(set)
|
||||
defs := definitions(&Env{Custody: c.Custody})
|
||||
approve := toolNamed(t, defs, "provisioner_retire_approve")
|
||||
if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil {
|
||||
t.Error("the tool took a set other than the one waiting")
|
||||
}
|
||||
got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone",
|
||||
"by": "person.jochen", "via": "telegram"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done := got.(map[string]any)["retired"].([]string); len(done) != 4 {
|
||||
t.Errorf("retired %v", done)
|
||||
}
|
||||
last := c.bus.events[len(c.bus.events)-5]["body"].(map[string]any)
|
||||
for _, e := range c.bus.events {
|
||||
if b := e["body"].(map[string]any); b["change"] == "approved" {
|
||||
last = b
|
||||
}
|
||||
}
|
||||
if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" {
|
||||
t.Errorf("the approval said %v", last)
|
||||
}
|
||||
|
||||
// Delete: consumer and confirm through, the act's words with them.
|
||||
r := retiredC(t)
|
||||
del := toolNamed(t, definitions(&Env{Custody: r.Custody}), "provisioner_delete")
|
||||
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "c-app", "why": "x"}); err == nil {
|
||||
t.Error("a confirm naming another was taken through the tool")
|
||||
}
|
||||
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "d-app", "why": "gone"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := r.bus.events[len(r.bus.events)-1]["body"].(map[string]any)
|
||||
if body["change"] != "deleted" || body["by"] != "person.jochen" || body["via"] != nil || body["why"] != "gone" {
|
||||
t.Errorf("the deletion said %v", body)
|
||||
}
|
||||
|
||||
// Reject, by the proven caller alone: nothing more is added to via.
|
||||
k := newCustody(t)
|
||||
k.ask(apps(5))
|
||||
k.passes(1)
|
||||
k.ask(keepOnly(apps(5), 1))
|
||||
k.stable()
|
||||
waiting := waitingNames(k.Retirement())
|
||||
if _, err := toolNamed(t, definitions(&Env{Custody: k.Custody}), "provisioner_retire_reject").runAs("person.jochen",
|
||||
map[string]any{"consumers": anyOf(waiting), "why": "they come back"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := k.bus.events[len(k.bus.events)-1]["body"].(map[string]any); body["by"] != "person.jochen" || body["via"] != nil {
|
||||
t.Errorf("the rejection said %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// (2): a roll of the record of hand-outs is said in the journal, loudly.
|
||||
func TestARolledRecordOfHandOutsIsSaid(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
var mu sync.Mutex
|
||||
var said []string
|
||||
v.Log = func(format string, args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, fmt.Sprintf(format, args...))
|
||||
}
|
||||
v.Store.HandedMax = 600
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for i := 0; i < 20; i++ {
|
||||
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
|
||||
}
|
||||
rolled := 0
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, "THE RECORD OF HAND-OUTS WAS ROLLED") {
|
||||
rolled++
|
||||
}
|
||||
}
|
||||
if rolled == 0 || rolled != v.Store.Rolled() {
|
||||
t.Errorf("rolled %d times, said %d times", v.Store.Rolled(), rolled)
|
||||
}
|
||||
}
|
||||
|
||||
// (3): the vault's export names the operator key's fingerprint as the controller's secrets.Fingerprint says it,
|
||||
// and vault_status says what needs a person.
|
||||
func TestTheExportsFingerprintAndVaultStatus(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := answer.(map[string]any)
|
||||
// sha256 of the key, its first eight bytes in hex: the controller's secrets.Fingerprint.
|
||||
if fp := doc["fingerprint"].(string); !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+16 ||
|
||||
fp != keyFingerprint(v.operatorPublic) || doc["operator-key"] != v.operatorPublic {
|
||||
t.Errorf("the export says %v", doc)
|
||||
}
|
||||
if keyFingerprint("") != "" {
|
||||
t.Error("no key has a fingerprint")
|
||||
}
|
||||
|
||||
env := &Env{Vault: v.Vault}
|
||||
status := func() map[string]any {
|
||||
got, err := toolNamed(t, definitions(env), "vault_status").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got.(map[string]any)
|
||||
}
|
||||
if s := status(); s["ok"] != true || s["copies_on_an_earlier_key"] != 0 || s["operator_key"] != v.operatorPublic {
|
||||
t.Errorf("a vault in order says %v", s)
|
||||
}
|
||||
env.refuseKey("a private key was offered")
|
||||
if s := status(); s["ok"] != false || s["operator_key_refused"] != "a private key was offered" {
|
||||
t.Errorf("a refused key says %v", s)
|
||||
}
|
||||
env.refuseKey("")
|
||||
// A new operator key whose resealing fails leaves copies on the earlier key, and the status says so.
|
||||
pub, _ := operatorKey(t)
|
||||
v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") }
|
||||
_, _ = v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
v.Store.failWrite = nil
|
||||
if s := status(); s["ok"] != false || s["copies_on_an_earlier_key"] != 1 {
|
||||
t.Errorf("a copy on an earlier key says %v", s)
|
||||
}
|
||||
if s, _ := toolNamed(t, definitions(&Env{}), "vault_status").Run(map[string]any{}); s.(map[string]any)["ready"] != false {
|
||||
t.Errorf("no vault says %v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305
|
||||
// with the vault in the controller's place; design 57's V2, issue 675).
|
||||
//
|
||||
// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the
|
||||
// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`.
|
||||
//
|
||||
// **A value comes in sealed to a key the vault made for that one call, and opens only here.** The first three
|
||||
// are two steps of one verb. The first names what is taken and answers a call and the public half of a key made
|
||||
// for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's prompt and a provider's tool
|
||||
// already seal to); the second hands back what was sealed to that key, and the vault opens it with the private
|
||||
// half, which never leaves this process and is forgotten once used or after two minutes.
|
||||
//
|
||||
// **The controller carries the call, and cannot open what it carries.** The desk's prompt and a provider's tool
|
||||
// answer the controller alone: the bus lets only the controller ask them, on the subject that names its caller
|
||||
// (issue 365). The vault cannot call in a name of its own while it runs in its machine's tool runner, whose one
|
||||
// bus account carries that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them on
|
||||
// the vault's behalf with the vault's key, and passes on the sealed answer: the value is opened in the vault and
|
||||
// nowhere else (ADR 0349 clause 9, ADR 0277 §23), and the vault takes these steps from the controller alone.
|
||||
//
|
||||
// Which secret each one is, by name in the vault's store:
|
||||
//
|
||||
// own/<node>/<module>/<name> a module's own secret on one machine
|
||||
// provision/<provider node>/<provision> the one credential of a provider assignment and provision (clause 8)
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName).
|
||||
const ControllerCaller = "controller"
|
||||
|
||||
// Where a value came from.
|
||||
const (
|
||||
OriginMade = "made"
|
||||
OriginAccepted = "accepted"
|
||||
OriginTyped = "typed"
|
||||
OriginIssued = "issued"
|
||||
)
|
||||
|
||||
// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits.
|
||||
const CallOpenFor = 2 * time.Minute
|
||||
|
||||
// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now).
|
||||
const (
|
||||
// SecretAskOpenFor is how long an ask that has not ended counts as open.
|
||||
SecretAskOpenFor = 2 * time.Minute
|
||||
// SecretAsksPerHour is how many asks for one secret an hour takes.
|
||||
SecretAsksPerHour = 3
|
||||
)
|
||||
|
||||
var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
|
||||
|
||||
// OwnSecret is the vault's name for a module's own secret on one machine.
|
||||
func OwnSecret(node, module, name string) (string, error) {
|
||||
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} {
|
||||
if !namePart.MatchString(v) {
|
||||
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
return "own/" + node + "/" + module + "/" + name, nil
|
||||
}
|
||||
|
||||
// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision.
|
||||
func ProvisionSecret(provider, provision string) (string, error) {
|
||||
for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} {
|
||||
if !namePart.MatchString(v) {
|
||||
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
return "provision/" + provider + "/" + provision, nil
|
||||
}
|
||||
|
||||
// callKey is one call's key and what the call takes in. Never written anywhere.
|
||||
type callKey struct {
|
||||
verb, secret, by string
|
||||
// ask is the ask's record for `secret-ask`, which the call's id is.
|
||||
ask *AskRecord
|
||||
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.
|
||||
type Opened struct {
|
||||
Call string `json:"call"`
|
||||
SealTo string `json:"seal_to"`
|
||||
Secret string `json:"secret"`
|
||||
AskedBy string `json:"asked_by,omitempty"`
|
||||
}
|
||||
|
||||
// fromTheController refuses every caller but the controller: only it reaches a desk's prompt and a provider's
|
||||
// tool, and only it carries a value given at its terminal.
|
||||
func fromTheController(caller, verb string) error {
|
||||
if caller != ControllerCaller {
|
||||
if caller == "" {
|
||||
caller = stdio.Unproven
|
||||
}
|
||||
return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+
|
||||
"for one call; %s is not the controller", verb, caller)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes
|
||||
// have passed.
|
||||
func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) {
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
idRaw := make([]byte, 12)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id := hex.EncodeToString(idRaw)
|
||||
if ask != nil {
|
||||
id = ask.ID
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
if v.pending == nil {
|
||||
v.pending = map[string]*callKey{}
|
||||
}
|
||||
now := v.Now()
|
||||
for k, c := range v.pending {
|
||||
if now.After(c.until) {
|
||||
delete(v.pending, k)
|
||||
}
|
||||
}
|
||||
v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv,
|
||||
until: now.Add(CallOpenFor)}
|
||||
return id, base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||
}
|
||||
|
||||
// takeCall ends a call: its key is forgotten whatever follows.
|
||||
func (v *Vault) takeCall(verb, id string) (*callKey, error) {
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
c, ok := v.pending[id]
|
||||
if !ok || c.verb != verb {
|
||||
return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+
|
||||
"started again; open it anew", verb, id)
|
||||
}
|
||||
delete(v.pending, id)
|
||||
if v.Now().After(c.until) {
|
||||
return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// open opens what was sealed to the call's key, and forgets the key.
|
||||
func (c *callKey) open(sealed string) ([]byte, error) {
|
||||
defer func() { c.private = [32]byte{} }()
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed))
|
||||
if err != nil {
|
||||
return nil, errors.New("the value is not sealed to this call; nothing was taken")
|
||||
}
|
||||
out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private)
|
||||
if !ok {
|
||||
// Never the value, never what failed to open: only that it was not sealed to this call.
|
||||
return nil, errors.New("the value is not sealed to this call; nothing was taken")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accept -------------------------------------------------------------------------------------------------
|
||||
|
||||
// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal
|
||||
// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value).
|
||||
func (v *Vault) AcceptOpen(caller, secret, by string) (Opened, error) {
|
||||
if err := fromTheController(caller, "accept"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if rec, held, err := v.Store.Read(secret); err != nil {
|
||||
return Opened{}, err
|
||||
} else if held && rec.Origin == OriginIssued {
|
||||
return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+
|
||||
"it enters the vault (ADR 0305 §10); nothing is taken", secret)
|
||||
}
|
||||
id, key, err := v.openCall("accept", secret, by, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault.
|
||||
func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) {
|
||||
if err := fromTheController(caller, "accept"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
c, err := v.takeCall("accept", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
value, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
defer zero(value)
|
||||
if strings.TrimSpace(string(value)) == "" {
|
||||
return Changed{}, errors.New("the value given is empty; nothing was taken")
|
||||
}
|
||||
return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, OriginAccepted)
|
||||
}
|
||||
|
||||
// ---- secret-ask ---------------------------------------------------------------------------------------------
|
||||
|
||||
// AskOf is what an ask is for, and where.
|
||||
type AskOf struct {
|
||||
Node, Module, Name, Desk string
|
||||
// AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the
|
||||
// controller alone.
|
||||
AskedBy string
|
||||
}
|
||||
|
||||
// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask
|
||||
// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to,
|
||||
// and who asked as the record says it, which the prompt names (§26).
|
||||
func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) {
|
||||
if err := fromTheController(caller, "secret-ask"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
secret, err := OwnSecret(a.Node, a.Module, a.Name)
|
||||
if err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
if !namePart.MatchString(a.Desk) {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk)
|
||||
}
|
||||
askedBy := askedByName(a.AskedBy)
|
||||
v.Store.asksMu.Lock()
|
||||
defer v.Store.asksMu.Unlock()
|
||||
now := v.Now().UTC()
|
||||
recent, err := v.Store.AsksSince(now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err)
|
||||
}
|
||||
ended := map[string]bool{}
|
||||
for _, r := range recent {
|
||||
if r.Event == AskEnded {
|
||||
ended[r.ID] = true
|
||||
}
|
||||
}
|
||||
lastHour, openBy := 0, ""
|
||||
for _, r := range recent {
|
||||
if r.Event != AskOpened || r.Secret != secret {
|
||||
continue
|
||||
}
|
||||
lastHour++
|
||||
if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) {
|
||||
openBy = r.AskedBy
|
||||
}
|
||||
}
|
||||
if openBy != "" {
|
||||
return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+
|
||||
"secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor)
|
||||
}
|
||||
if lastHour >= SecretAsksPerHour {
|
||||
return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+
|
||||
"for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour)
|
||||
}
|
||||
idRaw := make([]byte, 12)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node,
|
||||
Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller}
|
||||
if err := v.recordAsk(rec); err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err)
|
||||
}
|
||||
id, key, err := v.openCall("secret-ask", secret, askedBy, &rec)
|
||||
if err != nil {
|
||||
_ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"})
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil
|
||||
}
|
||||
|
||||
// The outcomes an ask ends with, as the controller's record had them.
|
||||
var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true}
|
||||
|
||||
// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with
|
||||
// the outcome the desk answered instead. How it ended is recorded whatever happens.
|
||||
func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) {
|
||||
if err := fromTheController(caller, "secret-ask"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if sealed == "" && !askOutcomes[outcome] {
|
||||
return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " +
|
||||
"timed-out, empty, refused or failed")
|
||||
}
|
||||
c, err := v.takeCall("secret-ask", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
end := func(how string) {
|
||||
if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret,
|
||||
Outcome: how}); err != nil {
|
||||
v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err)
|
||||
}
|
||||
}
|
||||
if sealed == "" {
|
||||
end(outcome)
|
||||
return Changed{Name: c.secret, Unchanged: true}, nil
|
||||
}
|
||||
opened, err := c.open(sealed)
|
||||
if err != nil {
|
||||
end("failed")
|
||||
return Changed{}, err
|
||||
}
|
||||
value := strings.TrimRight(string(opened), "\r\n")
|
||||
zero(opened)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
end("empty")
|
||||
return Changed{}, fmt.Errorf("nothing was given: the prompt on %s was answered empty", c.ask.Desk)
|
||||
}
|
||||
changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped)
|
||||
value = ""
|
||||
if err != nil {
|
||||
end("failed")
|
||||
return Changed{}, err
|
||||
}
|
||||
end("given")
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (v *Vault) recordAsk(r AskRecord) error {
|
||||
rolled, err := v.Store.RecordAsk(r)
|
||||
if rolled {
|
||||
v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+
|
||||
"dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the
|
||||
// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own
|
||||
// reach the prompt.
|
||||
func askedByName(caller string) string {
|
||||
first, _, _ := strings.Cut(caller, ",")
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
if b.Len() >= 80 {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
|
||||
r == '-', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(b.String())
|
||||
if name == "" || strings.HasPrefix(name, "-") {
|
||||
return "an unnamed caller"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ---- issued -------------------------------------------------------------------------------------------------
|
||||
|
||||
// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause
|
||||
// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the
|
||||
// provider's machine, with the call's key, and passes on what it answered.
|
||||
func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) {
|
||||
if err := fromTheController(caller, "issued"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
secret, err := ProvisionSecret(provider, provision)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if !namePart.MatchString(module) {
|
||||
return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module)
|
||||
}
|
||||
id, key, err := v.openCall("issued", secret, module+" on "+provider, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one
|
||||
// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is.
|
||||
func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) {
|
||||
if err := fromTheController(caller, "issued"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
c, err := v.takeCall("issued", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
opened, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
value := strings.TrimSpace(string(opened))
|
||||
zero(opened)
|
||||
if value == "" {
|
||||
return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by)
|
||||
}
|
||||
return v.keep(c.secret, value, c.by, caller, OriginIssued)
|
||||
}
|
||||
|
||||
// ---- rotate -------------------------------------------------------------------------------------------------
|
||||
|
||||
// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a
|
||||
// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential,
|
||||
// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is
|
||||
// rotated like a made one (ADR 0228). Who asked is the caller the runtime proved, or the person the controller
|
||||
// names when the controller carries the act — both are kept.
|
||||
func (v *Vault) Rotate(caller, secret, by string) (Changed, error) {
|
||||
if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) {
|
||||
return Changed{}, errors.New("the vault rotates only for a caller the runtime proved")
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
switch {
|
||||
case !held:
|
||||
return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret)
|
||||
case rec.Origin == OriginIssued:
|
||||
return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+
|
||||
"make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now",
|
||||
secret)
|
||||
case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"):
|
||||
return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+
|
||||
"vault (ADR 0349 clause 8): accept its new value instead", secret)
|
||||
}
|
||||
if caller != ControllerCaller || by == "" {
|
||||
by = caller
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade)
|
||||
}
|
||||
|
||||
func someoneOr(by, caller string) string {
|
||||
if strings.TrimSpace(by) == "" {
|
||||
return caller
|
||||
}
|
||||
return by
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package main
|
||||
|
||||
// ADR 0349's tests for what the vault takes in (design 57's V2, issue 675): the vault's `secret-ask` test, the
|
||||
// issuing provider's test with a stand-in provider (plex's own tool moves with the media catalogue, after
|
||||
// release 57), an accepted value opened in the vault alone and never remade, and rotate.
|
||||
//
|
||||
// In each, "the controller" is a test function that holds no private key: it only carries what the desk or the
|
||||
// provider sealed to the key the vault made for the call, as the controller does on the bus.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// sealToCall seals value to a call's key as the desk's prompt and a provider's tool do: the NaCl anonymous box.
|
||||
func sealToCall(t *testing.T, sealTo, value string) string {
|
||||
t.Helper()
|
||||
raw, err := base64.StdEncoding.DecodeString(sealTo)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("seal_to %q is not a key", sealTo)
|
||||
}
|
||||
var key [32]byte
|
||||
copy(key[:], raw)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(value), &key, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sealed)
|
||||
}
|
||||
|
||||
// handedTo opens what `get` hands an allowed asker: the value the vault holds.
|
||||
func handedTo(t *testing.T, v testVault, asker, name string) string {
|
||||
t.Helper()
|
||||
key, _ := seal.GenerateKeyPair()
|
||||
a, err := v.Get(asker, name, key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := seal.Open(a.Sealed, key.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// everything is all the vault said and wrote during a test, as text.
|
||||
func everything(t *testing.T, v testVault, more ...any) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for _, said := range v.bus.raw {
|
||||
b.WriteString(said)
|
||||
}
|
||||
for _, content := range filesUnder(t, v.dir) {
|
||||
b.WriteString(content)
|
||||
}
|
||||
for _, m := range more {
|
||||
raw, _ := json.Marshal(m)
|
||||
b.Write(raw)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const typedAtTheDesk = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||
|
||||
func telegramAsk(by string) AskOf {
|
||||
return AskOf{Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: by}
|
||||
}
|
||||
|
||||
// ---- the vault's secret-ask test (ADR 0349 "How it is checked") ---------------------------------------------
|
||||
|
||||
// An ask is recorded in the vault's store before the prompt opens; its prompt names the asker as the bus named
|
||||
// it; the typed value opens only in the vault; the controller carries ciphertext only.
|
||||
func TestAnAskIsRecordedBeforeThePromptAndTheTypedValueOpensOnlyInTheVault(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}})
|
||||
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code, via the console"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Recorded before any prompt opens: the record holds the ask now, with no end.
|
||||
asks, _ := v.Store.Asks(10)
|
||||
if len(asks) != 1 || asks[0].Event != AskOpened || asks[0].ID != opened.Call || asks[0].AskedBy != "g14/claude-code" ||
|
||||
asks[0].Caller != ControllerCaller || asks[0].Desk != "laptop" || asks[0].Secret != "own/anchor/telegram/telegram-token" {
|
||||
t.Fatalf("the record before the prompt: %+v", asks)
|
||||
}
|
||||
// The prompt names the asker as the bus named it, cut to a name: what the vault answers is what it recorded.
|
||||
if opened.AskedBy != "g14/claude-code" {
|
||||
t.Errorf("the prompt would name %q", opened.AskedBy)
|
||||
}
|
||||
// The desk seals what is typed to the call's key; the controller passes it on.
|
||||
changed, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, typedAtTheDesk+"\n"), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Generation != 1 || changed.By != "g14/claude-code" {
|
||||
t.Errorf("kept as %+v", changed)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); got != typedAtTheDesk {
|
||||
t.Errorf("the vault holds %q, not what was typed with its line ending taken off", got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token")
|
||||
if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("the record says origin %q, by %q, via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia)
|
||||
}
|
||||
asks, _ = v.Store.Asks(10)
|
||||
if len(asks) != 2 || asks[0].Event != AskEnded || asks[0].Outcome != "given" || asks[0].ID != opened.Call {
|
||||
t.Errorf("how it ended: %+v", asks)
|
||||
}
|
||||
if strings.Contains(everything(t, v, opened, changed, asks), typedAtTheDesk) {
|
||||
t.Fatal("the typed value is in something the vault said, answered or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// One open ask per secret, and three an hour (ADR 0277 §24), read from the vault's own record.
|
||||
func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
first, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.AskOpen(ControllerCaller, telegramAsk("ace.claude-code")); err == nil ||
|
||||
!strings.Contains(err.Error(), "still open (asked by g14.claude-code)") {
|
||||
t.Fatalf("a second ask while one is open: %v", err)
|
||||
}
|
||||
// Another secret is not held back by it.
|
||||
other := telegramAsk("ace.claude-code")
|
||||
other.Name = "webhook-secret"
|
||||
if _, err := v.AskOpen(ControllerCaller, other); err != nil {
|
||||
t.Fatalf("another secret's ask was held back: %v", err)
|
||||
}
|
||||
if _, err := v.AskEnd(ControllerCaller, first.Call, "", "dismissed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
o, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatalf("ask %d of the hour: %v", i+2, err)
|
||||
}
|
||||
if _, err := v.AskEnd(ControllerCaller, o.Call, "", "timed-out"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err == nil ||
|
||||
!strings.Contains(err.Error(), "3 times in the last hour") {
|
||||
t.Fatalf("a fourth ask in the hour: %v", err)
|
||||
}
|
||||
asks, _ := v.Store.Asks(50)
|
||||
outcomes := map[string]int{}
|
||||
for _, a := range asks {
|
||||
outcomes[a.Outcome]++
|
||||
}
|
||||
if outcomes["dismissed"] != 1 || outcomes["timed-out"] != 2 {
|
||||
t.Errorf("the record's outcomes: %v", outcomes)
|
||||
}
|
||||
// An ask left open counts as open for SecretAskOpenFor only: a prompt that died does not hold the secret.
|
||||
w := newVault(t, nil)
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
w.Now = func() time.Time { return clock }
|
||||
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock = clock.Add(SecretAskOpenFor + time.Second)
|
||||
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
|
||||
t.Errorf("an ask left open past its time still held the secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the
|
||||
// call's key, or a call that waited too long, keeps nothing.
|
||||
func TestAnAskIsTakenFromTheControllerAloneAndOnlySealedToItsCall(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for _, caller := range []string{"g14.node-tools", "unproven", "", "person.jochen"} {
|
||||
if _, err := v.AskOpen(caller, telegramAsk("x")); err == nil {
|
||||
t.Errorf("%q opened an ask", caller)
|
||||
}
|
||||
}
|
||||
if asks, _ := v.Store.Asks(10); len(asks) != 0 {
|
||||
t.Fatalf("a refused caller filled the record: %+v", asks)
|
||||
}
|
||||
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.AskEnd("g14.node-tools", opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
|
||||
t.Error("another caller ended the ask")
|
||||
}
|
||||
other, _, _ := box.GenerateKey(rand.Reader)
|
||||
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(other[:]), "x"), ""); err == nil ||
|
||||
!strings.Contains(err.Error(), "not sealed to this call") {
|
||||
t.Errorf("a value sealed to another key: %v", err)
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/anchor/telegram/telegram-token"); held {
|
||||
t.Error("a value not sealed to the call was kept")
|
||||
}
|
||||
// The call's key is forgotten once used: the same call cannot be answered twice.
|
||||
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
|
||||
t.Error("a call was answered twice")
|
||||
}
|
||||
asks, _ := v.Store.Asks(1)
|
||||
if asks[0].Outcome != "failed" {
|
||||
t.Errorf("the ask that failed ended %+v", asks[0])
|
||||
}
|
||||
// A call that waited longer than its time is closed.
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
v.Now = func() time.Time { return clock }
|
||||
late, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock = clock.Add(CallOpenFor + time.Second)
|
||||
if _, err := v.IssuedTake(ControllerCaller, late.Call, sealToCall(t, late.SealTo, "x")); err == nil {
|
||||
t.Error("a call that waited too long was taken")
|
||||
}
|
||||
// An empty answer gives nothing and says so.
|
||||
clock = clock.Add(time.Hour)
|
||||
empty, _ := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if _, err := v.AskEnd(ControllerCaller, empty.Call, sealToCall(t, empty.SealTo, " \n"), ""); err == nil {
|
||||
t.Error("an empty answer was kept")
|
||||
}
|
||||
if asks, _ := v.Store.Asks(1); asks[0].Outcome != "empty" {
|
||||
t.Errorf("an empty answer ended %+v", asks[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the issuing provider's test, with a stand-in provider -------------------------------------------------
|
||||
|
||||
// standInProvider is a provider's tool for an `issued-by: software` offer, as plex's must answer (ADR 0305 §2
|
||||
// with the vault in the controller's place): it refuses every caller but the controller — the one caller the
|
||||
// bus lets ask it, carrying the vault's call — and answers only the software's value sealed to the call's key.
|
||||
func standInProvider(software *string) func(caller string, args map[string]any) (map[string]any, error) {
|
||||
return func(caller string, args map[string]any) (map[string]any, error) {
|
||||
if caller != ControllerCaller {
|
||||
return nil, errors.New("this tool answers the controller 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)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(*software), &to, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// issue is the controller's `secret issued` for one provider, carried as the controller carries it: open the
|
||||
// vault's call, ask the provider's tool with the call's key, pass on what it sealed.
|
||||
func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) {
|
||||
t.Helper()
|
||||
opened, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
answer, err := provider(ControllerCaller, map[string]any{"provision": "plex-api", "seal_to": opened.SealTo})
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.IssuedTake(ControllerCaller, opened.Call, answer["sealed"].(string))
|
||||
}
|
||||
|
||||
func TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}})
|
||||
software := "plex-token-from-the-vendor"
|
||||
provider := standInProvider(&software)
|
||||
|
||||
// The provider's tool refuses every caller but the controller, whatever it asks.
|
||||
for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} {
|
||||
if _, err := provider(caller, map[string]any{"provision": "plex-api", "seal_to": "x"}); err == nil {
|
||||
t.Errorf("the provider's tool answered %q", caller)
|
||||
}
|
||||
}
|
||||
// The vault takes the steps from the controller alone.
|
||||
if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil {
|
||||
t.Error("the vault opened an issued call for another caller")
|
||||
}
|
||||
|
||||
changed, err := issue(t, v, provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Name != "provision/anchor/plex-api" || changed.Generation != 1 || changed.By != "plex on anchor" {
|
||||
t.Errorf("kept as %+v", changed)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
||||
t.Errorf("the vault holds %q", got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("provision/anchor/plex-api")
|
||||
if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("the record: origin %q via %q", rec.Origin, rec.ChangedVia)
|
||||
}
|
||||
|
||||
// Nothing rewritten for a value every holder has (ADR 0305 §5): no new generation, no write, nothing said.
|
||||
path := filepath.Join(v.dir, "store", "secrets", "provision%2Fanchor%2Fplex-api.json")
|
||||
before, _ := os.ReadFile(path)
|
||||
said := len(v.bus.raw)
|
||||
again, err := issue(t, v, provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !again.Unchanged || again.Generation != 1 || string(before) != string(after) || len(v.bus.raw) != said {
|
||||
t.Errorf("the same value again rewrote something: %+v", again)
|
||||
}
|
||||
|
||||
// The software changes its value: the next ask keeps it as a new generation, and says so without it.
|
||||
software = "plex-token-renewed"
|
||||
renewed, err := issue(t, v, provider)
|
||||
if err != nil || renewed.Generation != 2 {
|
||||
t.Fatalf("%+v %v", renewed, err)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
||||
t.Errorf("after the change the vault holds %q", got)
|
||||
}
|
||||
|
||||
// A value sealed to any other key is refused, and nothing changes.
|
||||
opened, _ := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
stranger, _, _ := box.GenerateKey(rand.Reader)
|
||||
if _, err := v.IssuedTake(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(stranger[:]), "forged")); err == nil {
|
||||
t.Error("a value not sealed to the call was taken")
|
||||
}
|
||||
|
||||
// The software rotates it, not the mesh (ADR 0305 §9); and no other path puts it into the vault (§10).
|
||||
if _, err := v.Rotate(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil ||
|
||||
!strings.Contains(err.Error(), "software") {
|
||||
t.Errorf("rotate of an issued value: %v", err)
|
||||
}
|
||||
if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil {
|
||||
t.Error("an issued value was taken by accept")
|
||||
}
|
||||
if strings.Contains(everything(t, v), "plex-token") {
|
||||
t.Fatal("the issued value is in something the vault said or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accept and rotate -------------------------------------------------------------------------------------
|
||||
|
||||
// A value given at the controller's terminal is sealed there to the vault's call and opened here alone; an
|
||||
// accepted provision's credential is never remade; a module's own given value rotates as a made one (ADR 0228).
|
||||
func TestAnAcceptedValueOpensInTheVaultAloneAndIsNotRemade(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"provision/novox/postgres", "own/g14/dunst/token"}})
|
||||
if _, err := v.AcceptOpen("g14.claude-code", "provision/novox/postgres", ""); err == nil {
|
||||
t.Error("accept was opened for a caller that is not the controller")
|
||||
}
|
||||
for name, value := range map[string]string{"provision/novox/postgres": "pw-of-last-year", "own/g14/dunst/token": "tok"} {
|
||||
opened, err := v.AcceptOpen(ControllerCaller, name, "jochen")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := v.AcceptTake(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.By != "jochen" {
|
||||
t.Errorf("%s accepted by %q", name, changed.By)
|
||||
}
|
||||
if got := handedTo(t, v, "g14.node-provisioner", name); got != value {
|
||||
t.Errorf("%s holds %q", name, got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read(name)
|
||||
if rec.Origin != OriginAccepted || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("%s: origin %q via %q", name, rec.Origin, rec.ChangedVia)
|
||||
}
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "provision/novox/postgres", "jochen"); err == nil ||
|
||||
!strings.Contains(err.Error(), "not remade") {
|
||||
t.Errorf("rotate of an accepted credential: %v", err)
|
||||
}
|
||||
rotated, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen")
|
||||
if err != nil || rotated.Generation != 2 {
|
||||
t.Fatalf("rotate of a module's own given value: %+v %v", rotated, err)
|
||||
}
|
||||
if got := handedTo(t, v, "g14.node-provisioner", "own/g14/dunst/token"); got == "tok" || len(got) != 40 {
|
||||
t.Errorf("rotated to %q", got)
|
||||
}
|
||||
if strings.Contains(everything(t, v), "pw-of-last-year") {
|
||||
t.Fatal("an accepted value is in something the vault said or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate: refused for a secret not held and for an unproven caller; who asked is the proven caller, or the
|
||||
// person the controller names when it carries the act — both kept.
|
||||
func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err == nil {
|
||||
t.Error("a secret not held was rotated")
|
||||
}
|
||||
if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Rotate("unproven", "own/g14/dunst/token", ""); err == nil {
|
||||
t.Error("an unproven caller rotated")
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/g14/dunst/token")
|
||||
if rec.ChangedBy != "jochen" || rec.ChangedVia != ControllerCaller || rec.Generation != 2 {
|
||||
t.Errorf("by %q via %q generation %d", rec.ChangedBy, rec.ChangedVia, rec.Generation)
|
||||
}
|
||||
// A `by` from any caller but the controller names nobody: the proven caller is who asked.
|
||||
if _, err := v.Rotate("g14.claude-code", "own/g14/dunst/token", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ = v.Store.Read("own/g14/dunst/token")
|
||||
if rec.ChangedBy != "g14.claude-code" || rec.ChangedVia != "" {
|
||||
t.Errorf("by %q via %q", rec.ChangedBy, rec.ChangedVia)
|
||||
}
|
||||
}
|
||||
|
||||
// The names the vault keeps them under are names, never paths.
|
||||
func TestTheNamesOfWhatIsTakenIn(t *testing.T) {
|
||||
if n, err := OwnSecret("g14", "dunst", "token"); err != nil || n != "own/g14/dunst/token" {
|
||||
t.Errorf("%q %v", n, err)
|
||||
}
|
||||
if n, err := ProvisionSecret("novox", "postgres"); err != nil || n != "provision/novox/postgres" {
|
||||
t.Errorf("%q %v", n, err)
|
||||
}
|
||||
for _, bad := range [][3]string{{"..", "x", "y"}, {"g14", "a/b", "y"}, {"g14", "x", ""}, {"G14", "x", "y"}} {
|
||||
if _, err := OwnSecret(bad[0], bad[1], bad[2]); err == nil {
|
||||
t.Errorf("%v was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the verbs as the runtime calls them ------------------------------------------------------------------
|
||||
|
||||
// Each verb takes its caller from the runtime, never from its arguments, and names its secret one way only.
|
||||
func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
env := &Env{Vault: v.Vault}
|
||||
defs := definitions(env)
|
||||
ask := toolNamed(t, defs, Seat+".secret-ask")
|
||||
args := map[string]any{"node": "anchor", "module": "telegram", "name": "telegram-token", "desk": "laptop",
|
||||
"asked_by": "g14.claude-code", "caller": ControllerCaller}
|
||||
if _, err := ask.Run(args); err == nil {
|
||||
t.Error("an ask opened for a caller named in the arguments")
|
||||
}
|
||||
answer, err := ask.runAs(ControllerCaller, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened := answer.(Opened)
|
||||
if _, err := ask.runAs(ControllerCaller, map[string]any{"call": opened.Call, "outcome": "dismissed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := toolNamed(t, defs, "secret_asks").Run(map[string]any{})
|
||||
if err != nil || len(listed.([]AskRecord)) != 2 {
|
||||
t.Fatalf("secret_asks: %v %v", listed, err)
|
||||
}
|
||||
|
||||
accept := toolNamed(t, defs, Seat+".accept")
|
||||
if _, err := accept.runAs(ControllerCaller, map[string]any{"node": "g14", "provision": "postgres"}); err == nil {
|
||||
t.Error("accept took a name both ways")
|
||||
}
|
||||
answer, err = accept.runAs(ControllerCaller, map[string]any{"provider": "novox", "provision": "postgres", "by": "jochen"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := answer.(Opened)
|
||||
if _, err := accept.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "pw")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotate := toolNamed(t, defs, Seat+".rotate")
|
||||
if _, err := rotate.runAs("g14.claude-code", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil {
|
||||
t.Error("a secret not held was rotated through the verb")
|
||||
}
|
||||
|
||||
issued := toolNamed(t, defs, Seat+".issued")
|
||||
answer, err = issued.runAs(ControllerCaller, map[string]any{"provision": "plex-api", "provider": "anchor", "module": "plex"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o = answer.(Opened)
|
||||
if _, err := issued.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "tok")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := &Env{Broken: errors.New("no store")}
|
||||
if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil ||
|
||||
err.Error() != "no store" {
|
||||
t.Errorf("a broken vault said %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ type Record struct {
|
||||
MadeAt time.Time `json:"made-at"`
|
||||
ChangedAt time.Time `json:"changed-at"`
|
||||
ChangedBy string `json:"changed-by"`
|
||||
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
|
||||
// carrying a person's act (design 57's V2). Empty when they are the same.
|
||||
ChangedVia string `json:"changed-via,omitempty"`
|
||||
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
|
||||
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
|
||||
Origin string `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
@@ -58,6 +64,8 @@ type Store struct {
|
||||
|
||||
handedMu sync.Mutex
|
||||
rolled int
|
||||
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
|
||||
asksMu sync.Mutex
|
||||
}
|
||||
|
||||
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
|
||||
@@ -160,6 +168,7 @@ func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
|
||||
if err := s.roll(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.rolled++
|
||||
rolled = true
|
||||
}
|
||||
return rolled, appendSynced(path, append(raw, '\n'))
|
||||
@@ -176,7 +185,6 @@ func (s *Store) roll(path string) error {
|
||||
if err := os.Rename(path, path+".1"); err != nil {
|
||||
return err
|
||||
}
|
||||
s.rolled++
|
||||
return syncDir(s.dir)
|
||||
}
|
||||
|
||||
@@ -334,3 +342,99 @@ func syncDir(dir string) error {
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
// The two lines an ask leaves in the record: when it opened, and how it ended.
|
||||
const (
|
||||
AskOpened = "opened"
|
||||
AskEnded = "ended"
|
||||
)
|
||||
|
||||
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
|
||||
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
|
||||
// Never a value.
|
||||
type AskRecord struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Event string `json:"event"`
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Desk string `json:"desk,omitempty"`
|
||||
AskedBy string `json:"asked_by,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
|
||||
// hand-outs is, and it says whether it was.
|
||||
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := filepath.Join(s.dir, "asks.jsonl")
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
|
||||
if err := s.roll(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
rolled = true
|
||||
}
|
||||
return rolled, appendSynced(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
|
||||
func (s *Store) asks() ([]AskRecord, error) {
|
||||
path := filepath.Join(s.dir, "asks.jsonl")
|
||||
var out []AskRecord
|
||||
for i := HandedKept; i >= 0; i-- {
|
||||
file := path
|
||||
if i > 0 {
|
||||
file = fmt.Sprintf("%s.%d", path, i)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
var r AskRecord
|
||||
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AsksSince is every line of the record of asks at or after since, oldest first.
|
||||
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
|
||||
all, err := s.asks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []AskRecord
|
||||
for _, r := range all {
|
||||
if !r.At.Before(since) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Asks is the latest lines of the record of asks, newest first, at most limit.
|
||||
func (s *Store) Asks(limit int) ([]AskRecord, error) {
|
||||
s.asksMu.Lock()
|
||||
defer s.asksMu.Unlock()
|
||||
all, err := s.asks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []AskRecord{}
|
||||
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
out = append(out, all[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -83,6 +83,100 @@ func definitions(env *Env) []toolDef {
|
||||
key, _ := a["public_key"].(string)
|
||||
return env.Vault.Get(caller, strings.TrimSpace(name), key)
|
||||
}},
|
||||
{Name: Seat + ".accept",
|
||||
Description: "Take a value given at the controller's terminal (ADR 0349 clause 8), in two steps from the " +
|
||||
"controller alone: name the secret (node, module and name for a module's own; provider and provision " +
|
||||
"for a provision's one credential) and get a call and a key made for it; then send the call and the " +
|
||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
||||
Input: intakeInput(map[string]string{"node": "the machine, for a module's own secret",
|
||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
||||
"by": "who gave it at the terminal, as the controller names them"}),
|
||||
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.AcceptTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
secret, err := secretNamed(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.AcceptOpen(caller, secret, arg(a, "by"))
|
||||
}},
|
||||
{Name: Seat + ".secret-ask",
|
||||
Description: "Ask the operator for a module's own secret at a desk (ADR 0349 clause 9, ADR 0277 §22–§27), in " +
|
||||
"two steps from the controller alone, which reaches the desk's prompt: node, module, name, desk and " +
|
||||
"asked_by open the ask — recorded before the prompt opens, one open ask per secret and three an " +
|
||||
"hour — and answer the key the prompt seals to and who asked as the record names them; then the call " +
|
||||
"with what the prompt sealed, or with how it ended (dismissed, timed-out, empty, refused, failed). " +
|
||||
"What is typed opens in the vault alone.",
|
||||
Input: intakeInput(map[string]string{"node": "the machine the secret is for", "module": "the module",
|
||||
"name": "the secret's name in the module", "desk": "the machine whose prompt asks",
|
||||
"asked_by": "who asked the controller, as the bus named its caller",
|
||||
"outcome": "instead of sealed: how the prompt ended — dismissed, timed-out, empty, refused or failed"}),
|
||||
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.AskEnd(caller, call, arg(a, "sealed"), arg(a, "outcome"))
|
||||
}
|
||||
return v.AskOpen(caller, AskOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Desk: arg(a, "desk"), AskedBy: arg(a, "asked_by")})
|
||||
}},
|
||||
{Name: Seat + ".issued",
|
||||
Description: "Take the credential a provider's software issued for a provision (ADR 0349 clause 10, ADR 0305), " +
|
||||
"in two steps from the controller alone, which asks the provider's tool: provision, provider and module " +
|
||||
"open a call and answer the key the tool seals to; then the call with what the tool sealed. Kept as the " +
|
||||
"provision's one credential; the same value again writes nothing.",
|
||||
Input: intakeInput(map[string]string{"provision": "the provision", "provider": "the provider's machine",
|
||||
"module": "the provider's module, whose software issued it"}),
|
||||
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.IssuedTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
return v.IssuedOpen(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module"))
|
||||
}},
|
||||
{Name: Seat + ".rotate",
|
||||
Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " +
|
||||
"and provision for a provision's credential. Refused for a value a provider's software issued, which " +
|
||||
"the software rotates, and for an accepted credential, which the vault does not remake. Never answers " +
|
||||
"the value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"node": str("the machine, for a module's own secret"), "module": str("the module"),
|
||||
"name": str("the secret's name in the module"), "provider": str("the provider's machine"),
|
||||
"provision": str("the provision"),
|
||||
"by": str("who asked, as the controller names them when it carries the act")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := secretNamed(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Rotate(caller, secret, arg(a, "by"))
|
||||
}},
|
||||
{Name: "secret_asks",
|
||||
Description: "The record of asks for a module's own secret at a desk, newest first: who asked as the bus named " +
|
||||
"them, for what, at which desk, when, and how each ended. Never a value.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many lines (default 50)"}},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Store.Asks(limitOf(a))
|
||||
}},
|
||||
{Name: "secret_export",
|
||||
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
|
||||
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
|
||||
@@ -237,7 +331,8 @@ func actInput(set, one bool) map[string]any {
|
||||
}
|
||||
|
||||
// actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the
|
||||
// runtime proved.
|
||||
// runtime proved. `by` is an argument, so it is not proven: when it names somebody other than the caller the
|
||||
// runtime proved, the proven caller is kept beside it in via, so the record says both.
|
||||
func actOf(caller string, a map[string]any) Act {
|
||||
act := Act{}
|
||||
act.Why, _ = a["why"].(string)
|
||||
@@ -246,9 +341,59 @@ func actOf(caller string, a map[string]any) Act {
|
||||
if act.By == "" {
|
||||
act.By = caller
|
||||
}
|
||||
if act.By != caller {
|
||||
proven := "called by " + caller
|
||||
if act.Via == "" {
|
||||
act.Via = proven
|
||||
} else {
|
||||
act.Via += ", " + proven
|
||||
}
|
||||
}
|
||||
return act
|
||||
}
|
||||
|
||||
// ready is the vault, or why there is none.
|
||||
func (e *Env) ready() (*Vault, error) {
|
||||
if e.Vault != nil {
|
||||
return e.Vault, nil
|
||||
}
|
||||
if e.Broken != nil {
|
||||
return nil, e.Broken
|
||||
}
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
|
||||
func arg(a map[string]any, key string) string {
|
||||
s, _ := a[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// secretNamed is the secret a call names: a module's own (node, module, name), or a provision's one credential
|
||||
// (provider, provision) — one or the other.
|
||||
func secretNamed(a map[string]any) (string, error) {
|
||||
own := arg(a, "node") != "" || arg(a, "module") != "" || arg(a, "name") != ""
|
||||
pair := arg(a, "provider") != "" || arg(a, "provision") != ""
|
||||
switch {
|
||||
case own && !pair:
|
||||
return OwnSecret(arg(a, "node"), arg(a, "module"), arg(a, "name"))
|
||||
case pair && !own:
|
||||
return ProvisionSecret(arg(a, "provider"), arg(a, "provision"))
|
||||
}
|
||||
return "", errors.New("name a module's own secret (node, module, name) or a provision's credential " +
|
||||
"(provider, provision), one of the two")
|
||||
}
|
||||
|
||||
// intakeInput is a two-step verb's arguments: what its first step names, then the call and what was sealed.
|
||||
func intakeInput(first map[string]string) map[string]any {
|
||||
props := map[string]any{}
|
||||
for k, d := range first {
|
||||
props[k] = str(d)
|
||||
}
|
||||
props["call"] = str("the second step: the call the first step answered")
|
||||
props["sealed"] = str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")
|
||||
return map[string]any{"type": "object", "properties": props}
|
||||
}
|
||||
|
||||
func stringsOf(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := []string{}
|
||||
|
||||
@@ -35,6 +35,9 @@ type Vault struct {
|
||||
Log func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
|
||||
callsMu sync.Mutex
|
||||
pending map[string]*callKey
|
||||
}
|
||||
|
||||
// Changed is what one change to a secret was. Never its value.
|
||||
@@ -65,6 +68,12 @@ func (v *Vault) log(format string, args ...any) {
|
||||
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
|
||||
// a secret only the vault could open is what ADR 0113 §22 rules out.
|
||||
func (v *Vault) Put(name, value, by string) (Changed, error) {
|
||||
return v.keep(name, value, by, "", OriginMade)
|
||||
}
|
||||
|
||||
// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who
|
||||
// by names (via).
|
||||
func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
@@ -104,9 +113,12 @@ func (v *Vault) Put(name, value, by string) (Changed, error) {
|
||||
return Changed{}, err
|
||||
}
|
||||
now := v.Now().UTC()
|
||||
if via == by {
|
||||
via = ""
|
||||
}
|
||||
rec := Record{Name: name, Generation: 1, AtRest: atRest,
|
||||
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
|
||||
MadeAt: now, ChangedAt: now, ChangedBy: by}
|
||||
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
|
||||
if held {
|
||||
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"secret_holders",
|
||||
"secret_verify",
|
||||
"secret_handed",
|
||||
"secret_asks",
|
||||
"secret_vault_export",
|
||||
"provisioner_retirement",
|
||||
"provisioner_retire_approve",
|
||||
@@ -124,7 +125,11 @@
|
||||
"name": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"get"
|
||||
"get",
|
||||
"accept",
|
||||
"secret-ask",
|
||||
"issued",
|
||||
"rotate"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user