Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
+132
-17
@@ -76,17 +76,19 @@ Two boxes on the mesh today, both over X25519:
|
||||
What the vault uses:
|
||||
|
||||
- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its
|
||||
public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is
|
||||
X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private
|
||||
half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is
|
||||
refused: a raw private key looks the same, and the vault takes no private key.
|
||||
public key as SPKI PEM. A node's provisioner client (V4) asks with a key of its own, made when it starts
|
||||
and kept in its memory alone: it runs under an account of its own and cannot read the node-engine's sealing
|
||||
key, which is root's. A node's sealing key would open an answer too, being X25519 (wrapped as SPKI and PKCS #8,
|
||||
`TestANodesSealingKeyOpensTheAnswer`), for the node-engine's own asks. A raw 32-byte key is refused: a raw
|
||||
private key looks the same, and the vault takes no private key.
|
||||
- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the
|
||||
operator's recovery opens them with the key and the command it already has.
|
||||
- **The at-rest copy is the ADR 0206 box**, to the vault's own key.
|
||||
|
||||
The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last
|
||||
module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the
|
||||
vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine.
|
||||
vault (ADR 0349 §16), the node-engine is to open the ADR 0206 box with the same sealing key; that is not built
|
||||
yet (issue 647 leaves it to its own issue).
|
||||
|
||||
## The seat's verb
|
||||
|
||||
@@ -102,15 +104,107 @@ bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, eac
|
||||
journal and counted in `vault_status`. `secret_handed` reads it.
|
||||
|
||||
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
|
||||
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
|
||||
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
|
||||
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
|
||||
through the journal and this tool only.
|
||||
the export the vault refused, and how often the record was rolled.
|
||||
|
||||
**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account
|
||||
of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither
|
||||
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).
|
||||
**A failing re-seal is a condition** (ADR 0366, issue 738). The vault re-seals its operator copies to the
|
||||
operator key once a minute. `vault_health` answers unhealthy once that has failed on three passes in a row,
|
||||
saying what failed and why, and healthy again after one pass succeeds. The manifest declares it as the
|
||||
health of the vault's own runtime (`runtime-health`), which the controller puts on `mesh-vault.own-runtime`;
|
||||
the node-engine asks it every minute and the controller raises `module.mesh-vault.<machine>.unhealthy` from
|
||||
it, cleared on the first healthy answer. An operator key in the export the vault refuses fails the pass too.
|
||||
How often the record was rolled still reaches a person through the journal and `vault_status` only.
|
||||
|
||||
**Who may ask: a node's provisioner client, for what its plan names** (ADR 0349 §11, design 57 V4, issue 647;
|
||||
`askers.go`). The caller is `provisioner.<node>`, the client's own account (never the tool runner's, which an agent
|
||||
on that machine shares), and the secret is `own/<node>/<module>/<name>` for that same machine, where the
|
||||
controller's assignment `<node>.<module>` (the assignments mirror, which the manifest `reads`) carries the
|
||||
provisioner's plan and the plan's `secrets` name it; or a value a provider's software issued for one holder,
|
||||
`provision/<provider>/<provision>/<node>/<module>`, where that plan's `provisions` name the provision from that
|
||||
provider with a credential's `name` (issue 757; an entry naming only the provider serving a provision, issue 760,
|
||||
hands nothing). The vault cannot read a node's declaration, so the plan the
|
||||
controller carries beside it is what the declaration names, as the vault reads it. A provision's one credential,
|
||||
`provision/<provider>/<provision>`, is handed to the machines the vault made it for (below) while the holder there
|
||||
is handed to the provisioner: the provider's module, or a consumer whose plan still names it from that provider. A
|
||||
module not handed to the provisioner, another machine's secret or holder value, and every other caller are refused,
|
||||
and each refusal is recorded with why. A module's own account (issue 569) is not answered yet.
|
||||
|
||||
**The controller, for the core's own credentials, sealed only to their machine** (ADR 0349 §16, issue 763;
|
||||
`core.go`). The bus and mesh-provisioner need their files before any client runs, so their credentials travel in
|
||||
the declaration sealed by the vault to their machine, which the controller carries and cannot open. The caller
|
||||
`controller` is answered `own/<node>/<module>/<name>` only for a module on the core's fixed list (`nats`,
|
||||
`mesh-provisioner`, `mesh-provisioner-client`, the controller's list too), and only when the key it names is that
|
||||
machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`).
|
||||
A machine whose engine stated none, any other key, any other module and any other name are refused and recorded.
|
||||
|
||||
## 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`.
|
||||
|
||||
**From the controller alone; the vault asks the desk and the provider itself.** The controller alone asks these
|
||||
verbs, as the runtime proves its caller. Each value comes in sealed to 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), and opens 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.
|
||||
|
||||
**The vault's own account** (issue 723). The vault runs as `mesh-vault` in a runtime of its own (`runs-as`, ADR
|
||||
0259 §8), on a bus account of its own (`<node>.mesh-vault`, from its own secret `broker`). The bus lets that
|
||||
account call the desks' prompt (`node-launcher.secret` on any machine) and each issuing provider's tool in its own
|
||||
name, and nothing else outside its seat (mesh-controller's `internal/broker` grants, proven on a real server). So
|
||||
the prompt and the tool know the vault asks, and the controller carries neither the key nor the sealed value. The
|
||||
controller still asks them itself for a module filled the old way, until R1 (issue 682) removes that path; they
|
||||
answer both until then. Everything the vault keeps is its account's: its directories, the export it keeps, the
|
||||
grant files the controller still writes for it.
|
||||
|
||||
- `accept` — two steps: node, module, name (a module's own secret) or provider, provision (a provision's one
|
||||
credential), and `by`; then `call` and `sealed`. 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` — one step: 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 vault then asks the desk's
|
||||
prompt itself, naming who asked as the record has it, with `timeout_seconds` 20. The answer is how it ended
|
||||
(`given`, dismissed, timed-out, empty, refused, failed) and who asked; 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` — one step: provision, provider (machine), module and tool. The vault asks that tool on the provider's
|
||||
machine itself; 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).
|
||||
- `issued` for each holder apart (issue 749; ADR 0349 clause 11, ADR 0365 clause 4: the forge's read token per agent
|
||||
account). The controller names the provision's holders from that provider (`holders`, each a node and a consuming
|
||||
module), and the vault asks the tool with them. The tool answers either one value (`sealed`, kept as above) or one
|
||||
per holder (`holders`: node, module and `sealed` each, all sealed to the call's key). Each is kept as
|
||||
`provision/<provider node>/<provision>/<holder node>/<holder module>`, origin `issued`, and the same value again for
|
||||
a holder writes nothing for it. The answer is taken whole or not at all: a holder not asked about, one named twice, a
|
||||
value not sealed to the call or an empty one keeps nothing. A holder asked about and not answered is said
|
||||
(`not_answered`) and keeps nothing. **`get` hands a holder's value to that holder alone**, its machine's provisioner
|
||||
client (`provisioner.<node>`) or the module under an account of its own (`<node>.<module>`), whatever the rule for
|
||||
who may ask allows; every other caller is refused and the refusal recorded.
|
||||
`TestOnARealBusTwoHoldersOnTwoMachinesEachGetOnlyTheirOwnIssuedValueAndNoneCrossesInTheClear` proves it on a NATS
|
||||
server with a stand-in forge.
|
||||
- `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). From the controller alone,
|
||||
which knows whether the module's secrets are filled from the vault yet, and with a `why`, said in the vault's
|
||||
log. Who asked is the `by` the controller names, beside the controller; both are kept (`changed-by`,
|
||||
`changed-via`). The controller's `rotate <provision>` forwards here for a provider filled the new way (issue
|
||||
767): a shared value is made anew for every holder, never for one consumer alone; for a provider whose consumers
|
||||
keep a login each, `consumer` and `consumer_module` (`--consumer`, `--module`) name the one consumer whose own
|
||||
login is remade, and no other is touched.
|
||||
|
||||
Names in the store: `own/<node>/<module>/<name>`, `provision/<provider node>/<provision>` and, for a value issued for
|
||||
one holder, `provision/<provider node>/<provision>/<holder node>/<holder module>`. 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's provisioner client 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).
|
||||
|
||||
**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheVaultsCallSealedToTheVault` and
|
||||
`TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses` use a stand-in provider that answers the vault's
|
||||
account (and, until R1, the controller) and no other caller, and only sealed to the call's key. plex's own tool
|
||||
answers the controller alone today; it must also answer `<node>.mesh-vault` when it moves with the media catalogue,
|
||||
after release 57.
|
||||
|
||||
## Events
|
||||
|
||||
@@ -149,11 +243,28 @@ and a holder's ledger file removed only through a person's delete, as here. The
|
||||
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
|
||||
file.
|
||||
|
||||
**The credential a provider shares with its consumers** (ADR 0349 clause 8, ADR 0158 §4–§6, design 57 V5, issue
|
||||
677; `pair.go`). One value per provider assignment and provision, `provision/<provider>/<provision>`. Once a minute
|
||||
the vault reads every plan in the assignments mirror; each plan's `provisions` names the provider serving each
|
||||
provision its module requires (issue 760). A consumer binds when an entry appears and unbinds when it goes or its
|
||||
assignment goes; on either, the vault makes one new value for every holder together and says `secret-changed`, and
|
||||
each holder's client asks again. What the move brought in (V3) is adopted when every pair row of a provider and
|
||||
provision shares one generation stamp and one value (ADR 0158's shared form), with its origin, and nothing is
|
||||
remade. Rows without a stamp (ADR 0048, a login per consumer) are never made one value (ADR 0158 §11): `rotate` of
|
||||
the provision remakes each consumer's own apart, and each consumer's machine is handed only its own row,
|
||||
`pair/<provider>/<provision>/<consumer>/<module>[/<local>]` (the provider's machine all of them). A provider the
|
||||
vault holds no row of gets its first value in the form each plan entry names (`credential-form`, from the provider's
|
||||
offer, issue 768): `shared`, one value for every holder; `pair`, a login of its own for each consumer bound; `issued`
|
||||
or `given` are never made. No form named, plans that disagree, or a form it does not know: nothing is made. An accepted or issued value is never remade, only
|
||||
its holders recorded. A provision whose plans name a credential per holder (issue 757) has no shared value. **No
|
||||
value is made that a holder would never get:** while the provider's assignment, or a consumer the move brought in,
|
||||
is still filled the old way (no plan), every make, remake and rotation is held back and the reason said in the
|
||||
log. Who holds each value is kept in the store's `shared/` directory, never a value.
|
||||
|
||||
## 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' provisioner clients ask with V4,
|
||||
for each module handed to the provisioner; providers answer their consumers with B1–B3.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -161,4 +272,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.
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
package main
|
||||
|
||||
// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11).
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Who may ask `get` for which secret (novox/hq ADR 0349 §11, design 57 V4, issue 647).
|
||||
//
|
||||
// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a
|
||||
// secret that node's declaration names; or a module, for a secret its manifest declares, only once its
|
||||
// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are
|
||||
// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the
|
||||
// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface.
|
||||
// The rule is: a node's provisioner client, running as a service under a bus account of its own
|
||||
// (`provisioner.<node>`, design 57 P3), for a secret that node's declaration names; or a module, for a secret its
|
||||
// manifest declares, only once its code runs under a bus account of its own (issue 569). The second does not exist
|
||||
// yet, so the vault answers the first alone (Declared), and refuses every other caller.
|
||||
//
|
||||
// **What a node's declaration names, as the vault can read it.** The vault cannot read a node's declaration, which
|
||||
// the node-engine keeps for root. The controller carries the provisioner's plan of each module it handed to the
|
||||
// provisioner in that module's assignment, `<node>.<module>` of the assignments mirror (mesh-controller internal/
|
||||
// inventory Provisioning), the same value the node's client fills from; its `secrets` are the module's own secrets
|
||||
// the declaration names for its files there. So a client is handed `own/<node>/<module>/<name>` only when the
|
||||
// asker is that node's client, the module is on that node and handed to the provisioner, and its plan names the
|
||||
// secret. A module only pulled in by another has a value there of its own, which names what requires it (issue 765),
|
||||
// and is read the same way. Everything else is refused, and the refusal recorded with why (vault.go).
|
||||
//
|
||||
// **A value a provider's software issued for one holder** (issue 757): the vault keeps it as
|
||||
// `provision/<provider>/<provision>/<node>/<module>` (perholder.go, issue 749). A node's client is handed it when
|
||||
// the asker is that node's client (perholder.go's rule, before this one) and the module's plan there names that
|
||||
// provision from that provider among its `provisions`: the credentials of what it requires that its machine's
|
||||
// client fills from the vault.
|
||||
//
|
||||
// **The provision's one credential** (issue 677): `provision/<provider>/<provision>` is handed to a node's client
|
||||
// when the vault made, adopted or recorded it for a holder on that node (pair.go) and that holder is still there,
|
||||
// handed to the provisioner: the provider's module on its machine, or a consumer whose plan there names the
|
||||
// provision from that provider (without a name, which is a credential per holder).
|
||||
//
|
||||
// **A consumer's own login** (issue 677's note 12, ADR 0048, ADR 0158 §11): `pair/<provider>/<provision>/<consumer>/
|
||||
// <module>[/<local>]` is handed to that consumer's machine while its plan there names the provision from that
|
||||
// provider under that local name, and to the provider's machine while the provider is handed there; never to
|
||||
// another consumer.
|
||||
|
||||
// Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not,
|
||||
// why not.
|
||||
@@ -14,13 +44,12 @@ type Askers interface {
|
||||
May(caller, name string) (bool, string)
|
||||
}
|
||||
|
||||
// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account.
|
||||
// NoAsker allows nobody: what a vault with no rule given answers.
|
||||
type NoAsker struct{}
|
||||
|
||||
// May refuses every caller.
|
||||
func (NoAsker) May(string, string) (bool, string) {
|
||||
return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " +
|
||||
"for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)"
|
||||
return false, "the vault hands a secret to no asker without a rule of who may ask (novox/hq ADR 0349 §11)"
|
||||
}
|
||||
|
||||
// AllowList is a stand-in rule: each caller, and the names it may have.
|
||||
@@ -35,3 +64,112 @@ func (l AllowList) May(caller, name string) (bool, string) {
|
||||
}
|
||||
return false, "not on the list of who may ask for it"
|
||||
}
|
||||
|
||||
// ProvisionerPrefix begins a node's provisioner client's bus user, `provisioner.<node>` (mesh-controller
|
||||
// internal/broker, KindProvisioner).
|
||||
const ProvisionerPrefix = "provisioner."
|
||||
|
||||
// AssignmentsRead is the controller's assignments mirror as this module reads it (its manifest's `reads`).
|
||||
const AssignmentsRead = "mesh-controller.assignments"
|
||||
|
||||
// AssignmentReader reads one value of the assignments mirror, `<node>.<module>`: nil when there is none.
|
||||
type AssignmentReader func(key string) ([]byte, error)
|
||||
|
||||
// Declared is the rule the running vault keeps: a node's provisioner client, for a secret of a module on its node
|
||||
// that the module's plan there names.
|
||||
type Declared struct {
|
||||
Read AssignmentReader
|
||||
// Store is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads.
|
||||
Store *Store
|
||||
}
|
||||
|
||||
// plannedSecrets is as much of an assignment's value as the rule reads: the plan's secrets, by name.
|
||||
type plannedSecrets struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Provisioning *struct {
|
||||
Secrets []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"secrets"`
|
||||
// Provisions are what the module requires, each with its provider (issue 760); one with a name is a
|
||||
// credential the machine's client fills from the vault (issue 757).
|
||||
Provisions []struct {
|
||||
Name string `json:"name"`
|
||||
Provision string `json:"provision"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"provisions"`
|
||||
} `json:"provisioning"`
|
||||
}
|
||||
|
||||
// May allows caller the secret name when caller is `provisioner.<node>` and name is `own/<node>/<module>/<secret>`
|
||||
// that the plan of module on node names, or `provision/<provider>/<provision>/<node>/<module>` whose provision from
|
||||
// that provider the plan names (issue 757).
|
||||
func (d Declared) May(caller, name string) (bool, string) {
|
||||
node, ok := strings.CutPrefix(caller, ProvisionerPrefix)
|
||||
if !ok || !namePart.MatchString(node) {
|
||||
return false, "the vault hands a secret only to a node's provisioner client under its own account " +
|
||||
"(provisioner.<node>); a module's code is answered once it runs under an account of its own (issue 569)"
|
||||
}
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) == 3 && parts[0] == "provision" {
|
||||
if why := sharedRefusal(d.Store, d.Read, node, parts[1], parts[2]); why != "" {
|
||||
return false, why
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
if (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" {
|
||||
if why := pairRowRefusal(d.Read, node, parts); why != "" {
|
||||
return false, why
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
var holderNode, module, secret, provider string
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "own":
|
||||
holderNode, module, secret = parts[1], parts[2], parts[3]
|
||||
case len(parts) == 5 && parts[0] == "provision":
|
||||
provider, secret, holderNode, module = parts[1], parts[2], parts[3], parts[4]
|
||||
default:
|
||||
return false, fmt.Sprintf("a node's provisioner client is handed a module's own secret, own/%s/<module>/<name>, "+
|
||||
"the value a provider issued for one of its modules, provision/<provider>/<provision>/%s/<module>, or "+
|
||||
"a provision's one credential, provision/<provider>/<provision>; %s is none of them", node, node, name)
|
||||
}
|
||||
if holderNode != node {
|
||||
return false, fmt.Sprintf("%s is a secret of machine %s, and %s asks for machine %s alone", name, holderNode,
|
||||
caller, node)
|
||||
}
|
||||
if d.Read == nil {
|
||||
return false, "the vault cannot read the assignments, so it cannot tell what " + node + "'s declaration names"
|
||||
}
|
||||
raw, err := d.Read(node + "." + module)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("the assignment %s.%s could not be read, so what it names is not known: %v", node,
|
||||
module, err)
|
||||
}
|
||||
if raw == nil {
|
||||
return false, fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", module,
|
||||
node)
|
||||
}
|
||||
var a plannedSecrets
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Node != node || a.Module != module {
|
||||
return false, fmt.Sprintf("the assignment %s.%s is not the controller's", node, module)
|
||||
}
|
||||
if a.Provisioning == nil {
|
||||
return false, fmt.Sprintf("%s on %s is not handed to the provisioner, so its secrets reach it in its "+
|
||||
"declaration (novox/hq ADR 0349 §17)", module, node)
|
||||
}
|
||||
if provider != "" {
|
||||
for _, p := range a.Provisioning.Provisions {
|
||||
if p.Name != "" && p.Provision == secret && p.Provider == provider {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%s's plan on %s names no credential of %s from %s", module, node, secret, provider)
|
||||
}
|
||||
for _, s := range a.Provisioning.Secrets {
|
||||
if s.Name == secret {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%s's plan on %s names no secret %s", module, node, secret)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
// The core's own credentials (novox/hq ADR 0349 §16, issue 763). The bus, the node-engine, the controller and
|
||||
// mesh-provisioner need their files before any provisioner client runs, so their credentials travel in the
|
||||
// declaration as values the vault sealed to the node they run on, which the controller carries and cannot open.
|
||||
//
|
||||
// **The controller is answered for a core module's own secret alone, and only sealed to that node's key.** The
|
||||
// controller asks `get` for own/<node>/<core module>/<name> with the key to seal to; the vault seals only when that
|
||||
// key is the sealing key the node's own engine stated in its own name (`node.<node>`, at the move: move.go). The
|
||||
// controller names the key, so that a key it knows to be stale is refused loudly rather than answered with a box
|
||||
// nobody opens; but it can never choose one, so a value it asks for is never sealed to a key it holds.
|
||||
//
|
||||
// **Where the node's key comes from.** Only from the node-engine of that machine, never from the controller's
|
||||
// record of it, which is the asker's own word. A machine whose engine stated none is answered nothing.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoreModules are the core's modules whose own secrets the controller carries sealed by the vault: the same fixed
|
||||
// list as the controller's (ADR 0349 §16). The node-engine and the controller have no own secrets of their own here.
|
||||
var CoreModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
||||
|
||||
// nodeKeyFile is a machine's sealing key as its engine stated it.
|
||||
type nodeKeyFile struct {
|
||||
Node string `json:"node"`
|
||||
Key string `json:"key"`
|
||||
By string `json:"by"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
func (s *Store) nodeKeyPath(node string) string {
|
||||
return filepath.Join(s.dir, "nodes", node+".json")
|
||||
}
|
||||
|
||||
// NodeKey is the sealing key node's engine stated (a raw X25519 public key in base64), and whether there is one.
|
||||
func (s *Store) NodeKey(node string) (string, bool, error) {
|
||||
if !namePart.MatchString(node) {
|
||||
return "", false, fmt.Errorf("%q is not a machine's name", node)
|
||||
}
|
||||
raw, err := os.ReadFile(s.nodeKeyPath(node))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var f nodeKeyFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Node != node || f.Key == "" {
|
||||
return "", false, fmt.Errorf("the record of %s's sealing key is not readable", node)
|
||||
}
|
||||
return f.Key, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) setNodeKey(node, key, by string, at time.Time) error {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "nodes"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.MarshalIndent(nodeKeyFile{Node: node, Key: key, By: by, Since: at}, "", " ")
|
||||
return writeWhole(s.nodeKeyPath(node), append(raw, '\n'))
|
||||
}
|
||||
|
||||
// sealingKey checks a sealing key a node-engine states: one raw X25519 public key in base64, never a PEM, which a
|
||||
// private key could be.
|
||||
func sealingKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if privateKeyIn(key) || strings.Contains(key, "-----") {
|
||||
return "", errors.New("a machine's sealing key is stated as its raw X25519 public key in base64, never a PEM " +
|
||||
"and never a private key; nothing was taken")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return "", errors.New("the sealing key stated is not a raw X25519 public key in base64; nothing was taken")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// recordNodeKey keeps the sealing key node's engine stated, saying so when it replaces another.
|
||||
func (v *Vault) recordNodeKey(node, key, caller string) error {
|
||||
held, had, err := v.Store.NodeKey(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if had && held == key {
|
||||
return nil
|
||||
}
|
||||
if err := v.Store.setNodeKey(node, key, caller, v.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("%s's sealing key could not be recorded: %w", node, err)
|
||||
}
|
||||
if had {
|
||||
v.log("%s's sealing key is now %s, stated by %s (it was %s): the core's credentials are sealed to it from now on",
|
||||
node, key, caller, held)
|
||||
} else {
|
||||
v.log("%s's sealing key is %s, stated by %s", node, key, caller)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coreRefusal says why the controller may not have name sealed to key, or "" when it may: name is a core module's
|
||||
// own secret and key is its machine's sealing key as that machine's engine stated it.
|
||||
func (v *Vault) coreRefusal(name, key string) string {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" {
|
||||
return fmt.Sprintf("the controller is handed a core module's own secret alone, own/<node>/<%s>/<name>, sealed "+
|
||||
"to its machine (ADR 0349 §16); %s is not one", strings.Join(CoreModules, "|"), name)
|
||||
}
|
||||
node, module := parts[1], parts[2]
|
||||
if !slices.Contains(CoreModules, module) {
|
||||
return fmt.Sprintf("%s is not one of the core's modules (%s): its secrets reach its machine through the "+
|
||||
"provisioner or its declaration, never through the controller's ask (ADR 0349 §16)", module,
|
||||
strings.Join(CoreModules, ", "))
|
||||
}
|
||||
stated, held, err := v.Store.NodeKey(node)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s's sealing key cannot be read: %v", node, err)
|
||||
}
|
||||
if !held {
|
||||
return fmt.Sprintf("%s's node-engine has stated no sealing key to the vault (it does so at the move), so "+
|
||||
"there is no key of that machine's own to seal %s to", node, name)
|
||||
}
|
||||
want, err := publicPEM(stated)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s's sealing key as recorded is not usable: %v", node, err)
|
||||
}
|
||||
named, err := askerKey(key)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if named != want {
|
||||
return fmt.Sprintf("%s is sealed only to %s's sealing key as its node-engine stated it, and the key named "+
|
||||
"is another", name, node)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
// Issue 763 (ADR 0349 §16, the lead's decision on issue 755): the core's own credentials travel in the declaration
|
||||
// sealed by the vault to their node, which the controller carries and cannot open. The vault answers the controller
|
||||
// for own/<node>/<core module>/<name> alone, sealed only to the sealing key that node's engine stated in its own name
|
||||
// at the move; any other module, any other key and any other caller are refused.
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// aSealingKey is a machine's sealing key as its node-engine makes it: the public half raw in base64, the form it
|
||||
// states, and the private half as the PKCS #8 PEM the ADR 0206 box opens with.
|
||||
func aSealingKey(t *testing.T) (public, privatePEM string) {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()),
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
p, err := publicPEM(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
const brokerValue = "pv-anchors-provisioner-broker-password-2025"
|
||||
|
||||
func clientsBroker(sealingKey string) MoveOf {
|
||||
return MoveOf{Node: "anchor", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made",
|
||||
Generation: "g-1", SealingKey: sealingKey}
|
||||
}
|
||||
|
||||
// The list is the controller's (mesh-controller's core list, ADR 0349 §16): named here so a change to either is seen.
|
||||
func TestTheCoreIsTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v", CoreModules, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A node-engine states its machine's sealing key in its own name at the move, and only its own machine's.
|
||||
func TestAMoveRecordsTheSealingKeyItsNodeEngineStates(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
pub, _ := aSealingKey(t)
|
||||
if _, err := moveIn(t, v, "node.anchor", clientsBroker(pub), brokerValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, held, err := v.Store.NodeKey("anchor"); err != nil || !held || got != pub {
|
||||
t.Fatalf("anchor's key is %q, %v, %v", got, held, err)
|
||||
}
|
||||
if _, held, _ := v.Store.NodeKey("laptop"); held {
|
||||
t.Error("a key was recorded for a machine that stated none")
|
||||
}
|
||||
// A PEM, which a private key could be, is never taken as a sealing key; nor anything not 32 bytes.
|
||||
_, private := aSealingKey(t)
|
||||
for _, bad := range []string{private, pemOf(t, pub), "c2hvcnQ="} {
|
||||
if _, err := v.MoveOpen("node.anchor", clientsBroker(bad)); err == nil {
|
||||
t.Errorf("the move took %q as a sealing key", bad[:8])
|
||||
}
|
||||
}
|
||||
if got, _, _ := v.Store.NodeKey("anchor"); got != pub {
|
||||
t.Error("a refused key replaced the one stated")
|
||||
}
|
||||
}
|
||||
|
||||
// coreOnABus is a vault holding anchor's core credential and a leaf module's secret, both moved in by anchor's
|
||||
// node-engine, which stated anchor's sealing key; the controller asks it on a real bus. Answers the controller's
|
||||
// ask (the box as answered), and every byte that crossed.
|
||||
func coreOnABus(t *testing.T) (ask func(name, key string) (Answer, error), anchorPub, anchorPriv string,
|
||||
heard func() string) {
|
||||
t.Helper()
|
||||
const conf = `listen: 127.0.0.1:-1
|
||||
authorization {
|
||||
users = [
|
||||
{ user: "novox.mesh-vault", password: "pw", permissions: {
|
||||
publish: { deny: [">"] }
|
||||
subscribe: { allow: ["mesh.seat.mesh-vault.call.get.*"] }
|
||||
allow_responses: true } }
|
||||
{ user: "controller", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.get.controller"] }
|
||||
subscribe: { allow: ["_INBOX.controller.>"] } } }
|
||||
{ user: "tap", password: "pw", permissions: { publish: { deny: [">"] }, subscribe: { allow: [">"] } } }
|
||||
]
|
||||
}
|
||||
`
|
||||
file := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(file, []byte(conf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
url := s.ClientURL()
|
||||
|
||||
var mu sync.Mutex
|
||||
var crossed []string
|
||||
tap := connectAs(t, url, "tap")
|
||||
if _, err := tap.Subscribe(">", func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
crossed = append(crossed, m.Subject+" "+string(m.Data))
|
||||
mu.Unlock()
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = tap.Flush()
|
||||
|
||||
v := newVault(t, Declared{Read: assignments{}.read})
|
||||
anchorPub, anchorPriv = aSealingKey(t)
|
||||
if _, err := moveIn(t, v, "node.anchor", clientsBroker(anchorPub), brokerValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := moveIn(t, v, "node.anchor", superuser(), movedValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vaultConn := connectAs(t, url, "novox.mesh-vault")
|
||||
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
var args struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
answer := busAnswer{}
|
||||
got, err := v.Get(untoken(parts[len(parts)-1]), args.Name, args.PublicKey)
|
||||
if err != nil {
|
||||
answer.Error = err.Error()
|
||||
} else {
|
||||
answer.Result, _ = json.Marshal(got)
|
||||
}
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = m.Respond(raw)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vaultConn.Flush()
|
||||
|
||||
controller := connectAs(t, url, "controller")
|
||||
ask = func(name, key string) (Answer, error) {
|
||||
raw, _ := json.Marshal(map[string]string{"name": name, "public_key": key})
|
||||
msg, err := controller.Request("mesh.seat.mesh-vault.call.get.controller", raw, 3*time.Second)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
var a busAnswer
|
||||
if err := json.Unmarshal(msg.Data, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Error != "" {
|
||||
return Answer{}, errString(a.Error)
|
||||
}
|
||||
var got Answer
|
||||
if err := json.Unmarshal(a.Result, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
heard = func() string {
|
||||
_ = tap.Flush()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return strings.Join(crossed, "\n")
|
||||
}
|
||||
return ask, anchorPub, anchorPriv, heard
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func TestOnARealBusTheControllerGetsACoreCredentialSealedOnlyToItsNodesKey(t *testing.T) {
|
||||
ask, anchorPub, anchorPriv, heard := coreOnABus(t)
|
||||
|
||||
got, err := ask("own/anchor/mesh-provisioner-client/provisioner-broker", pemOf(t, anchorPub))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "own/anchor/mesh-provisioner-client/provisioner-broker" || got.Generation != 1 {
|
||||
t.Errorf("answered %+v", got)
|
||||
}
|
||||
opened, err := seal.Open(got.Sealed, anchorPriv)
|
||||
if err != nil || opened != brokerValue {
|
||||
t.Fatalf("anchor's key opens %q, %v", opened, err)
|
||||
}
|
||||
|
||||
// The controller's own key, or any key but anchor's, is refused: it carries the box and never opens it.
|
||||
mine, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := ask("own/anchor/mesh-provisioner-client/provisioner-broker", mine.PublicKey); err == nil {
|
||||
t.Errorf("the controller was handed anchor's core credential sealed to its own key: %+v", got)
|
||||
} else if !strings.Contains(err.Error(), "sealing key") {
|
||||
t.Errorf("refused for another reason: %v", err)
|
||||
}
|
||||
// A module that is not the core's is refused, though anchor's key is the one named.
|
||||
if got, err := ask("own/anchor/postgres/superuser-password", pemOf(t, anchorPub)); err == nil {
|
||||
t.Errorf("the controller was handed a leaf module's secret: %+v", got)
|
||||
} else if !strings.Contains(err.Error(), "core") {
|
||||
t.Errorf("refused for another reason: %v", err)
|
||||
}
|
||||
// A machine whose engine stated no key: nothing to seal to, so nothing is handed.
|
||||
laptopPub, _ := aSealingKey(t)
|
||||
if _, err := ask("own/laptop/nats/broker", pemOf(t, laptopPub)); err == nil {
|
||||
t.Error("the controller was handed a core credential for a machine whose engine stated no key")
|
||||
}
|
||||
|
||||
all := heard()
|
||||
if !strings.Contains(all, "mesh.seat.mesh-vault.call.get.controller") {
|
||||
t.Fatalf("the listener heard nothing of the asks:\n%s", all)
|
||||
}
|
||||
for _, v := range []string{brokerValue, movedValue} {
|
||||
for _, form := range []string{v, base64.StdEncoding.EncodeToString([]byte(v))} {
|
||||
if strings.Contains(all, form) {
|
||||
t.Fatal("a value crossed the bus in the clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the controller is answered by this rule: a machine's client asking for a core credential, which no plan
|
||||
// names, is still refused by the running rule, and a node-engine is not an asker.
|
||||
func TestTheCoreRuleAnswersTheControllerAlone(t *testing.T) {
|
||||
v := newVault(t, Declared{Read: assignments{}.read})
|
||||
pub, _ := aSealingKey(t)
|
||||
if _, err := moveIn(t, v, "node.anchor", clientsBroker(pub), brokerValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, caller := range []string{"provisioner.anchor", "node.anchor", "anchor.mesh-provisioner-client"} {
|
||||
if _, err := v.Get(caller, "own/anchor/mesh-provisioner-client/provisioner-broker", pemOf(t, pub)); err == nil {
|
||||
t.Errorf("%s was handed a core credential", caller)
|
||||
}
|
||||
}
|
||||
// The controller is answered for nothing else: not a pair credential, not a provision's.
|
||||
for _, name := range []string{"pair/anchor/postgres/anchor/gitea", "provision/anchor/postgres"} {
|
||||
if _, err := v.Get(ControllerCaller, name, pemOf(t, pub)); err == nil {
|
||||
t.Errorf("the controller was handed %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,221 @@
|
||||
package main
|
||||
|
||||
// A provider the vault holds no row of yet gets its first value in the form its offer declares (issue 768): the
|
||||
// controller names the form in each consumer's plan entry (`credential-form`, from the provider's manifest), and
|
||||
// the vault reads it strictly. A provider the vault already holds rows of is unchanged by it.
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestANewSharedProviderGetsOneFirstValueForEveryHolder(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+shared"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairMade || o.Generation != 1 {
|
||||
t.Fatalf("a new shared provider: %+v", o)
|
||||
}
|
||||
value, _, err := asks(v, "provisioner.anchor", standIn)
|
||||
if err != nil || value == "" {
|
||||
t.Fatalf("the provider's machine was refused its first value: %v", err)
|
||||
}
|
||||
for _, c := range []string{"provisioner.g14", "provisioner.ace"} {
|
||||
if got, _, err := asks(v, c, standIn); err != nil || got != value {
|
||||
t.Fatalf("%s got %q (%v), not the one value", c, got, err)
|
||||
}
|
||||
}
|
||||
if changedSaid(t, v, standIn, value) != 1 {
|
||||
t.Fatal("secret-changed was not said once for the first value")
|
||||
}
|
||||
rows, _ := v.pairRows()
|
||||
if len(rows[pairKey{"anchor", "stand-in-api"}]) != 0 {
|
||||
t.Fatal("a login per consumer was made for a provider whose offer shares one credential")
|
||||
}
|
||||
if o := pass(t, v, standIn); o.Outcome != PairUnchanged {
|
||||
t.Fatalf("the pass after the first value: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewPerLoginProviderGetsAFirstLoginOfItsOwnForEachConsumer(t *testing.T) {
|
||||
const pg = "provision/novox/postgres-database"
|
||||
mirror := assignments{
|
||||
"novox.postgres": handedWith("novox", "postgres"),
|
||||
"g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres+pair"),
|
||||
"ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres#stats+pair"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, pg); o.Outcome != PairMade {
|
||||
t.Fatalf("a new per-login provider: %+v", o)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(pg); held {
|
||||
t.Fatal("one shared value was made for a provider whose consumers each keep a login of their own")
|
||||
}
|
||||
gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami/stats"
|
||||
ofGitea, _, err := asks(v, "provisioner.g14", gitea)
|
||||
if err != nil || ofGitea == "" {
|
||||
t.Fatalf("gitea's machine was refused its first login: %v", err)
|
||||
}
|
||||
ofUmami, _, err := asks(v, "provisioner.ace", umami)
|
||||
if err != nil || ofUmami == "" {
|
||||
t.Fatalf("umami's machine was refused its first login: %v", err)
|
||||
}
|
||||
if ofGitea == ofUmami {
|
||||
t.Fatal("two consumers of a per-login provider were given one value")
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.g14", umami); err == nil {
|
||||
t.Fatal("gitea's machine was handed umami's login")
|
||||
}
|
||||
for name, want := range map[string]string{gitea: ofGitea, umami: ofUmami} {
|
||||
if got, _, err := asks(v, "provisioner.novox", name); err != nil || got != want {
|
||||
t.Fatalf("the provider's machine got %q for %s (%v)", got, name, err)
|
||||
}
|
||||
}
|
||||
// The logins are rows now: the next pass remakes none of them.
|
||||
if o := pass(t, v, pg); o.Outcome != PairPerPair {
|
||||
t.Fatalf("the pass after the first logins: %+v", o)
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", gitea); got != ofGitea || gen != 1 {
|
||||
t.Fatal("a consumer's login was remade by the next pass")
|
||||
}
|
||||
}
|
||||
|
||||
// A provider the vault holds rows of keeps what it had, whatever form the plans name.
|
||||
func TestAProviderWithRowsIsUnchangedByTheFormItsPlansName(t *testing.T) {
|
||||
t.Run("stamped rows", func(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "the-value-they-share", "stamp-1", OriginMade)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairAdopted {
|
||||
t.Fatalf("stamped rows: %+v", o)
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 {
|
||||
t.Fatal("the adopted value is not the move's")
|
||||
}
|
||||
})
|
||||
t.Run("logins of their own", func(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+shared"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+pair"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade)
|
||||
movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairPerPair {
|
||||
t.Fatalf("unstamped rows: %+v", o)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatal("a shared value was made for a provider whose rows are logins of their own")
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", "pair/anchor/stand-in-api/g14/app"); got != "login-of-app" ||
|
||||
gen != 1 {
|
||||
t.Fatal("a consumer's own login changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A form the vault does not make (issued by the provider's software, or given by a person), a form it does not know,
|
||||
// or plans that disagree: nothing is made, and the pass says why. The rule that no holder is left behind still holds.
|
||||
func TestTheVaultMakesNothingForAFormItDoesNotMakeOrCannotTrust(t *testing.T) {
|
||||
for form, said := range map[string]string{
|
||||
"issued": "issued by the provider's software",
|
||||
"given": "given by a person",
|
||||
"one-for-all": "which is not a credential form",
|
||||
"shared|pair": "disagree",
|
||||
"shared|": "no plan says whether",
|
||||
"shared@unmoved": "stand-in on anchor",
|
||||
} {
|
||||
t.Run(form, func(t *testing.T) {
|
||||
provider := handedWith("anchor", "stand-in")
|
||||
first, second := form, form
|
||||
if a, b, ok := strings.Cut(form, "|"); ok {
|
||||
first, second = a, b
|
||||
}
|
||||
if a, _, ok := strings.Cut(form, "@"); ok {
|
||||
first, second, provider = a, a, unmoved("anchor", "stand-in")
|
||||
}
|
||||
entry := func(f string) string {
|
||||
if f == "" {
|
||||
return "stand-in-api@anchor/stand-in"
|
||||
}
|
||||
return "stand-in-api@anchor/stand-in+" + f
|
||||
}
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": provider,
|
||||
"g14.app": handedWith("g14", "app", entry(first)),
|
||||
"ace.other": handedWith("ace", "other", entry(second)),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, said) {
|
||||
t.Fatalf("form %s: %+v", form, o)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatalf("form %s: a shared value was made", form)
|
||||
}
|
||||
if rows, _ := v.pairRows(); len(rows[pairKey{"anchor", "stand-in-api"}]) != 0 {
|
||||
t.Fatalf("form %s: a login was made", form)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer that binds after the move to a per-login provider the vault already holds rows of gets a login of its
|
||||
// own (issue 771): the rows that exist are never touched, and `secret-changed` is said for the new login only.
|
||||
func TestANewConsumerOfAPerLoginProviderWithRowsGetsALoginOfItsOwn(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in+pair"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in+pair"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade)
|
||||
movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade)
|
||||
app, other := "pair/anchor/stand-in-api/g14/app", "pair/anchor/stand-in-api/ace/other"
|
||||
third := "pair/anchor/stand-in-api/novox/third"
|
||||
before, said := map[string]Record{}, map[string]int{}
|
||||
for _, n := range []string{app, other} {
|
||||
r, _, _ := v.Store.Read(n)
|
||||
before[n], said[n] = r, changedSaid(t, v, n, "")
|
||||
}
|
||||
|
||||
mirror["novox.third"] = handedWith("novox", "third", "stand-in-api@anchor/stand-in+pair")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairMade {
|
||||
t.Fatalf("a third consumer binding: %+v", o)
|
||||
}
|
||||
got, _, err := asks(v, "provisioner.novox", third)
|
||||
if err != nil || got == "" || got == "login-of-app" || got == "login-of-other" {
|
||||
t.Fatalf("the third consumer has no login of its own: %q (%v)", got, err)
|
||||
}
|
||||
if got, _, err := asks(v, "provisioner.anchor", third); err != nil || got == "" {
|
||||
t.Fatalf("the provider's machine was refused the new login: %v", err)
|
||||
}
|
||||
for n, was := range before {
|
||||
now, _, _ := v.Store.Read(n)
|
||||
if !reflect.DeepEqual(now, was) {
|
||||
t.Fatalf("%s was touched: %+v, was %+v", n, now, was)
|
||||
}
|
||||
if changedSaid(t, v, n, "") != said[n] {
|
||||
t.Fatalf("secret-changed was said for %s", n)
|
||||
}
|
||||
}
|
||||
if changedSaid(t, v, third, "") != 1 {
|
||||
t.Fatal("secret-changed was not said once for the new login")
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatal("a shared value was made")
|
||||
}
|
||||
if o := pass(t, v, standIn); o.Outcome != PairPerPair {
|
||||
t.Fatalf("the pass after the new login: %+v", o)
|
||||
}
|
||||
if got2, _, _ := asks(v, "provisioner.novox", third); got2 != got {
|
||||
t.Fatal("the new login was remade by the next pass")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
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.** Each call opens with
|
||||
// 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) and ends with what was sealed to that key, which the vault opens
|
||||
// with the private half: it never leaves this process and is forgotten once used or after two minutes.
|
||||
//
|
||||
// **The vault asks the desk and the provider itself** (issue 723). The desk's prompt and a provider's issuing tool
|
||||
// are asked by the vault, in its own name, under the bus account its own runtime runs on (ADR 0259 §8's runs-as):
|
||||
// the bus lets that account alone call them naming the vault, so the prompt and the tool know the vault asks, and
|
||||
// what they seal to the call's key opens here and nowhere else (ADR 0349 clauses 9 and 10, ADR 0277 §23). The
|
||||
// controller asks `secret-ask` and `issued` once, carrying no key and no sealed value; it reaches the desk and the
|
||||
// tool itself only for a module still filled the old way, until R1 (issue 682). Only `accept` stays two steps:
|
||||
// the value is given at the controller's terminal, which seals it there to the call's key.
|
||||
//
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"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
|
||||
// origin is what an `accept` call keeps the value as: accepted, or issued for a covered account (issue 787).
|
||||
origin string
|
||||
// ask is the ask's record for `secret-ask`, which the call's id is.
|
||||
ask *AskRecord
|
||||
// move is what `move-in` takes: the row of the controller's store (move.go).
|
||||
move *MoveOf
|
||||
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 asks the vault to take a value in, 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{} }()
|
||||
return c.openOne(sealed)
|
||||
}
|
||||
|
||||
// openOne opens one value sealed to the call's key and keeps the key, for a call that takes several (one per
|
||||
// holder, perholder.go); the caller forgets the key.
|
||||
func (c *callKey) openOne(sealed string) ([]byte, error) {
|
||||
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 -------------------------------------------------------------------------------------------------
|
||||
|
||||
// CoveredSecrets are the names, in a module's own secrets, of the bus accounts the controller mints for a core module
|
||||
// or a module marked moved and hands to the vault (novox/hq ADR 0374, issue 787): the module's account on the bus
|
||||
// (`broker`) and, for the machine's provisioner client, its own (`provisioner-broker`).
|
||||
var CoveredSecrets = []string{"broker", "provisioner-broker"}
|
||||
|
||||
// Covered says whether secret is a covered account: a module's own secret named one of CoveredSecrets. The vault
|
||||
// cannot tell core or moved from a module's manifest; the controller, which can, is the only caller that may hand
|
||||
// one in, and the name keeps what it hands to these.
|
||||
func Covered(secret string) bool {
|
||||
parts := strings.Split(secret, "/")
|
||||
return len(parts) == 4 && parts[0] == "own" && slices.Contains(CoveredSecrets, parts[3])
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// **With origin issued it is a covered account the controller minted** (ADR 0374, issue 787): the controller alone
|
||||
// hands it, for a covered secret alone, and a re-issue replaces the value in place. Without origin it is a value
|
||||
// given at the terminal, as before.
|
||||
func (v *Vault) AcceptOpen(caller, secret, by, origin string) (Opened, error) {
|
||||
if err := fromTheController(caller, "accept"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
kept := OriginAccepted
|
||||
switch origin {
|
||||
case "":
|
||||
if rec, held, err := v.Store.Read(secret); err != nil {
|
||||
return Opened{}, err
|
||||
} else if held && rec.Origin == OriginIssued && !Covered(secret) {
|
||||
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)
|
||||
}
|
||||
case OriginIssued:
|
||||
if !Covered(secret) {
|
||||
return Opened{}, fmt.Errorf("%s is not a bus account the controller mints for a core or moved module "+
|
||||
"(%s), so it is not handed with origin issued; nothing is taken", secret, strings.Join(CoveredSecrets, ", "))
|
||||
}
|
||||
kept = OriginIssued
|
||||
default:
|
||||
return Opened{}, fmt.Errorf("the origin %q is not one accept takes (issued, or none for a value given at the "+
|
||||
"terminal); nothing is taken", origin)
|
||||
}
|
||||
id, key, err := v.openCall("accept", secret, by, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
if c := v.pending[id]; c != nil {
|
||||
c.origin = kept
|
||||
}
|
||||
v.callsMu.Unlock()
|
||||
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")
|
||||
}
|
||||
origin := c.origin
|
||||
if origin == "" {
|
||||
origin = OriginAccepted
|
||||
}
|
||||
return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, origin)
|
||||
}
|
||||
|
||||
// ---- 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) {
|
||||
v.Store.asksMu.Lock()
|
||||
defer v.Store.asksMu.Unlock()
|
||||
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("%w: the prompt on %s was answered empty", ErrNothingGiven, 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
|
||||
}
|
||||
|
||||
// recordAsk appends one line to the record of asks. The caller holds Store.asksMu, so no two writers roll the
|
||||
// record at once and no ask is counted against its bounds while another line is half written.
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- the vault's own calls (issue 723) -----------------------------------------------------------------------
|
||||
|
||||
// DeskPromptFor is how long the desk's prompt waits for the operator when the vault asks: under the thirty seconds
|
||||
// a call through the runtime waits, and under the controller's wait for `secret-ask`.
|
||||
const DeskPromptFor = 20
|
||||
|
||||
// DeskPrompt is the desk's secret prompt as the vault asks it: the seat's verb on the desk's machine.
|
||||
func DeskPrompt(desk string) string { return "seat:node-launcher.secret@" + desk }
|
||||
|
||||
// Asked is how an ask at a desk ended: what was kept, or why nothing was. Never the value.
|
||||
type Asked struct {
|
||||
Secret string `json:"secret"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
AskedBy string `json:"asked_by"`
|
||||
// Outcome is given, dismissed, timed-out, empty, refused or failed.
|
||||
Outcome string `json:"outcome"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
}
|
||||
|
||||
// AskAtDesk is `secret-ask` (ADR 0349 clause 9, ADR 0277 §22–§27): asked by the controller alone, which names who
|
||||
// asked it. The ask is recorded and its bounds held before any prompt opens; the vault asks the desk's prompt
|
||||
// itself, in its own name, naming the asker as recorded and giving a key made for this one call; what is typed
|
||||
// comes back sealed to that key and opens here. How the prompt ended is the answer and the record's.
|
||||
func (v *Vault) AskAtDesk(caller string, a AskOf) (Asked, error) {
|
||||
if v.Ask == nil {
|
||||
return Asked{}, errors.New("nobody was asked to type anything: this vault reaches no desk")
|
||||
}
|
||||
opened, err := v.AskOpen(caller, a)
|
||||
if err != nil {
|
||||
return Asked{}, err
|
||||
}
|
||||
out := Asked{Secret: opened.Secret, AskedBy: opened.AskedBy}
|
||||
end := func(outcome string) (Asked, error) {
|
||||
out.Outcome = outcome
|
||||
if _, err := v.AskEnd(caller, opened.Call, "", outcome); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
raw, err := v.Ask(DeskPrompt(a.Desk), map[string]any{"module": a.Module, "secret": a.Name, "node": a.Node,
|
||||
"asked_by": opened.AskedBy, "seal_to": opened.SealTo, "timeout_seconds": DeskPromptFor})
|
||||
if err != nil {
|
||||
if _, ended := end("refused"); ended != nil {
|
||||
v.log("%v", ended)
|
||||
}
|
||||
return Asked{}, fmt.Errorf("the desk on %s could not be asked: %w", a.Desk, err)
|
||||
}
|
||||
var answer struct {
|
||||
Sealed string `json:"sealed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return end("failed")
|
||||
}
|
||||
switch {
|
||||
case answer.TimedOut:
|
||||
return end("timed-out")
|
||||
case answer.Cancelled:
|
||||
return end("dismissed")
|
||||
case answer.Sealed == "":
|
||||
return end("failed")
|
||||
}
|
||||
changed, err := v.AskEnd(caller, opened.Call, answer.Sealed, "")
|
||||
if err != nil {
|
||||
// AskEnd recorded how it ended; an empty answer is the operator's, not a failure.
|
||||
if errors.Is(err, ErrNothingGiven) {
|
||||
out.Outcome = "empty"
|
||||
return out, nil
|
||||
}
|
||||
return Asked{}, err
|
||||
}
|
||||
out.Outcome, out.Generation, out.Unchanged = "given", changed.Generation, changed.Unchanged
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrNothingGiven is a prompt answered empty: the operator's answer, not a failure.
|
||||
var ErrNothingGiven = errors.New("nothing was given")
|
||||
|
||||
// Issued is what the vault kept of a value a provider's software issued. Never the value. For a tool that answers a
|
||||
// value per holder (perholder.go), Secret is the provision's name, Holders what was kept for each and NotAnswered
|
||||
// the holders asked about that the tool gave nothing for; Unchanged is then whether no holder's value changed.
|
||||
type Issued struct {
|
||||
Secret string `json:"secret"`
|
||||
Generation int `json:"generation"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
Holders []IssuedHolder `json:"holders,omitempty"`
|
||||
NotAnswered []Holder `json:"not_answered,omitempty"`
|
||||
}
|
||||
|
||||
// TakeIssued is `issued` (ADR 0349 clause 10, ADR 0305 with the vault in the controller's place): asked by the
|
||||
// controller alone, naming the provision and the provider's machine, module and tool. The vault asks that tool
|
||||
// itself, in its own name, with a key made for this one call; the tool answers its software's value sealed to that
|
||||
// key, which opens here and is kept as the provision's one credential. The bus lets the vault's account call no
|
||||
// tool but an issuing one, so a tool named here that is not one is refused there.
|
||||
func (v *Vault) TakeIssued(caller, provision, provider, module, tool string) (Issued, error) {
|
||||
return v.TakeIssuedFor(caller, provision, provider, module, tool, nil)
|
||||
}
|
||||
|
||||
// TakeIssuedFor is TakeIssued naming the provision's holders from that provider, as the controller knows them (issue
|
||||
// 749): the tool is asked with them, and may answer one value for the provision (`sealed`, kept as its one
|
||||
// credential, as before) or one per holder (`holders`: node, module and sealed each, kept as that holder's own and
|
||||
// handed to it alone, perholder.go). Never both.
|
||||
func (v *Vault) TakeIssuedFor(caller, provision, provider, module, tool string, holders []Holder) (Issued, error) {
|
||||
if v.Ask == nil {
|
||||
return Issued{}, errors.New("nothing was taken: this vault reaches no provider")
|
||||
}
|
||||
if !toolName.MatchString(tool) {
|
||||
return Issued{}, fmt.Errorf("the provider's tool %q is not a tool's name", tool)
|
||||
}
|
||||
if err := checkHolders(holders); err != nil {
|
||||
return Issued{}, fmt.Errorf("nothing was asked: %w", err)
|
||||
}
|
||||
opened, err := v.IssuedOpen(caller, provision, provider, module)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
args := map[string]any{"provision": provision, "seal_to": opened.SealTo}
|
||||
if len(holders) > 0 {
|
||||
args["holders"] = holders
|
||||
}
|
||||
raw, err := v.Ask(module+"."+tool+"@"+provider, args)
|
||||
if err != nil {
|
||||
v.dropCall("issued", opened.Call)
|
||||
return Issued{}, fmt.Errorf("its tool %s could not be asked: %w", tool, err)
|
||||
}
|
||||
var given struct {
|
||||
Sealed string `json:"sealed"`
|
||||
Holders []sealedFor `json:"holders"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &given); err != nil || (given.Sealed == "") == (len(given.Holders) == 0) {
|
||||
v.dropCall("issued", opened.Call)
|
||||
return Issued{}, fmt.Errorf("its tool %s answered no sealed value, or both one value and a value per holder", tool)
|
||||
}
|
||||
if len(given.Holders) > 0 {
|
||||
return v.takePerHolder(caller, opened.Call, provider, provision, holders, given.Holders)
|
||||
}
|
||||
changed, err := v.IssuedTake(caller, opened.Call, given.Sealed)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
return Issued{Secret: changed.Name, Generation: changed.Generation, Unchanged: changed.Unchanged}, nil
|
||||
}
|
||||
|
||||
var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
|
||||
// dropCall forgets a call's key that will not be answered.
|
||||
func (v *Vault) dropCall(verb, id string) {
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
if c, ok := v.pending[id]; ok && c.verb == verb {
|
||||
c.private = [32]byte{}
|
||||
delete(v.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 person the controller names, beside the controller that
|
||||
// carried the act — both are kept — and why is said in the vault's log.
|
||||
//
|
||||
// **From the controller alone, and with a why** (the review of issue 675): only the controller knows whether a
|
||||
// module's secrets are filled from the vault yet (ADR 0349 clause 17); a rotation asked of the vault for a module
|
||||
// still filled the old way would change a value its machine never reads, and drift from the one the controller
|
||||
// holds. And a rotation is an act by hand, which says why.
|
||||
func (v *Vault) Rotate(caller, secret, by, why string) (Changed, error) {
|
||||
return v.RotateFor(caller, secret, Narrow{}, by, why)
|
||||
}
|
||||
|
||||
// Narrow is the one consumer a rotation of a per-login provider's credential is for (issue 767): the controller's
|
||||
// `rotate <provision> --consumer <machine> --module <module>`, either or both. Empty is every consumer.
|
||||
type Narrow struct {
|
||||
Consumer string `json:"consumer,omitempty"`
|
||||
ConsumerModule string `json:"consumer_module,omitempty"`
|
||||
}
|
||||
|
||||
func (n Narrow) empty() bool { return n.Consumer == "" && n.ConsumerModule == "" }
|
||||
|
||||
func (n Narrow) holds(r pairRow) bool {
|
||||
return (n.Consumer == "" || r.consumer == n.Consumer) && (n.ConsumerModule == "" || r.module == n.ConsumerModule)
|
||||
}
|
||||
|
||||
func (n Narrow) String() string {
|
||||
switch {
|
||||
case n.Consumer == "":
|
||||
return n.ConsumerModule
|
||||
case n.ConsumerModule == "":
|
||||
return n.Consumer
|
||||
}
|
||||
return n.ConsumerModule + " on " + n.Consumer
|
||||
}
|
||||
|
||||
// RotateFor is Rotate, narrowed to one consumer's own login of a per-login provider (ADR 0158 §11, issue 767). A
|
||||
// value shared by every holder (ADR 0158) is one value, so it is never rotated for one consumer alone: refused, and
|
||||
// nothing changes. A module's own secret names no consumer.
|
||||
func (v *Vault) RotateFor(caller, secret string, one Narrow, by, why string) (Changed, error) {
|
||||
if caller != ControllerCaller {
|
||||
return Changed{}, fmt.Errorf("the vault rotates for the controller alone, which knows whether the module's "+
|
||||
"secrets are filled from the vault yet; %s is not the controller: `secret rotate` at the controller", caller)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return Changed{}, errors.New("not rotated: a rotation says why it was asked (`why`)")
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
parts := strings.Split(secret, "/")
|
||||
shared := len(parts) == 3 && parts[0] == "provision"
|
||||
switch {
|
||||
case !one.empty() && !shared:
|
||||
return Changed{}, fmt.Errorf("not rotated: %s is not a provision's credential, and only a provider's "+
|
||||
"consumers are named in a rotation", secret)
|
||||
case !one.empty() && held:
|
||||
return Changed{}, fmt.Errorf("not rotated: %s is one value every holder shares (ADR 0158), so it cannot be "+
|
||||
"rotated for %s alone; rotated without a consumer, it is made anew for every holder", secret, one)
|
||||
case !held && shared:
|
||||
// A provider whose consumers keep logins of their own: each one's own is remade, never one shared value.
|
||||
changed, err := v.rotatePerPair(secret, one, someoneOr(by, caller))
|
||||
if err == nil {
|
||||
v.log("rotated each consumer's own login of %s (%d), asked by %s via %s: %s", secret, len(changed.Each),
|
||||
someoneOr(by, caller), caller, strings.TrimSpace(why))
|
||||
}
|
||||
return changed, err
|
||||
case !held:
|
||||
return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret)
|
||||
case Covered(secret):
|
||||
return Changed{}, fmt.Errorf("not rotated: %s is a bus account the controller mints and hands here (ADR 0374 "+
|
||||
"clause 4): only a password whose hash the bus's user list holds can log in, so the vault makes no new "+
|
||||
"value for it; `module issue` again hands a new one and replaces this in place", 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 shared || (parts[0] == "pair" && (len(parts) == 5 || len(parts) == 6)) {
|
||||
if why := v.rotationHeldBack(strings.Join(append([]string{"provision"}, parts[1:3]...), "/")); why != "" {
|
||||
return Changed{}, fmt.Errorf("not rotated: %s", why)
|
||||
}
|
||||
}
|
||||
if by == "" {
|
||||
by = caller
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
changed, err := v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade)
|
||||
if err == nil && shared {
|
||||
if sh, recorded, rerr := v.Store.ReadShared(secret); rerr == nil && recorded {
|
||||
sh.Generation, sh.At = changed.Generation, v.Now().UTC()
|
||||
if werr := v.Store.WriteShared(sh); werr != nil {
|
||||
v.log("%s was rotated, but the record of who holds it keeps the earlier generation: %v", secret, werr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
v.log("rotated %s (generation %d), asked by %s via %s: %s", secret, changed.Generation, by, caller,
|
||||
strings.TrimSpace(why))
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func someoneOr(by, caller string) string {
|
||||
if strings.TrimSpace(by) == "" {
|
||||
return caller
|
||||
}
|
||||
return by
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
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.
|
||||
//
|
||||
// The two-step tests below drive the steps the vault's own calls are made of (owncalls_test.go, issue 723): "the
|
||||
// controller" there is a test function that holds no private key, standing where the vault's call to the desk or the
|
||||
// provider stands, and carries only what was sealed to the key the vault made for the call.
|
||||
|
||||
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 answers the vault's own account, which asks it itself (issue 723),
|
||||
// and the controller, whose path for a provider filled the old way stays until R1 (issue 682); it refuses every
|
||||
// other caller, 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 && !strings.HasSuffix(caller, ".mesh-vault") {
|
||||
return nil, errors.New("this tool answers the vault and, until R1, 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: one ask of the vault, which asks the provider's
|
||||
// tool itself, in its own name.
|
||||
func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) {
|
||||
t.Helper()
|
||||
v.Ask = (&aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){
|
||||
"plex.plex_issued_credential@anchor": provider}}).ask
|
||||
got, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential")
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
rec, _, _ := v.Store.Read(got.Secret)
|
||||
return Changed{Name: got.Secret, Generation: got.Generation, By: rec.ChangedBy, Unchanged: got.Unchanged}, nil
|
||||
}
|
||||
|
||||
func TestAnIssuingProviderAnswersOnlyTheVaultsCallSealedToTheVault(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 vault's account and 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", "leaked"); 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", "leaked"); 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", "leaked")
|
||||
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", "leaked"); 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", "", "leaked"); err == nil {
|
||||
t.Error("an unproven caller rotated")
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", "leaked"); 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate is the controller's alone, and says why (the review of issue 675): only the controller knows whether a
|
||||
// module is filled from the vault yet, so a rotation asked by any other caller — a proven one included — would
|
||||
// drift from the value the controller holds; and a rotation without a reason is refused. Neither changes anything.
|
||||
func TestRotateIsTheControllersAloneAndSaysWhy(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, caller := range []string{"g14.claude-code", "g14.node-provisioner", "unproven", ""} {
|
||||
if _, err := v.Rotate(caller, "own/g14/dunst/token", "jochen", "leaked"); err == nil ||
|
||||
!strings.Contains(err.Error(), "controller alone") {
|
||||
t.Errorf("%q rotated: %v", caller, err)
|
||||
}
|
||||
}
|
||||
for _, why := range []string{"", " "} {
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen", why); err == nil ||
|
||||
!strings.Contains(err.Error(), "why") {
|
||||
t.Errorf("a rotation without a why (%q): %v", why, err)
|
||||
}
|
||||
}
|
||||
if rec, _, _ := v.Store.Read("own/g14/dunst/token"); rec.Generation != 1 {
|
||||
t.Fatalf("a refused rotation changed the secret: generation %d", rec.Generation)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
var asked []map[string]any
|
||||
software := "tok"
|
||||
v.Ask = (&aMesh{desk: aDesk("typed", &asked), tools: map[string]func(string, map[string]any) (map[string]any, error){
|
||||
"plex.plex_issued_credential@anchor": standInProvider(&software)}}).ask
|
||||
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 || answer.(Asked).Outcome != "given" || len(asked) != 1 {
|
||||
t.Fatalf("secret-ask: %+v %v", answer, 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", "tool": "plex_issued_credential"})
|
||||
if err != nil || answer.(Issued).Secret != "provision/anchor/plex-api" {
|
||||
t.Fatalf("issued: %+v %v", answer, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// How an ask ended is written under the record's lock (the review of issue 675), as its opening is: two writers
|
||||
// never roll asks.jsonl at once, and an ask opened meanwhile never counts its bounds from a half-written record.
|
||||
// With the lock held elsewhere, AskEnd waits, and writes nothing until it is let go.
|
||||
func TestTheEndOfAnAskIsWrittenUnderTheRecordsLock(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ended := func() int {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(v.Store.dir, "asks.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.Count(string(raw), `"`+string(AskEnded)+`"`)
|
||||
}
|
||||
v.Store.asksMu.Lock()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := v.AskEnd(ControllerCaller, opened.Call, "", "dismissed")
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
v.Store.asksMu.Unlock()
|
||||
t.Fatalf("the end of the ask was written while another writer held the record (%v)", err)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if n := ended(); n != 0 {
|
||||
v.Store.asksMu.Unlock()
|
||||
t.Fatalf("%d end(s) written under another writer's lock", n)
|
||||
}
|
||||
v.Store.asksMu.Unlock()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := ended(); n != 1 {
|
||||
t.Fatalf("%d end(s) written once the lock was let go", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bus account the controller minted for a core or moved module is handed to the vault with origin issued (novox/hq
|
||||
// ADR 0374, issue 787): kept as that module's own secret on that machine, served to the controller's core get and to
|
||||
// the moved module's client, replaced in place on a re-issue with `secret-changed`, and never made anew by the vault.
|
||||
|
||||
func handIssued(t *testing.T, v testVault, caller, secret, value string) (Changed, error) {
|
||||
t.Helper()
|
||||
opened, err := v.AcceptOpen(caller, secret, "module issue", OriginIssued)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.AcceptTake(caller, opened.Call, sealToCall(t, opened.SealTo, value))
|
||||
}
|
||||
|
||||
func TestAHandedBusAccountIsKeptServedReplacedAndNeverMadeByTheVault(t *testing.T) {
|
||||
const moved = "own/g14/moved-bot/broker"
|
||||
v := newVault(t, AllowList{"provisioner.g14": {moved}})
|
||||
|
||||
first, err := handIssued(t, v, ControllerCaller, moved, `{"password":"first-pw"}`)
|
||||
if err != nil || first.Generation != 1 {
|
||||
t.Fatalf("the handed account: %+v %v", first, err)
|
||||
}
|
||||
rec, held, _ := v.Store.Read(moved)
|
||||
if !held || rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller {
|
||||
t.Fatalf("kept as origin %q via %q (held %v), want issued via the controller", rec.Origin, rec.ChangedVia, held)
|
||||
}
|
||||
if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"first-pw"}` {
|
||||
t.Errorf("the moved module's client is handed %q", got)
|
||||
}
|
||||
|
||||
// A re-issue replaces it in place, a new generation, and the vault says it changed.
|
||||
before := len(v.bus.events)
|
||||
second, err := handIssued(t, v, ControllerCaller, moved, `{"password":"second-pw"}`)
|
||||
if err != nil || second.Generation != 2 || second.Unchanged {
|
||||
t.Fatalf("the re-issue: %+v %v", second, err)
|
||||
}
|
||||
if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"second-pw"}` {
|
||||
t.Errorf("after the re-issue the client is handed %q: a stale value stayed", got)
|
||||
}
|
||||
changedEvents := 0
|
||||
for _, e := range v.bus.events[before:] {
|
||||
if e["event"] == ChangedEvent {
|
||||
changedEvents++
|
||||
}
|
||||
}
|
||||
if changedEvents != 1 {
|
||||
t.Errorf("the re-issue raised %d secret-changed events, want 1", changedEvents)
|
||||
}
|
||||
|
||||
// The vault makes no value for it: not a rotation, and not an accept without origin over it either way round.
|
||||
if _, err := v.Rotate(ControllerCaller, moved, "jochen", "leaked"); err == nil || !strings.Contains(err.Error(), "ADR 0374") {
|
||||
t.Errorf("the vault rotated a covered account: %v", err)
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/moved-bot/provisioner-broker", "jochen", "leaked"); err == nil {
|
||||
t.Error("the vault made a first value for a covered account by rotating it")
|
||||
}
|
||||
if got := handedTo(t, v, "provisioner.g14", moved); got != `{"password":"second-pw"}` {
|
||||
t.Errorf("a refused rotation changed the value to %q", got)
|
||||
}
|
||||
|
||||
// A secret that is not a covered account is not handed with origin issued; and only the controller hands one.
|
||||
if _, err := v.AcceptOpen(ControllerCaller, "own/g14/moved-bot/token", "x", OriginIssued); err == nil {
|
||||
t.Error("origin issued was taken for a secret that is not a bus account")
|
||||
}
|
||||
if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/postgres", "x", OriginIssued); err == nil {
|
||||
t.Error("origin issued was taken for a provision's credential")
|
||||
}
|
||||
if _, err := v.AcceptOpen("g14.moved-bot", moved, "x", OriginIssued); err == nil {
|
||||
t.Error("a caller that is not the controller handed a covered account")
|
||||
}
|
||||
if _, err := v.AcceptOpen(ControllerCaller, moved, "x", "made"); err == nil {
|
||||
t.Error("an origin accept does not take was taken")
|
||||
}
|
||||
if strings.Contains(everything(t, v), "first-pw") || strings.Contains(everything(t, v), "second-pw") {
|
||||
t.Fatal("a handed account is in something the vault said or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// A core module's handed account is the one the controller's core get is answered, sealed to its machine's key.
|
||||
func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) {
|
||||
const natsBroker = "own/anchor/nats/broker"
|
||||
v := newVault(t, nil)
|
||||
if _, err := handIssued(t, v, ControllerCaller, natsBroker, `{"password":"core-pw"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, held, _ := v.Store.Read(natsBroker)
|
||||
if !held || rec.Origin != OriginIssued {
|
||||
t.Fatalf("the core's account is held %v as %q", held, rec.Origin)
|
||||
}
|
||||
if !Covered(natsBroker) || Covered("own/anchor/nats/token") || Covered("pair/a/b/c/broker") {
|
||||
t.Error("the covered names are not the module's broker and provisioner-broker own secrets")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -45,17 +46,24 @@ func build() *Env {
|
||||
say("%v", env.Broken)
|
||||
return env
|
||||
}
|
||||
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
|
||||
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
|
||||
// Who may ask (askers.go): a node's provisioner client, for what its module's plan there names.
|
||||
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say}
|
||||
return env
|
||||
}
|
||||
|
||||
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
|
||||
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
|
||||
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished.
|
||||
func adoptOperatorKey(env *Env) {
|
||||
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It
|
||||
// returns why the pass did not re-seal everything, or nil.
|
||||
func adoptOperatorKey(env *Env) error {
|
||||
if env.Vault == nil {
|
||||
return
|
||||
if env.Broken != nil {
|
||||
return env.Broken
|
||||
}
|
||||
return errors.New("the vault was not built")
|
||||
}
|
||||
if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" {
|
||||
key, err := publicPEM(raw)
|
||||
@@ -67,20 +75,36 @@ func adoptOperatorKey(env *Env) {
|
||||
env.refuseKey(err.Error())
|
||||
say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err)
|
||||
}
|
||||
return
|
||||
return err
|
||||
}
|
||||
if _, err := env.Vault.Reseal(); err != nil {
|
||||
_, err := env.Vault.Reseal()
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// readAssignment is one value of the controller's assignments mirror, read through the runtime under the vault's
|
||||
// own account (its manifest `reads` it), or nil when there is none.
|
||||
func readAssignment(key string) ([]byte, error) {
|
||||
e, err := stdio.State(AssignmentsRead).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}
|
||||
|
||||
// assignmentKeys is every key of the controller's assignments mirror, read the same way.
|
||||
func assignmentKeys() ([]string, error) { return stdio.State(AssignmentsRead).Keys() }
|
||||
|
||||
func main() {
|
||||
env := build()
|
||||
go func() {
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
adoptOperatorKey(env)
|
||||
env.pass()
|
||||
env.pairPass()
|
||||
if env.Custody != nil {
|
||||
env.Custody.Pass()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package main
|
||||
|
||||
// The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676;
|
||||
// the operator's decision of 2026-10-14 on that issue).
|
||||
//
|
||||
// **Only a machine the value was sealed to can open it.** The controller's store holds every value sealed to a
|
||||
// machine's sealing key (and, for some, to the operator key); the controller cannot open one. So each machine's
|
||||
// node-engine opens what was sealed to it and hands it in, in the two steps of the intake (intake.go): `move-in`
|
||||
// names the secret, where it came from and its generation in the controller's store, and is answered a call and
|
||||
// a key made for it; the second step carries the value sealed to that key and a key of the node-engine's own,
|
||||
// and is answered what the vault kept, read back from its own store and sealed to that key — the node-engine
|
||||
// opens it and compares it with what it handed in, which is the move's proof.
|
||||
//
|
||||
// **From the node-engine of a machine the value was sealed to, and no other caller.** The caller is the bus user
|
||||
// the runtime proved, `node.<machine>` (the controller's broker names a node-engine so): a module's own secret
|
||||
// was sealed to its one machine, a pair credential to both its ends.
|
||||
//
|
||||
// **Copied as it is, and never over a value the vault holds.** Nothing is rotated (ADR 0349's rule for a move):
|
||||
// the value keeps its origin, the controller's generation and when it was made. The same value again changes
|
||||
// nothing — the other end of a pair, or the move run again — and a different one is refused, so a value the
|
||||
// vault holds, rotated or given since, is never put back to the controller's older one.
|
||||
//
|
||||
// The controller's record of asks at a desk holds no value, and the controller copies it as it is (`move-asks`).
|
||||
//
|
||||
// Which secret each row of the controller's store is, by name in the vault's store:
|
||||
//
|
||||
// own/<node>/<module>/<name> a row of module_secret
|
||||
// pair/<provider>/<provision>/<consumer>/<module>[/<local>] a row of secret, its local name last when it has one
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// NodeEnginePrefix starts the bus user of a machine's node-engine: `node.<machine>`.
|
||||
const NodeEnginePrefix = "node."
|
||||
|
||||
// MovedFrom is where a value the move took came from: the node-engine that handed it in, and the generation the
|
||||
// controller's store stamped it with (ADR 0158, ADR 0305 §5), kept as it was. Never a value.
|
||||
type MovedFrom struct {
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
}
|
||||
|
||||
// MoveOf is what one row of the controller's store is: a module's own secret (node, module, name) or a pair
|
||||
// credential (provider, provision, consumer, consumer module, local name), where it came from, its generation
|
||||
// and when it was made.
|
||||
type MoveOf struct {
|
||||
Node, Module, Name string
|
||||
Provider, Provision, Consumer, ConsumerModule, Local string
|
||||
Origin, Generation string
|
||||
MadeAt time.Time
|
||||
// SealingKey is the caller's machine's sealing key as its node-engine states it (a raw X25519 public key in
|
||||
// base64), kept as that machine's: what the core's credentials are sealed to (core.go, issue 763). Optional.
|
||||
SealingKey string
|
||||
}
|
||||
|
||||
// MovedIn is what the vault kept, and that value read back from its store sealed to the node-engine's key.
|
||||
type MovedIn struct {
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
// ReadBack is the value the vault's store holds now, sealed to the key the second step sent (NaCl anonymous
|
||||
// box, base64).
|
||||
ReadBack string `json:"read_back"`
|
||||
}
|
||||
|
||||
// PairSecret is the vault's name for one row of the controller's pair credentials.
|
||||
func PairSecret(provider, provision, consumer, consumerModule, local string) (string, error) {
|
||||
parts := map[string]string{"the provider's machine": provider, "the provision": provision,
|
||||
"the consumer's machine": consumer, "the consuming module": consumerModule}
|
||||
if local != "" {
|
||||
parts["the local name"] = local
|
||||
}
|
||||
for what, v := range parts {
|
||||
if !namePart.MatchString(v) {
|
||||
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
name := "pair/" + provider + "/" + provision + "/" + consumer + "/" + consumerModule
|
||||
if local != "" {
|
||||
name += "/" + local
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// secret is the row's name in the vault and the machines it was sealed to.
|
||||
func (m MoveOf) secret() (string, []string, error) {
|
||||
own := m.Node != "" || m.Module != "" || m.Name != ""
|
||||
pair := m.Provider != "" || m.Provision != "" || m.Consumer != "" || m.ConsumerModule != "" || m.Local != ""
|
||||
switch {
|
||||
case own && !pair:
|
||||
name, err := OwnSecret(m.Node, m.Module, m.Name)
|
||||
return name, []string{m.Node}, err
|
||||
case pair && !own:
|
||||
name, err := PairSecret(m.Provider, m.Provision, m.Consumer, m.ConsumerModule, m.Local)
|
||||
return name, []string{m.Provider, m.Consumer}, err
|
||||
}
|
||||
return "", nil, errors.New("name a module's own secret (node, module, name) or a pair credential (provider, " +
|
||||
"provision, consumer, consumer_module and local when it has one), one of the two")
|
||||
}
|
||||
|
||||
// movedOrigins are the controller's words for where a value came from, as the vault's.
|
||||
var movedOrigins = map[string]string{"made": OriginMade, "accepted": OriginAccepted, "issued": OriginIssued}
|
||||
|
||||
// nodeEngineOf is the machine whose node-engine caller is, if it is one.
|
||||
func nodeEngineOf(caller string) (string, bool) {
|
||||
node, ok := strings.CutPrefix(caller, NodeEnginePrefix)
|
||||
return node, ok && namePart.MatchString(node) && !strings.Contains(node, ".")
|
||||
}
|
||||
|
||||
// MoveOpen opens a call to take one value of the controller's store from the node-engine of a machine it was
|
||||
// sealed to.
|
||||
func (v *Vault) MoveOpen(caller string, m MoveOf) (Opened, error) {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
if caller == "" {
|
||||
caller = "an unproven caller"
|
||||
}
|
||||
return Opened{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone, which opens what was "+
|
||||
"sealed to its machine; %s is not a node-engine", caller)
|
||||
}
|
||||
secret, sealedTo, err := m.secret()
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if !slices.Contains(sealedTo, node) {
|
||||
return Opened{}, fmt.Errorf("%s was sealed to %s, not to %s: only a machine it was sealed to moves it in",
|
||||
secret, strings.Join(sealedTo, " and "), node)
|
||||
}
|
||||
if movedOrigins[m.Origin] == "" {
|
||||
return Opened{}, fmt.Errorf("the origin %q is not one the controller's store holds (made, accepted or "+
|
||||
"issued); nothing is taken", m.Origin)
|
||||
}
|
||||
if len(m.Generation) > 120 || strings.ContainsAny(m.Generation, "\r\n\x00") {
|
||||
return Opened{}, errors.New("the generation is not one short line; nothing is taken")
|
||||
}
|
||||
if m.SealingKey != "" {
|
||||
stated, err := sealingKey(m.SealingKey)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if err := v.recordNodeKey(node, stated, caller); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
}
|
||||
id, key, err := v.openCall("move-in", secret, caller, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
if c := v.pending[id]; c != nil {
|
||||
moved := m
|
||||
c.move = &moved
|
||||
}
|
||||
v.callsMu.Unlock()
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// readBackKey is the key a read-back is sealed to: a raw X25519 public key in base64, the box the node-engine's
|
||||
// own sealing key opens. A PEM, which a private key could be, is refused.
|
||||
func readBackKey(key string) (*[32]byte, error) {
|
||||
if privateKeyIn(key) || strings.Contains(key, "-----") {
|
||||
return nil, errors.New("the key to read back to is a raw X25519 public key in base64, never a PEM and never " +
|
||||
"a private key; nothing was taken")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("the second step names no key to read back to (`read_back_to`, a raw X25519 public " +
|
||||
"key in base64); nothing was taken")
|
||||
}
|
||||
var pub [32]byte
|
||||
copy(pub[:], raw)
|
||||
return &pub, nil
|
||||
}
|
||||
|
||||
// MoveTake opens the value sealed to the call's key and keeps it as it is, unless the vault holds another; then
|
||||
// answers what the store holds, read back and sealed to readBackTo.
|
||||
func (v *Vault) MoveTake(caller, call, sealed, readBackTo string) (MovedIn, error) {
|
||||
if _, ok := nodeEngineOf(caller); !ok {
|
||||
return MovedIn{}, fmt.Errorf("move-in takes a value from a machine's node-engine alone; %q is not one", caller)
|
||||
}
|
||||
to, err := readBackKey(readBackTo)
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
c, err := v.takeCall("move-in", call)
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
if c.by != caller || c.move == nil {
|
||||
return MovedIn{}, fmt.Errorf("the move-in call %q was opened by another caller; nothing was taken", call)
|
||||
}
|
||||
value, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
defer zero(value)
|
||||
if len(value) == 0 {
|
||||
return MovedIn{}, errors.New("the value handed in is empty; nothing was taken")
|
||||
}
|
||||
m := c.move
|
||||
changed, err := v.keepWith(c.secret, string(value), caller, "", movedOrigins[m.Origin], keepOptions{
|
||||
neverReplace: true, madeAt: m.MadeAt, movedFrom: &MovedFrom{By: caller, Generation: m.Generation}})
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
rec, held, err := v.Store.Read(c.secret)
|
||||
if err != nil || !held {
|
||||
return MovedIn{}, fmt.Errorf("%s was kept but cannot be read back from the vault's store: %v", c.secret, err)
|
||||
}
|
||||
back, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
return MovedIn{}, fmt.Errorf("%s was kept but does not open with the vault's key: %w", c.secret, err)
|
||||
}
|
||||
boxed, err := box.SealAnonymous(nil, []byte(back), to, rand.Reader)
|
||||
back = ""
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
v.log("moved in %s (generation %d) from %s, origin %s, the controller's generation %q", c.secret,
|
||||
changed.Generation, caller, movedOrigins[m.Origin], m.Generation)
|
||||
return MovedIn{Name: c.secret, Generation: rec.Generation, Unchanged: changed.Unchanged,
|
||||
ReadBack: base64.StdEncoding.EncodeToString(boxed)}, nil
|
||||
}
|
||||
|
||||
// ---- move-asks ----------------------------------------------------------------------------------------------
|
||||
|
||||
// MovedAsk is one row of the controller's record of asks at a desk (its secret_ask table): no value.
|
||||
type MovedAsk struct {
|
||||
ID int64 `json:"id"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Desk string `json:"desk"`
|
||||
AskedBy string `json:"asked_by"`
|
||||
OpenedAt time.Time `json:"opened_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
// AsksMoved is what a copy of the record of asks did.
|
||||
type AsksMoved struct {
|
||||
Copied int `json:"copied"`
|
||||
Already int `json:"already"`
|
||||
}
|
||||
|
||||
// MovedAsksAtOnce bounds one call: the controller sends its record in parts.
|
||||
const MovedAsksAtOnce = 500
|
||||
|
||||
// movedAskID is the vault's id of a row of the controller's record.
|
||||
func movedAskID(id int64) string { return "controller-" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// MoveAsks copies rows of the controller's record of asks into the vault's, as they are: each ask's opening and,
|
||||
// when it ended, its end — once, so a copy run again adds nothing. From the controller alone, whose record it is.
|
||||
// Every row is judged before any is written.
|
||||
func (v *Vault) MoveAsks(caller string, asks []MovedAsk) (AsksMoved, error) {
|
||||
if err := fromTheController(caller, "move-asks"); err != nil {
|
||||
return AsksMoved{}, err
|
||||
}
|
||||
if len(asks) > MovedAsksAtOnce {
|
||||
return AsksMoved{}, fmt.Errorf("%d asks at once is more than %d; send them in parts", len(asks), MovedAsksAtOnce)
|
||||
}
|
||||
type lines struct{ opened, ended AskRecord }
|
||||
var all []lines
|
||||
for _, a := range asks {
|
||||
secret, err := OwnSecret(a.Node, a.Module, a.Name)
|
||||
if err != nil {
|
||||
return AsksMoved{}, fmt.Errorf("ask %d: %w; nothing was copied", a.ID, err)
|
||||
}
|
||||
if !namePart.MatchString(a.Desk) || a.OpenedAt.IsZero() || a.ID <= 0 {
|
||||
return AsksMoved{}, fmt.Errorf("ask %d names no desk, no time it opened or no id; nothing was copied", a.ID)
|
||||
}
|
||||
l := lines{opened: AskRecord{ID: movedAskID(a.ID), At: a.OpenedAt.UTC(), Event: AskOpened, Secret: secret,
|
||||
Node: a.Node, Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedByName(a.AskedBy),
|
||||
Caller: ControllerCaller}}
|
||||
if a.EndedAt != nil {
|
||||
if a.Outcome != "given" && !askOutcomes[a.Outcome] {
|
||||
return AsksMoved{}, fmt.Errorf("ask %d ended %q, which is no outcome the record knows; nothing was "+
|
||||
"copied", a.ID, a.Outcome)
|
||||
}
|
||||
l.ended = AskRecord{ID: movedAskID(a.ID), At: a.EndedAt.UTC(), Event: AskEnded, Secret: secret,
|
||||
Outcome: a.Outcome}
|
||||
}
|
||||
all = append(all, l)
|
||||
}
|
||||
v.Store.asksMu.Lock()
|
||||
defer v.Store.asksMu.Unlock()
|
||||
held, err := v.Store.asks()
|
||||
if err != nil {
|
||||
return AsksMoved{}, fmt.Errorf("the record of asks cannot be read: %w; nothing was copied", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range held {
|
||||
if r.Event == AskOpened {
|
||||
seen[r.ID] = true
|
||||
}
|
||||
}
|
||||
out := AsksMoved{}
|
||||
for _, l := range all {
|
||||
if seen[l.opened.ID] {
|
||||
out.Already++
|
||||
continue
|
||||
}
|
||||
if err := v.recordAsk(l.opened); err != nil {
|
||||
return out, fmt.Errorf("ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err)
|
||||
}
|
||||
if l.ended.ID != "" {
|
||||
if err := v.recordAsk(l.ended); err != nil {
|
||||
return out, fmt.Errorf("the end of ask %s could not be copied, after %d: %w", l.opened.ID, out.Copied, err)
|
||||
}
|
||||
}
|
||||
seen[l.opened.ID] = true
|
||||
out.Copied++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StateNodeKey records the sealing key the node-engine of caller's machine states in its own name (novox/hq ADR 0372,
|
||||
// issue 786), at its start and whenever the key changes, whether or not it ever moved a secret in. The machine is the
|
||||
// caller's, as the bus proved it (`node.<machine>`), never an argument. A first key is handled like a changed one: the
|
||||
// machine's core secrets, sealed to no key until now or to the old one, are said changed so the controller asks again
|
||||
// (ADR 0349 §16). The same key again changes nothing and says nothing.
|
||||
func (v *Vault) StateNodeKey(caller, key string) error {
|
||||
node, ok := nodeEngineOf(caller)
|
||||
if !ok {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
caller = "an unproven caller"
|
||||
}
|
||||
return fmt.Errorf("node-key takes a machine's sealing key from that machine's node-engine alone, in its own "+
|
||||
"name; %s is not a node-engine", caller)
|
||||
}
|
||||
stated, err := sealingKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
held, had, err := v.Store.NodeKey(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if had && held == stated {
|
||||
return nil
|
||||
}
|
||||
if err := v.recordNodeKey(node, stated, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s's key is recorded, but its core secrets could not be listed to say them changed: %w", node, err)
|
||||
}
|
||||
for _, r := range all {
|
||||
parts := strings.Split(r.Name, "/")
|
||||
if len(parts) == 4 && parts[0] == "own" && parts[1] == node && slices.Contains(CoreModules, parts[2]) {
|
||||
v.announce(Changed{Name: r.Name, Generation: r.Generation, At: v.Now().UTC(), By: caller})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
// The move of the controller's secrets into the vault (design 57's V3, issue 676, the operator's decision of
|
||||
// 2026-10-14): each machine's node-engine opens what was sealed to it and hands it in, sealed to a key the vault
|
||||
// made for the call, keeping where it came from and its generation; the vault takes that step only from the
|
||||
// node-engine of a machine the value was sealed to, never replaces a value it holds, and answers what it kept
|
||||
// read back from its own store, sealed to a key the node-engine sent. The controller copies its record of asks
|
||||
// as it is. Nothing is rotated.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// aReadBackKey is a key the node-engine makes for one read-back: its public half to send, its private to open.
|
||||
func aReadBackKey(t *testing.T) (string, *[32]byte, *[32]byte) {
|
||||
t.Helper()
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub[:]), pub, priv
|
||||
}
|
||||
|
||||
// openReadBack opens what the vault read back, as the node-engine does.
|
||||
func openReadBack(t *testing.T, sealed string, pub, priv *[32]byte) string {
|
||||
t.Helper()
|
||||
blob, err := base64.StdEncoding.DecodeString(sealed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, ok := box.OpenAnonymous(nil, blob, pub, priv)
|
||||
if !ok {
|
||||
t.Fatal("the read-back does not open with the key sent for it")
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// moveIn is one node-engine's two steps for one value.
|
||||
func moveIn(t *testing.T, v testVault, caller string, m MoveOf, value string) (MovedIn, error) {
|
||||
t.Helper()
|
||||
opened, err := v.MoveOpen(caller, m)
|
||||
if err != nil {
|
||||
return MovedIn{}, err
|
||||
}
|
||||
key, _, _ := aReadBackKey(t)
|
||||
return v.MoveTake(caller, opened.Call, sealToCall(t, opened.SealTo, value), key)
|
||||
}
|
||||
|
||||
const movedValue = "Xq3-the-superusers-password-made-in-2025"
|
||||
|
||||
func superuser() MoveOf {
|
||||
return MoveOf{Node: "anchor", Module: "postgres", Name: "superuser-password", Origin: "made",
|
||||
Generation: "g-2025-11-02T10:00:00Z-1", MadeAt: time.Date(2025, 11, 2, 10, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
// A machine's own value moves as it is: opened in the vault alone, kept with its origin, the controller's
|
||||
// generation and when it was made, and read back from the vault's store to the node-engine, sealed to its key.
|
||||
func TestANodeEngineMovesInAValueSealedToItsMachineAsItIs(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
opened, err := v.MoveOpen("node.anchor", superuser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened.Secret != "own/anchor/postgres/superuser-password" || opened.SealTo == "" || opened.Call == "" {
|
||||
t.Fatalf("opened %+v", opened)
|
||||
}
|
||||
key, pub, priv := aReadBackKey(t)
|
||||
moved, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.Name != opened.Secret || moved.Generation != 1 || moved.Unchanged {
|
||||
t.Errorf("moved %+v", moved)
|
||||
}
|
||||
if got := openReadBack(t, moved.ReadBack, pub, priv); got != movedValue {
|
||||
t.Errorf("the read-back opens to %q", got)
|
||||
}
|
||||
rec, held, _ := v.Store.Read(opened.Secret)
|
||||
if !held || rec.Origin != OriginMade || rec.ChangedBy != "node.anchor" || rec.MovedFrom == nil ||
|
||||
rec.MovedFrom.Generation != "g-2025-11-02T10:00:00Z-1" || rec.MovedFrom.By != "node.anchor" ||
|
||||
!rec.MadeAt.Equal(superuser().MadeAt) {
|
||||
t.Errorf("the record: %+v moved from %+v", rec, rec.MovedFrom)
|
||||
}
|
||||
// Kept as every value is: a copy for the operator.
|
||||
if got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed); got != movedValue {
|
||||
t.Errorf("the operator copy opens to %q", got)
|
||||
}
|
||||
if strings.Contains(everything(t, v, opened, moved), movedValue) {
|
||||
t.Fatal("the value is in something the vault said, answered or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// Each origin the controller's store holds is kept as the vault's word for it; any other is refused.
|
||||
func TestAMovedValueKeepsWhereItCameFrom(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for origin, want := range map[string]string{"made": OriginMade, "accepted": OriginAccepted, "issued": OriginIssued} {
|
||||
m := superuser()
|
||||
m.Name, m.Origin = "from-"+origin, origin
|
||||
if _, err := moveIn(t, v, "node.anchor", m, "value-"+origin); err != nil {
|
||||
t.Fatalf("%s: %v", origin, err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/postgres/from-" + origin)
|
||||
if rec.Origin != want {
|
||||
t.Errorf("%s was kept as %q", origin, rec.Origin)
|
||||
}
|
||||
}
|
||||
m := superuser()
|
||||
m.Origin = "invented"
|
||||
if _, err := v.MoveOpen("node.anchor", m); err == nil || !strings.Contains(err.Error(), "origin") {
|
||||
t.Errorf("an unknown origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the node-engine of a machine the value was sealed to takes the step: not the controller, not a module's
|
||||
// tools, not another machine's node-engine.
|
||||
func TestOnlyTheNodeEngineOfAMachineTheValueWasSealedToMovesIt(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for _, caller := range []string{ControllerCaller, "anchor.node-tools", "anchor.mesh-vault", "node.shanks", "",
|
||||
"node.", "node.anchor.x"} {
|
||||
if _, err := v.MoveOpen(caller, superuser()); err == nil {
|
||||
t.Errorf("%q opened a move of anchor's value", caller)
|
||||
}
|
||||
}
|
||||
// A pair credential was sealed to both its ends: either may move it, and nobody else.
|
||||
pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea",
|
||||
Origin: "made", Generation: "g-1"}
|
||||
for _, caller := range []string{"node.anchor", "node.shanks"} {
|
||||
if _, err := v.MoveOpen(caller, pair); err != nil {
|
||||
t.Errorf("%s, an end of the pair, was refused: %v", caller, err)
|
||||
}
|
||||
}
|
||||
if _, err := v.MoveOpen("node.g14", pair); err == nil {
|
||||
t.Error("a machine at neither end opened a move of the pair")
|
||||
}
|
||||
// The second step is the first step's caller's alone.
|
||||
opened, err := v.MoveOpen("node.anchor", superuser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _, _ := aReadBackKey(t)
|
||||
if _, err := v.MoveTake("node.shanks", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err == nil {
|
||||
t.Error("another machine's node-engine took the second step")
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/anchor/postgres/superuser-password"); held {
|
||||
t.Error("a value was kept from the wrong caller")
|
||||
}
|
||||
}
|
||||
|
||||
// A pair credential is named by both ends and its local name, so every row of the controller's store is one
|
||||
// secret in the vault, kept as it is.
|
||||
func TestAPairCredentialMovesUnderTheNameOfItsRow(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea",
|
||||
Local: "replica", Origin: "accepted", Generation: "g-7"}
|
||||
moved, err := moveIn(t, v, "node.shanks", pair, "the-pair-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.Name != "pair/anchor/database/shanks/gitea/replica" {
|
||||
t.Errorf("named %s", moved.Name)
|
||||
}
|
||||
pair.Local = ""
|
||||
if moved, err = moveIn(t, v, "node.anchor", pair, "the-ordinary-value"); err != nil ||
|
||||
moved.Name != "pair/anchor/database/shanks/gitea" {
|
||||
t.Errorf("the ordinary one: %+v, %v", moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The move never replaces a value: the same value again changes nothing (the other end of a pair, or a move run
|
||||
// twice), and a different one is refused, so a value the vault holds is never put back to an older one.
|
||||
func TestTheMoveNeverReplacesAValueTheVaultHolds(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
pair := MoveOf{Provider: "anchor", Provision: "database", Consumer: "shanks", ConsumerModule: "gitea",
|
||||
Origin: "made", Generation: "g-1"}
|
||||
if _, err := moveIn(t, v, "node.anchor", pair, "the-pair-value"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened, err := v.MoveOpen("node.shanks", pair)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, pub, priv := aReadBackKey(t)
|
||||
again, err := v.MoveTake("node.shanks", opened.Call, sealToCall(t, opened.SealTo, "the-pair-value"), key)
|
||||
if err != nil || !again.Unchanged || again.Generation != 1 {
|
||||
t.Fatalf("the other end's same value: %+v, %v", again, err)
|
||||
}
|
||||
if got := openReadBack(t, again.ReadBack, pub, priv); got != "the-pair-value" {
|
||||
t.Errorf("read back %q", got)
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "pair/anchor/database/shanks/gitea", "jochen", "a test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := moveIn(t, v, "node.anchor", pair, "the-pair-value"); err == nil ||
|
||||
!strings.Contains(err.Error(), "never replaces") {
|
||||
t.Fatalf("a move over a value the vault changed since: %v", err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("pair/anchor/database/shanks/gitea")
|
||||
if rec.Generation != 2 || rec.Origin != OriginMade {
|
||||
t.Errorf("the vault's own value was touched: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// A second step that is not sealed to its call, or carries no key to read back to, takes nothing.
|
||||
func TestAMoveTakesNothingItCannotOpenOrReadBack(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
opened, err := v.MoveOpen("node.anchor", superuser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"", "not a key", "-----BEGIN PRIVATE KEY-----"} {
|
||||
if _, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err == nil {
|
||||
t.Errorf("read-back key %q was taken", key)
|
||||
}
|
||||
}
|
||||
// A refused key leaves the call open for the right one.
|
||||
other, _ := v.MoveOpen("node.anchor", superuser())
|
||||
key, _, _ := aReadBackKey(t)
|
||||
if _, err := v.MoveTake("node.anchor", other.Call, "bm90IHNlYWxlZA==", key); err == nil {
|
||||
t.Error("a value not sealed to the call was taken")
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/anchor/postgres/superuser-password"); held {
|
||||
t.Error("something was kept")
|
||||
}
|
||||
if moved, err := v.MoveTake("node.anchor", opened.Call, sealToCall(t, opened.SealTo, movedValue), key); err != nil ||
|
||||
moved.Generation != 1 {
|
||||
t.Errorf("the call after a refused key: %+v, %v", moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's record of asks is copied as it is, once: each ask's opening and its end, who asked and at which
|
||||
// desk, and the bounds then count them. From the controller alone.
|
||||
func TestTheRecordOfAsksIsCopiedAsItIsOnce(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
now := v.Now()
|
||||
ended := now.Add(-30 * time.Second)
|
||||
asks := []MovedAsk{
|
||||
{ID: 41, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "g14/claude-code",
|
||||
OpenedAt: now.Add(-50 * time.Minute), EndedAt: &ended, Outcome: "given"},
|
||||
{ID: 42, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "g14/claude-code",
|
||||
OpenedAt: now.Add(-40 * time.Minute), EndedAt: &ended, Outcome: "dismissed"},
|
||||
{ID: 43, Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: "ace/agent",
|
||||
OpenedAt: now.Add(-20 * time.Minute)},
|
||||
}
|
||||
if _, err := v.MoveAsks("node.anchor", asks); err == nil {
|
||||
t.Error("a node-engine copied the record of asks")
|
||||
}
|
||||
copied, err := v.MoveAsks(ControllerCaller, asks)
|
||||
if err != nil || copied.Copied != 3 || copied.Already != 0 {
|
||||
t.Fatalf("copied %+v, %v", copied, err)
|
||||
}
|
||||
again, err := v.MoveAsks(ControllerCaller, asks)
|
||||
if err != nil || again.Copied != 0 || again.Already != 3 {
|
||||
t.Fatalf("copied again %+v, %v", again, err)
|
||||
}
|
||||
all, _ := v.Store.Asks(100)
|
||||
if len(all) != 5 {
|
||||
t.Fatalf("%d lines: %+v", len(all), all)
|
||||
}
|
||||
opened := map[string]AskRecord{}
|
||||
for _, r := range all {
|
||||
if r.Event == AskOpened {
|
||||
opened[r.ID] = r
|
||||
}
|
||||
}
|
||||
if r := opened["controller-41"]; r.Secret != "own/anchor/telegram/telegram-token" || r.AskedBy != "g14/claude-code" ||
|
||||
r.Desk != "laptop" || !r.At.Equal(asks[0].OpenedAt) || r.Caller != ControllerCaller {
|
||||
t.Errorf("ask 41: %+v", r)
|
||||
}
|
||||
// Three asks in the hour, from the controller's record: the vault's bound holds them.
|
||||
if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code")); err == nil ||
|
||||
!strings.Contains(err.Error(), "in the last hour") {
|
||||
t.Errorf("an ask after three copied in the hour: %v", err)
|
||||
}
|
||||
bad := []MovedAsk{{ID: 44, Node: "anchor", Module: "telegram", Name: "x", Desk: "laptop", AskedBy: "a",
|
||||
OpenedAt: now, EndedAt: &ended, Outcome: "whatever"}}
|
||||
if _, err := v.MoveAsks(ControllerCaller, bad); err == nil {
|
||||
t.Error("an ask that ended in no outcome the record knows was copied")
|
||||
}
|
||||
}
|
||||
|
||||
// The two steps as the runtime calls the tool: the caller is the one it proved, never an argument.
|
||||
func TestMoveInThroughTheTool(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-in")
|
||||
first := map[string]any{"node": "anchor", "module": "postgres", "name": "superuser-password", "origin": "made",
|
||||
"generation": "g-1", "made_at": "2025-11-02T10:00:00Z"}
|
||||
if _, err := tool.runAs("controller", first); err == nil {
|
||||
t.Fatal("the controller opened a move")
|
||||
}
|
||||
got, err := tool.runAs("node.anchor", first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened := got.(Opened)
|
||||
key, pub, priv := aReadBackKey(t)
|
||||
got, err = tool.runAs("node.anchor", map[string]any{"call": opened.Call,
|
||||
"sealed": sealToCall(t, opened.SealTo, movedValue), "read_back_to": key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back := openReadBack(t, got.(MovedIn).ReadBack, pub, priv); back != movedValue {
|
||||
t.Errorf("read back %q", back)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/postgres/superuser-password")
|
||||
if rec.MadeAt.Year() != 2025 || rec.MovedFrom == nil || rec.MovedFrom.Generation != "g-1" {
|
||||
t.Errorf("kept %+v", rec)
|
||||
}
|
||||
asks := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".move-asks")
|
||||
moved, err := asks.runAs("controller", map[string]any{"asks": []any{map[string]any{"id": 7, "node": "anchor",
|
||||
"module": "telegram", "name": "telegram-token", "desk": "laptop", "asked_by": "g14/claude-code",
|
||||
"opened_at": "2026-10-01T10:00:00Z", "ended_at": "2026-10-01T10:00:20Z", "outcome": "given"}}})
|
||||
if err != nil || moved.(AsksMoved).Copied != 1 {
|
||||
t.Fatalf("asks through the tool: %+v, %v", moved, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
// A consumer's own login, kept by the vault as its pair row, is named on the consumer's plan entry when the
|
||||
// controller hands the consumer's secrets file to its machine's client (issue 779): the client asks for the row, and
|
||||
// the rule hands it to that consumer's machine alone.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestANamedPairEntryHandsTheConsumersMachineItsOwnLoginAndNoOtherConsumers(t *testing.T) {
|
||||
const pg = "provision/novox/postgres-database"
|
||||
mirror := assignments{
|
||||
"novox.postgres": handedWith("novox", "postgres"),
|
||||
"g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres=database+pair"),
|
||||
"ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres#stats=database-stats+pair"),
|
||||
"shanks.other": handedWith("shanks", "other", "postgres-database@novox/postgres=database+pair"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, pg); o.Outcome != PairMade {
|
||||
t.Fatalf("a named pair entry held the provider back: %+v", o)
|
||||
}
|
||||
gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami/stats"
|
||||
ofGitea, _, err := asks(v, "provisioner.g14", gitea)
|
||||
if err != nil || ofGitea == "" {
|
||||
t.Fatalf("the consumer's machine was refused its own login: %v", err)
|
||||
}
|
||||
if ofUmami, _, err := asks(v, "provisioner.ace", umami); err != nil || ofUmami == "" || ofUmami == ofGitea {
|
||||
t.Fatalf("the second consumer's login: %q (%v)", ofUmami, err)
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.g14", umami); err == nil {
|
||||
t.Fatal("a consumer's machine was handed another consumer's login")
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.shanks", gitea); err == nil {
|
||||
t.Fatal("a machine was handed the login of a module that is not its own")
|
||||
}
|
||||
// A named entry of another form is still not a pair row's.
|
||||
other := assignments{
|
||||
"novox.postgres": handedWith("novox", "postgres"),
|
||||
"g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres=database+issued"),
|
||||
}
|
||||
w := pairVault(t, other)
|
||||
if _, _, err := asks(w, "provisioner.g14", gitea); err == nil {
|
||||
t.Fatal("a named entry of the issued form was handed a pair row")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
// Issue 786 (ADR 0372): a machine's node-engine states its own sealing key to the vault, in its own name, at start and
|
||||
// when it changes, whether or not it ever moved a secret in. The caller is the bus user the runtime proved; a changed
|
||||
// key (or a first one) raises `secret-changed` for the machine's core secrets, the same key says nothing.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func changedNames(p *published) []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var out []string
|
||||
for _, e := range p.events {
|
||||
if e["event"] == ChangedEvent {
|
||||
out = append(out, e["body"].(map[string]any)["name"].(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestANodeEngineStatesItsOwnKeyAndAChangeRaisesItsCoreSecrets(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key")
|
||||
// ace holds two core secrets and a leaf module's; g14 holds a core secret too.
|
||||
old, _ := aSealingKey(t)
|
||||
for _, m := range []MoveOf{
|
||||
{Node: "ace", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", Generation: "g-1", SealingKey: old},
|
||||
{Node: "ace", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"},
|
||||
{Node: "ace", Module: "postgres", Name: "superuser-password", Origin: "made", Generation: "g-1"},
|
||||
{Node: "g14", Module: "nats", Name: "broker", Origin: "made", Generation: "g-1"},
|
||||
} {
|
||||
if _, err := moveIn(t, v, "node."+m.Node, m, movedValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// ace never stated a key in the moves above except through the first row; forget it to be a machine that never moved.
|
||||
if err := os.Remove(v.Store.nodeKeyPath("ace")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(changedNames(v.bus))
|
||||
|
||||
pub, _ := aSealingKey(t)
|
||||
if _, err := tool.runAs("controller", map[string]any{"key": pub}); err == nil {
|
||||
t.Error("the controller stated a machine's key")
|
||||
}
|
||||
if _, err := tool.runAs("", map[string]any{"key": pub}); err == nil {
|
||||
t.Error("an unproven caller stated a key")
|
||||
}
|
||||
if _, err := tool.runAs("node.ace", map[string]any{"key": "c2hvcnQ="}); err == nil {
|
||||
t.Error("a short key was taken")
|
||||
}
|
||||
if _, err := tool.runAs("node.ace", map[string]any{"node": "g14", "key": pub}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The machine is the caller's; an argument naming another one changes nothing.
|
||||
if got, held, _ := v.Store.NodeKey("ace"); !held || got != pub {
|
||||
t.Errorf("ace's key is %q, %v", got, held)
|
||||
}
|
||||
if _, held, _ := v.Store.NodeKey("g14"); held {
|
||||
t.Error("a key was recorded for g14 by ace's engine")
|
||||
}
|
||||
got := changedNames(v.bus)[before:]
|
||||
want := []string{"own/ace/mesh-provisioner-client/provisioner-broker", "own/ace/nats/broker"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("secret-changed was raised for %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The same key again changes nothing and says nothing.
|
||||
if _, err := tool.runAs("node.ace", map[string]any{"key": pub}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(changedNames(v.bus)); n != before+2 {
|
||||
t.Errorf("the same key raised %d more", n-before-2)
|
||||
}
|
||||
// A different key raises them again.
|
||||
other, _ := aSealingKey(t)
|
||||
if _, err := tool.runAs("node.ace", map[string]any{"key": other}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(changedNames(v.bus)); n != before+4 {
|
||||
t.Errorf("a changed key raised %d, want 2", n-before-2)
|
||||
}
|
||||
if got, _, _ := v.Store.NodeKey("ace"); got != other {
|
||||
t.Error("the changed key was not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
const nodeKeyBus = `listen: 127.0.0.1:-1
|
||||
authorization {
|
||||
users = [
|
||||
{ user: "novox.mesh-vault", password: "pw", permissions: {
|
||||
publish: { deny: [">"] }
|
||||
subscribe: { allow: ["mesh.seat.mesh-vault.call.node-key.*"] }
|
||||
allow_responses: true } }
|
||||
{ user: "node.ace", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_ace"] }
|
||||
subscribe: { allow: ["_INBOX.node.ace.>"] } } }
|
||||
{ user: "node.g14", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.node-key.node_g14"] }
|
||||
subscribe: { allow: ["_INBOX.node.g14.>"] } } }
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
// On a real bus whose accounts may publish only their own subject: node.ace's statement records ace's key; node.g14
|
||||
// cannot speak in ace's name, and what it states is g14's.
|
||||
func TestOnARealBusANodeEngineStatesOnlyItsOwnKey(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(file, []byte(nodeKeyBus), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
|
||||
v := newVault(t, nil)
|
||||
tool := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".node-key")
|
||||
vaultConn := connectAs(t, s.ClientURL(), "novox.mesh-vault")
|
||||
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.node-key.*", func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
answer := busAnswer{}
|
||||
if got, err := tool.runAs(untoken(parts[len(parts)-1]), args); err != nil {
|
||||
answer.Error = err.Error()
|
||||
} else {
|
||||
answer.Result, _ = json.Marshal(got)
|
||||
}
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = m.Respond(raw)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vaultConn.Flush()
|
||||
|
||||
say := func(user, subject, key string) (string, error) {
|
||||
raw, _ := json.Marshal(map[string]string{"key": key})
|
||||
msg, err := connectAs(t, s.ClientURL(), user).Request(subject, raw, 3*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a busAnswer
|
||||
_ = json.Unmarshal(msg.Data, &a)
|
||||
if a.Error != "" {
|
||||
return a.Error, errString(a.Error)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
aceKey, _ := aSealingKey(t)
|
||||
g14Key, _ := aSealingKey(t)
|
||||
if _, err := say("node.ace", "mesh.seat.mesh-vault.call.node-key.node_ace", aceKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, held, _ := v.Store.NodeKey("ace"); !held || got != aceKey {
|
||||
t.Errorf("ace's key is %q, %v", got, held)
|
||||
}
|
||||
// g14 may not publish on ace's subject: the bus refuses it, so the vault never hears it.
|
||||
if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_ace", g14Key); err == nil {
|
||||
t.Error("node.g14 spoke in ace's name")
|
||||
}
|
||||
if got, _, _ := v.Store.NodeKey("ace"); got != aceKey {
|
||||
t.Error("ace's key was replaced by g14's")
|
||||
}
|
||||
if _, err := say("node.g14", "mesh.seat.mesh-vault.call.node-key.node_g14", g14Key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, held, _ := v.Store.NodeKey("g14"); !held || got != g14Key {
|
||||
t.Errorf("g14's key is %q, %v", got, held)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
// The vault's own calls (issue 723): it asks the desk's prompt and an issuing provider's tool itself, in its own
|
||||
// name, under the bus account its own runtime runs on. That the bus lets that account alone make these calls, and
|
||||
// nothing else, is mesh-controller's test on a real server (internal/broker vault_calls_live_test.go); here, what
|
||||
// the vault sends and takes back on those calls carries keys and sealed values only.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// vaultsAccount is the vault's bus user as its own runtime names it to a tool it calls.
|
||||
const vaultsAccount = "novox.mesh-vault"
|
||||
|
||||
// aMesh stands in for the bus the vault's runtime calls on: each call is answered by the desk's prompt or a
|
||||
// provider's tool, named by the caller the vault's own account is, and everything that crosses is kept.
|
||||
type aMesh struct {
|
||||
mu sync.Mutex
|
||||
desk func(caller string, args map[string]any) (map[string]any, error)
|
||||
tools map[string]func(caller string, args map[string]any) (map[string]any, error)
|
||||
keys []string
|
||||
crossed []string
|
||||
}
|
||||
|
||||
func (m *aMesh) ask(key string, body any) (json.RawMessage, error) {
|
||||
m.mu.Lock()
|
||||
m.keys = append(m.keys, key)
|
||||
m.mu.Unlock()
|
||||
raw, _ := json.Marshal(body)
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(raw, &args)
|
||||
var answer map[string]any
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(key, "seat:node-launcher.secret@") && m.desk != nil:
|
||||
answer, err = m.desk(vaultsAccount, args)
|
||||
case m.tools[key] != nil:
|
||||
answer, err = m.tools[key](vaultsAccount, args)
|
||||
default:
|
||||
err = errors.New("503 no responders")
|
||||
}
|
||||
out, _ := json.Marshal(answer)
|
||||
m.mu.Lock()
|
||||
m.crossed = append(m.crossed, string(raw), string(out))
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *aMesh) said() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return strings.Join(m.crossed, "\n")
|
||||
}
|
||||
|
||||
// aDesk is the desk's prompt (rofi's node-launcher.secret): it answers what the operator types, sealed to the key
|
||||
// it is given, and keeps what it was asked.
|
||||
func aDesk(typed 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) {
|
||||
*asked = append(*asked, args)
|
||||
switch typed {
|
||||
case "":
|
||||
return map[string]any{"cancelled": true}, nil
|
||||
case "\x00timeout":
|
||||
return map[string]any{"cancelled": true, "timed_out": true}, nil
|
||||
}
|
||||
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(typed+"\n"), &to, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// The vault asks the desk itself: recorded first, the prompt named by the vault's call and naming the asker, what
|
||||
// is typed opened here alone; what crossed is a key and a sealed box.
|
||||
func TestTheVaultAsksTheDesksPromptItselfAndOnlyASealedValueCrosses(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}})
|
||||
var asked []map[string]any
|
||||
mesh := &aMesh{desk: aDesk(typedAtTheDesk, &asked)}
|
||||
v.Ask = mesh.ask
|
||||
|
||||
got, err := v.AskAtDesk(ControllerCaller, telegramAsk("g14/claude-code, via the console"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Outcome != "given" || got.Generation != 1 || got.AskedBy != "g14/claude-code" ||
|
||||
got.Secret != "own/anchor/telegram/telegram-token" {
|
||||
t.Errorf("answered %+v", got)
|
||||
}
|
||||
if len(mesh.keys) != 1 || mesh.keys[0] != "seat:node-launcher.secret@laptop" {
|
||||
t.Fatalf("the vault asked %v", mesh.keys)
|
||||
}
|
||||
p := asked[0]
|
||||
if p["asked_by"] != "g14/claude-code" || p["module"] != "telegram" || p["secret"] != "telegram-token" ||
|
||||
p["node"] != "anchor" || p["seal_to"] == "" || p["timeout_seconds"] != float64(DeskPromptFor) {
|
||||
t.Errorf("the prompt was asked %v", p)
|
||||
}
|
||||
if v := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); v != typedAtTheDesk {
|
||||
t.Errorf("the vault holds %q", v)
|
||||
}
|
||||
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: origin %q by %q via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia)
|
||||
}
|
||||
asks, _ := v.Store.Asks(10)
|
||||
if len(asks) != 2 || asks[1].Event != AskOpened || asks[0].Outcome != "given" {
|
||||
t.Errorf("the record of asks: %+v", asks)
|
||||
}
|
||||
if strings.Contains(mesh.said(), typedAtTheDesk) || strings.Contains(everything(t, v, got, asks), typedAtTheDesk) {
|
||||
t.Fatal("the typed value crossed the bus, or is in something the vault said or wrote, in the clear")
|
||||
}
|
||||
|
||||
// Dismissed and timed out end the ask so, and keep nothing.
|
||||
for typed, outcome := range map[string]string{"": "dismissed", "\x00timeout": "timed-out"} {
|
||||
w := newVault(t, nil)
|
||||
var a []map[string]any
|
||||
w.Ask = (&aMesh{desk: aDesk(typed, &a)}).ask
|
||||
got, err := w.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil || got.Outcome != outcome {
|
||||
t.Errorf("%s: %+v %v", outcome, got, err)
|
||||
}
|
||||
if asks, _ := w.Store.Asks(1); asks[0].Outcome != outcome {
|
||||
t.Errorf("%s recorded %+v", outcome, asks[0])
|
||||
}
|
||||
if _, held, _ := w.Store.Read("own/anchor/telegram/telegram-token"); held {
|
||||
t.Errorf("%s kept a value", outcome)
|
||||
}
|
||||
}
|
||||
// Answered empty: the operator's answer, said so, nothing kept.
|
||||
e := newVault(t, nil)
|
||||
var ea []map[string]any
|
||||
e.Ask = (&aMesh{desk: aDesk(" ", &ea)}).ask
|
||||
if got, err := e.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err != nil || got.Outcome != "empty" {
|
||||
t.Errorf("empty: %+v %v", got, err)
|
||||
}
|
||||
// The desk cannot be asked: refused, recorded so.
|
||||
r := newVault(t, nil)
|
||||
r.Ask = (&aMesh{}).ask
|
||||
if _, err := r.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err == nil {
|
||||
t.Error("an ask nobody answered was said to be done")
|
||||
}
|
||||
if asks, _ := r.Store.Asks(1); asks[0].Outcome != "refused" {
|
||||
t.Errorf("an unanswered desk recorded %+v", asks[0])
|
||||
}
|
||||
// Only the controller asks the vault to ask: anybody else is refused before any prompt.
|
||||
n := newVault(t, nil)
|
||||
var na []map[string]any
|
||||
n.Ask = (&aMesh{desk: aDesk(typedAtTheDesk, &na)}).ask
|
||||
for _, caller := range []string{"g14.node-tools", "g14.claude-code", "unproven", ""} {
|
||||
if _, err := n.AskAtDesk(caller, telegramAsk("x")); err == nil {
|
||||
t.Errorf("%q had the vault ask a desk", caller)
|
||||
}
|
||||
}
|
||||
if len(na) != 0 {
|
||||
t.Fatal("a desk was asked for a caller that is not the controller")
|
||||
}
|
||||
}
|
||||
|
||||
// The vault asks an issuing provider's tool itself: by its name on the provider's machine, with a key made for the
|
||||
// call; the tool answers the vault's account; the value opens here; what crossed is a key and a sealed box.
|
||||
func TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}})
|
||||
software := "plex-token-from-the-vendor"
|
||||
mesh := &aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){
|
||||
"plex.plex_issued_credential@anchor": standInProvider(&software)}}
|
||||
v.Ask = mesh.ask
|
||||
got, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Secret != "provision/anchor/plex-api" || got.Generation != 1 || got.Unchanged {
|
||||
t.Errorf("answered %+v", got)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
||||
t.Errorf("the vault holds %q", got)
|
||||
}
|
||||
if strings.Contains(mesh.said(), software) || strings.Contains(everything(t, v, got), software) {
|
||||
t.Fatal("the issued value crossed the bus, or is in something the vault said or wrote, in the clear")
|
||||
}
|
||||
if !strings.Contains(mesh.said(), `"provision":"plex-api"`) || !strings.Contains(mesh.said(), `"seal_to"`) {
|
||||
t.Errorf("the tool was asked %s", mesh.said())
|
||||
}
|
||||
// The same value again writes nothing.
|
||||
again, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential")
|
||||
if err != nil || !again.Unchanged || again.Generation != 1 {
|
||||
t.Errorf("again: %+v %v", again, err)
|
||||
}
|
||||
// Only the controller asks the vault to take it; a tool's name that is not one is refused; a tool that is not
|
||||
// answered, or answers no sealed value, keeps nothing.
|
||||
for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} {
|
||||
if _, err := v.TakeIssued(caller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil {
|
||||
t.Errorf("%q had the vault ask a provider", caller)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "plex.other", "x@y", "a b"} {
|
||||
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", bad); err == nil {
|
||||
t.Errorf("the tool %q was asked", bad)
|
||||
}
|
||||
}
|
||||
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_status"); err == nil {
|
||||
t.Error("a tool nobody answers gave a value")
|
||||
}
|
||||
mesh.tools["plex.plex_issued_credential@anchor"] = func(string, map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"value": "in-the-clear"}, nil
|
||||
}
|
||||
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil {
|
||||
t.Error("a tool answering no sealed value gave a value")
|
||||
}
|
||||
if len(v.pending) != 0 {
|
||||
t.Errorf("%d calls' keys are still kept", len(v.pending))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
package main
|
||||
|
||||
// The credential a provider shares with its consumers (novox/hq ADR 0349 clause 8; ADR 0158 §4–§6, §8; design 57's
|
||||
// V5, issue 677): one value per provider assignment and provision, `provision/<provider node>/<provision>`, minted
|
||||
// and remade by the vault for all its holders together when a consumer binds or unbinds, or when it is rotated.
|
||||
//
|
||||
// **Where the vault sees a bind.** A recorded need names no provider (issue 677's note 7). The controller names, in
|
||||
// the plan of each module it handed to the provisioner, each provision the module requires with the provider
|
||||
// resolution chose (issue 760): `provisions` in `<node>.<module>` of the assignments mirror, the plan the module's
|
||||
// client records its needs from. A consumer binds when such an entry appears and unbinds when it goes, or when its
|
||||
// assignment goes. The vault reads every plan on each pass (once a minute, and when it starts).
|
||||
//
|
||||
// **Every holder can get it, or nothing is made** (the lead's rule on issue 677, note 10). The holders are the
|
||||
// provider on its machine and every consumer bound to it. Only a module handed to the provisioner gets a value from
|
||||
// the vault; one still filled the old way gets the controller's (ADR 0349 clause 17). So the vault mints or remakes
|
||||
// a provider's value only when the provider's assignment is handed to the provisioner, and no consumer the vault
|
||||
// knows to hold it is still filled the old way: a consumer whose pair row the move brought in (V3) and whose
|
||||
// assignment is still there with no plan. Otherwise it makes nothing, and says why on each pass that changes what
|
||||
// it says. A consumer filled the old way that bound after the move names no provider on the bus at all; it is the
|
||||
// provider's move (design 57 B2, B3) that ends that case.
|
||||
//
|
||||
// **What the move brought in is adopted, and nothing is rotated early.** The move (V3) copied each pair row as
|
||||
// `pair/<provider>/<provision>/<consumer>/<module>[/<local>]`. Rows a provider shares (ADR 0158: its own secret and
|
||||
// every consumer's row made in one act) carry one generation stamp; when every row of a provider and provision
|
||||
// carries the same stamp and the same value, the vault keeps that value as the provision's one credential, with its
|
||||
// origin, and remakes nothing. Rows of a provider with a login per consumer (ADR 0048) carry no stamp and differ
|
||||
// per pair: each consumer keeps a value of its own for ever (ADR 0158 §11, issue 677's note 12). The vault never
|
||||
// makes them one shared value; a rotation (`rotate` of `provision/<provider>/<provision>`) remakes each consumer's
|
||||
// own apart, and each consumer's machine is handed only its own.
|
||||
//
|
||||
// **A provider the vault holds no row of gets its first value in its offer's form** (issue 768). Each plan entry
|
||||
// names the form the provider's offer declares (`credential-form`, from the offer's `credential` in the provider's
|
||||
// manifest): `shared`, one value for every holder (ADR 0158), made as `provision/<provider>/<provision>`; `pair`, a
|
||||
// login of its own for each consumer (ADR 0048, ADR 0158 §11), made as each consumer's pair row; `issued` by the
|
||||
// provider's software (ADR 0305) or `given` by a person (ADR 0337), which the vault never makes. An entry naming no
|
||||
// form (a controller from before), plans that disagree, or a form the vault does not know: nothing is made, rather
|
||||
// than a guess. A provider the vault already holds rows of is unchanged by the form: it is adopted or kept per pair
|
||||
// as above.
|
||||
//
|
||||
// **An accepted or issued value is never remade** (ADR 0158 §8, ADR 0305 §9): the vault records who holds it and
|
||||
// hands it to them, and leaves the value alone. A provision whose plans name a credential per holder (issue 757,
|
||||
// an entry with a name) has no one shared value and is not the vault's to make here.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// Mirror is the controller's assignments mirror as the vault reads it: every key, and one key's value.
|
||||
type Mirror struct {
|
||||
Keys func() ([]string, error)
|
||||
Read AssignmentReader
|
||||
}
|
||||
|
||||
// Shared is what the vault last made, adopted or recorded the provision's one credential for: its holders and the
|
||||
// generation they hold. Never a value.
|
||||
type Shared struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Provision string `json:"provision"`
|
||||
ProviderModule string `json:"provider-module,omitempty"`
|
||||
// Consumers are the binds holding it: `<node>/<module>`, and `/<local>` when bound under a local name.
|
||||
Consumers []string `json:"consumers"`
|
||||
Generation int `json:"generation"`
|
||||
At time.Time `json:"at"`
|
||||
// Adopted is the controller's generation stamp of the pair rows the value was adopted from, when it was.
|
||||
Adopted string `json:"adopted,omitempty"`
|
||||
}
|
||||
|
||||
// holderNodes are the machines holding it: the provider's and every consumer's.
|
||||
func (s Shared) holderNodes() []string {
|
||||
out := []string{s.Provider}
|
||||
for _, c := range s.Consumers {
|
||||
out = append(out, strings.SplitN(c, "/", 2)[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) sharedPath(name string) string {
|
||||
return filepath.Join(s.dir, "shared", url.PathEscape(name)+".json")
|
||||
}
|
||||
|
||||
// ReadShared is the record of who holds the provision's one credential name, and whether there is one.
|
||||
func (s *Store) ReadShared(name string) (Shared, bool, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Shared{}, false, err
|
||||
}
|
||||
raw, err := os.ReadFile(s.sharedPath(name))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Shared{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Shared{}, false, err
|
||||
}
|
||||
var sh Shared
|
||||
if err := json.Unmarshal(raw, &sh); err != nil {
|
||||
return Shared{}, false, fmt.Errorf("the record of who holds %s is not readable: %w", name, err)
|
||||
}
|
||||
return sh, true, nil
|
||||
}
|
||||
|
||||
// WriteShared keeps the record of who holds a provision's one credential.
|
||||
func (s *Store) WriteShared(sh Shared) error {
|
||||
if err := validName(sh.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "shared"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(sh.Consumers)
|
||||
raw, err := json.MarshalIndent(sh, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWhole(s.sharedPath(sh.Name), append(raw, '\n'))
|
||||
}
|
||||
|
||||
// pairKey is one provider assignment and provision.
|
||||
type pairKey struct{ provider, provision string }
|
||||
|
||||
func (k pairKey) name() string { return "provision/" + k.provider + "/" + k.provision }
|
||||
|
||||
// binds is what the plans say of one provider and provision: its module, who binds it, whether a plan names a
|
||||
// credential per holder for it, and the credential forms the plans name for it ("" for an entry naming none).
|
||||
type binds struct {
|
||||
providerModules map[string]bool
|
||||
consumers map[string]bool
|
||||
perHolder bool
|
||||
forms map[string]bool
|
||||
}
|
||||
|
||||
// The credential forms a plan entry names (issue 768), as the provider's offer declares them.
|
||||
const (
|
||||
// FormShared is one value every holder shares (ADR 0158): the offer's credential is one of its own secrets.
|
||||
FormShared = "shared"
|
||||
// FormPair is a login of its own for each consumer (ADR 0048, ADR 0158 §11): the offer says no credential.
|
||||
FormPair = "pair"
|
||||
// FormIssued is a value the provider's software issues (ADR 0305): the mesh never makes it.
|
||||
FormIssued = "issued"
|
||||
// FormGiven is a value only a person gives (ADR 0337): the mesh never makes it.
|
||||
FormGiven = "given"
|
||||
)
|
||||
|
||||
// credentialForms are every form a plan entry may name.
|
||||
var credentialForms = []string{FormShared, FormPair, FormIssued, FormGiven}
|
||||
|
||||
// form is the one form the plans name for k, or why the vault cannot trust one.
|
||||
func (b *binds) form(k pairKey) (string, string) {
|
||||
if b == nil || len(b.forms) == 0 || b.forms[""] {
|
||||
return "", unknownForm(k)
|
||||
}
|
||||
var named []string
|
||||
for f := range b.forms {
|
||||
if !slices.Contains(credentialForms, f) {
|
||||
return "", fmt.Sprintf("a plan names %q for %s from %s, which is not a credential form the vault knows "+
|
||||
"(%s); it makes nothing", f, k.provision, k.provider, strings.Join(credentialForms, ", "))
|
||||
}
|
||||
named = append(named, f)
|
||||
}
|
||||
if len(named) > 1 {
|
||||
sort.Strings(named)
|
||||
return "", fmt.Sprintf("the plans binding %s from %s disagree on its credential form (%s); it makes nothing",
|
||||
k.provision, k.provider, strings.Join(named, ", "))
|
||||
}
|
||||
return named[0], ""
|
||||
}
|
||||
|
||||
// pairRow is one row the move brought in.
|
||||
type pairRow struct {
|
||||
consumer, module string
|
||||
rec Record
|
||||
}
|
||||
|
||||
// bindings is what one pass read: every plan's bound provisions, which assignments there are, and which of them
|
||||
// are handed to the provisioner.
|
||||
type bindings struct {
|
||||
bound map[pairKey]*binds
|
||||
assigned map[string]bool
|
||||
handed map[string]bool
|
||||
}
|
||||
|
||||
// plannedProvisions is as much of an assignment as the pass reads.
|
||||
type plannedProvisions struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Provisioning *struct {
|
||||
Provisions []struct {
|
||||
Name string `json:"name"`
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
CredentialForm string `json:"credential-form"`
|
||||
// Contributes is what the consumer tells its provider (issue 776); the vault does not act on it.
|
||||
Contributes map[string]any `json:"contributes"`
|
||||
} `json:"provisions"`
|
||||
} `json:"provisioning"`
|
||||
}
|
||||
|
||||
// readBindings reads every assignment. An assignment that cannot be read stops the pass: a bind missed would read
|
||||
// as an unbind, and remake a value its holder still uses.
|
||||
func readBindings(m Mirror) (bindings, error) {
|
||||
b := bindings{bound: map[pairKey]*binds{}, assigned: map[string]bool{}, handed: map[string]bool{}}
|
||||
if m.Keys == nil || m.Read == nil {
|
||||
return b, errors.New("the vault cannot read the assignments, so it cannot see who binds a provider")
|
||||
}
|
||||
keys, err := m.Keys()
|
||||
if err != nil {
|
||||
return b, fmt.Errorf("the assignments' keys could not be read: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, err := m.Read(key)
|
||||
if err != nil {
|
||||
return b, fmt.Errorf("the assignment %s could not be read: %w", key, err)
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
var a plannedProvisions
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key {
|
||||
return b, fmt.Errorf("the assignment %s is not the controller's", key)
|
||||
}
|
||||
b.assigned[key] = true
|
||||
if a.Provisioning == nil {
|
||||
continue
|
||||
}
|
||||
b.handed[key] = true
|
||||
for _, p := range a.Provisioning.Provisions {
|
||||
if !namePart.MatchString(p.Provider) || !namePart.MatchString(p.Provision) {
|
||||
continue
|
||||
}
|
||||
k := pairKey{p.Provider, p.Provision}
|
||||
if b.bound[k] == nil {
|
||||
b.bound[k] = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{},
|
||||
forms: map[string]bool{}}
|
||||
}
|
||||
b.bound[k].forms[p.CredentialForm] = true
|
||||
if p.ProviderModule != "" {
|
||||
b.bound[k].providerModules[p.ProviderModule] = true
|
||||
}
|
||||
// A name on an entry of the login-per-consumer form is that login's file (issue 779), not a value issued
|
||||
// for one holder: the provider still gets its logins as rows.
|
||||
if p.Name != "" && p.CredentialForm != FormPair {
|
||||
b.bound[k].perHolder = true
|
||||
}
|
||||
b.bound[k].consumers[consumerOf(a.Node, a.Module, p.Local)] = true
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func consumerOf(node, module, local string) string {
|
||||
if local == "" {
|
||||
return node + "/" + module
|
||||
}
|
||||
return node + "/" + module + "/" + local
|
||||
}
|
||||
|
||||
// pairRows is every row the move brought in, by provider and provision.
|
||||
func (v *Vault) pairRows() (map[pairKey][]pairRow, error) {
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[pairKey][]pairRow{}
|
||||
for _, rec := range all {
|
||||
parts := strings.Split(rec.Name, "/")
|
||||
if parts[0] != "pair" || (len(parts) != 5 && len(parts) != 6) {
|
||||
continue
|
||||
}
|
||||
k := pairKey{parts[1], parts[2]}
|
||||
out[k] = append(out[k], pairRow{consumer: parts[3], module: parts[4], rec: rec})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PairOutcome is what one pass did, or did not do, for one provider and provision.
|
||||
type PairOutcome struct {
|
||||
Name string `json:"name"`
|
||||
Outcome string `json:"outcome"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// What a pass did for a provider and provision.
|
||||
const (
|
||||
PairMade = "made"
|
||||
PairRemade = "remade"
|
||||
PairAdopted = "adopted"
|
||||
PairRecorded = "recorded"
|
||||
PairUnchanged = "unchanged"
|
||||
PairHeld = "held"
|
||||
PairPerPair = "waits-for-rotation"
|
||||
PairPerHolder = "per-holder"
|
||||
)
|
||||
|
||||
// sharedHolders is who holds a provider's value now: its provider module and the consumers bound to it.
|
||||
func sharedHolders(k pairKey, b *binds) (string, []string) {
|
||||
var modules, consumers []string
|
||||
if b != nil {
|
||||
for m := range b.providerModules {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
for c := range b.consumers {
|
||||
consumers = append(consumers, c)
|
||||
}
|
||||
}
|
||||
sort.Strings(modules)
|
||||
sort.Strings(consumers)
|
||||
return strings.Join(modules, ","), consumers
|
||||
}
|
||||
|
||||
// heldBack says why the vault may not make a value for k now, or "": a holder that would never get it.
|
||||
func heldBack(k pairKey, b *binds, rows []pairRow, seen bindings) string {
|
||||
if b == nil || len(b.providerModules) == 0 {
|
||||
return fmt.Sprintf("no plan names the module serving %s on %s, so the vault cannot tell the provider gets "+
|
||||
"a value it makes", k.provision, k.provider)
|
||||
}
|
||||
for m := range b.providerModules {
|
||||
if !seen.handed[k.provider+"."+m] {
|
||||
return fmt.Sprintf("the provider %s on %s is not handed to the provisioner, so it would never get a "+
|
||||
"value the vault makes (ADR 0349 clause 17)", m, k.provider)
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
key := r.consumer + "." + r.module
|
||||
if seen.assigned[key] && !seen.handed[key] {
|
||||
return fmt.Sprintf("%s on %s holds %s from %s and is not handed to the provisioner, so it would never "+
|
||||
"get a value the vault makes (ADR 0349 clause 17)", r.module, r.consumer, k.provision, k.provider)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// oneStamp is the generation stamp every row shares, with their one origin and value, when they all share one; or
|
||||
// why not.
|
||||
func (v *Vault) oneStamp(rows []pairRow) (stamp, origin, value string, madeAt time.Time, why string) {
|
||||
for i, r := range rows {
|
||||
if r.rec.MovedFrom == nil || r.rec.MovedFrom.Generation == "" {
|
||||
return "", "", "", time.Time{}, fmt.Sprintf("%s carries no generation stamp: a login of its own (ADR 0048)",
|
||||
r.rec.Name)
|
||||
}
|
||||
opened, err := seal.Open(r.rec.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, fmt.Sprintf("%s does not open with the vault's key: %v", r.rec.Name, err)
|
||||
}
|
||||
o := r.rec.Origin
|
||||
if o == "" {
|
||||
o = OriginMade
|
||||
}
|
||||
if i == 0 {
|
||||
stamp, origin, value, madeAt = r.rec.MovedFrom.Generation, o, opened, r.rec.MadeAt
|
||||
continue
|
||||
}
|
||||
if r.rec.MovedFrom.Generation != stamp || opened != value || o != origin {
|
||||
return "", "", "", time.Time{}, fmt.Sprintf("the rows of one provider and provision carry different "+
|
||||
"generations, values or origins (%s among them): logins of their own (ADR 0048)", r.rec.Name)
|
||||
}
|
||||
if r.rec.MadeAt.Before(madeAt) {
|
||||
madeAt = r.rec.MadeAt
|
||||
}
|
||||
}
|
||||
return stamp, origin, value, madeAt, ""
|
||||
}
|
||||
|
||||
// PairPass looks at every provider and provision the plans bind or the move brought in, and makes, remakes or
|
||||
// adopts each one's shared value as the rules above say. It returns what it did for each; an error is a pass that
|
||||
// could not read what it needs, and did nothing.
|
||||
func (v *Vault) PairPass() ([]PairOutcome, error) {
|
||||
seen, err := readBindings(v.Assignments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := v.pairRows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := map[pairKey]bool{}
|
||||
for k := range seen.bound {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range rows {
|
||||
keys[k] = true
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rec := range all {
|
||||
parts := strings.Split(rec.Name, "/")
|
||||
if len(parts) == 3 && parts[0] == "provision" {
|
||||
if _, recorded, _ := v.Store.ReadShared(rec.Name); recorded {
|
||||
keys[pairKey{parts[1], parts[2]}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ordered := make([]pairKey, 0, len(keys))
|
||||
for k := range keys {
|
||||
ordered = append(ordered, k)
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].name() < ordered[j].name() })
|
||||
var out []PairOutcome
|
||||
var failed []error
|
||||
for _, k := range ordered {
|
||||
o, err := v.pairOne(k, seen.bound[k], rows[k], seen)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %w", k.name(), err))
|
||||
o = PairOutcome{Name: k.name(), Outcome: PairHeld, Why: err.Error()}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// pairOne is one provider and provision's part of a pass.
|
||||
func (v *Vault) pairOne(k pairKey, b *binds, rows []pairRow, seen bindings) (PairOutcome, error) {
|
||||
name := k.name()
|
||||
if b != nil && b.perHolder {
|
||||
return PairOutcome{Name: name, Outcome: PairPerHolder,
|
||||
Why: "a plan names a credential per holder for it (issue 757): there is no one shared value"}, nil
|
||||
}
|
||||
rec, held, err := v.Store.Read(name)
|
||||
if err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
sh, recorded, err := v.Store.ReadShared(name)
|
||||
if err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
// The provider's module, when no plan names it any more (its last consumer unbound), is the one recorded.
|
||||
if b == nil {
|
||||
b = &binds{providerModules: map[string]bool{}, consumers: map[string]bool{}}
|
||||
}
|
||||
if len(b.providerModules) == 0 && recorded && sh.ProviderModule != "" {
|
||||
for _, m := range strings.Split(sh.ProviderModule, ",") {
|
||||
b.providerModules[m] = true
|
||||
}
|
||||
}
|
||||
providerModule, consumers := sharedHolders(k, b)
|
||||
record := func(generation int, adopted string) error {
|
||||
return v.Store.WriteShared(Shared{Name: name, Provider: k.provider, Provision: k.provision,
|
||||
ProviderModule: providerModule, Consumers: consumers, Generation: generation, At: v.Now().UTC(),
|
||||
Adopted: adopted})
|
||||
}
|
||||
switch {
|
||||
case !held && len(rows) > 0:
|
||||
stamp, origin, value, madeAt, why := v.oneStamp(rows)
|
||||
if why != "" {
|
||||
// A consumer bound since the move, that has no row yet, gets a login of its own; the rows there are
|
||||
// never touched (issue 771).
|
||||
if form, formWhy := b.form(k); formWhy == "" && form == FormPair {
|
||||
have := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
have[strings.Join(strings.Split(r.rec.Name, "/")[3:], "/")] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, c := range consumers {
|
||||
if !have[c] {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if held := heldBack(k, b, rows, seen); held != "" {
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Why: held}, nil
|
||||
}
|
||||
return v.firstLogins(k, missing)
|
||||
}
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairPerPair, Why: why + "; each consumer keeps a value of its " +
|
||||
"own for ever (ADR 0048, ADR 0158 §11), and a rotation remakes each one's own, never one shared value"}, nil
|
||||
}
|
||||
// Adopted as it is: the holders the rows name hold it, and a bind since then is a remake on the next pass.
|
||||
changed, err := v.keepWith(name, value, "the vault", "", origin, keepOptions{neverReplace: true, madeAt: madeAt,
|
||||
movedFrom: &MovedFrom{By: "the move's pair rows", Generation: stamp}})
|
||||
value = ""
|
||||
if err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
var fromRows []string
|
||||
for _, r := range rows {
|
||||
parts := strings.Split(r.rec.Name, "/")
|
||||
fromRows = append(fromRows, strings.Join(parts[3:], "/"))
|
||||
}
|
||||
consumers = fromRows
|
||||
if err := record(changed.Generation, stamp); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairAdopted, Generation: changed.Generation}, nil
|
||||
case !held:
|
||||
if len(consumers) == 0 {
|
||||
return PairOutcome{Name: name, Outcome: PairUnchanged}, nil
|
||||
}
|
||||
if why := heldBack(k, b, rows, seen); why != "" {
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil
|
||||
}
|
||||
form, why := b.form(k)
|
||||
switch {
|
||||
case why != "":
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Why: why}, nil
|
||||
case form == FormShared:
|
||||
changed, err := v.mintShared(name)
|
||||
if err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
if err := record(changed.Generation, ""); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairMade, Generation: changed.Generation}, nil
|
||||
case form == FormPair:
|
||||
return v.firstLogins(k, consumers)
|
||||
case form == FormIssued:
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Why: "its credential is issued by the provider's " +
|
||||
"software (ADR 0305), which the vault never makes; `secret issued` brings it in"}, nil
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Why: "its credential is given by a person (ADR 0337), " +
|
||||
"which the vault never makes; `secret accept` brings it in"}, nil
|
||||
case rec.Origin == OriginAccepted || rec.Origin == OriginIssued:
|
||||
// Never remade: who holds it is recorded, so each gets it on request.
|
||||
if recorded && slices.Equal(sh.Consumers, consumers) && sh.ProviderModule == providerModule {
|
||||
return PairOutcome{Name: name, Outcome: PairUnchanged, Generation: rec.Generation}, nil
|
||||
}
|
||||
if err := record(rec.Generation, sh.Adopted); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairRecorded, Generation: rec.Generation,
|
||||
Why: "a value " + rec.Origin + " is not remade (ADR 0158 §8, ADR 0305 §9); its holders are recorded"}, nil
|
||||
case !recorded:
|
||||
// Held but never recorded (made by a rotation before any plan bound it): who binds it now holds it.
|
||||
if err := record(rec.Generation, ""); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairRecorded, Generation: rec.Generation}, nil
|
||||
case slices.Equal(sh.Consumers, consumers):
|
||||
return PairOutcome{Name: name, Outcome: PairUnchanged, Generation: rec.Generation}, nil
|
||||
}
|
||||
if why := heldBack(k, b, rows, seen); why != "" {
|
||||
return PairOutcome{Name: name, Outcome: PairHeld, Generation: rec.Generation, Why: why}, nil
|
||||
}
|
||||
changed, err := v.mintShared(name)
|
||||
if err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
if err := record(changed.Generation, ""); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: name, Outcome: PairRemade, Generation: changed.Generation}, nil
|
||||
}
|
||||
|
||||
// mintShared makes the provision's one credential anew, for all its holders in one act: a new generation and
|
||||
// `secret-changed`, which tells every holder to ask again.
|
||||
func (v *Vault) mintShared(name string) (Changed, error) {
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.keep(name, base64.RawURLEncoding.EncodeToString(raw), "the vault", "", OriginMade)
|
||||
}
|
||||
|
||||
// firstLogins makes each bound consumer of a per-login provider the vault holds no row of its first login of its
|
||||
// own (ADR 0048, ADR 0158 §11, issue 768): `pair/<provider>/<provision>/<consumer>/<module>[/<local>]`, a value
|
||||
// apart for each, never one shared value. Once made they are rows, and every later pass keeps them per pair.
|
||||
func (v *Vault) firstLogins(k pairKey, consumers []string) (PairOutcome, error) {
|
||||
var failed []error
|
||||
for _, c := range consumers {
|
||||
parts := strings.SplitN(c, "/", 3)
|
||||
local := ""
|
||||
if len(parts) == 3 {
|
||||
local = parts[2]
|
||||
}
|
||||
row, err := PairSecret(k.provider, k.provision, parts[0], parts[1], local)
|
||||
if err != nil {
|
||||
failed = append(failed, err)
|
||||
continue
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
if _, err := v.keepWith(row, base64.RawURLEncoding.EncodeToString(raw), "the vault", "", OriginMade,
|
||||
keepOptions{neverReplace: true}); err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %w", row, err))
|
||||
}
|
||||
}
|
||||
if err := errors.Join(failed...); err != nil {
|
||||
return PairOutcome{}, err
|
||||
}
|
||||
return PairOutcome{Name: k.name(), Outcome: PairMade, Why: fmt.Sprintf("a first login of its own for each of "+
|
||||
"%d consumers (ADR 0048, ADR 0158 §11)", len(consumers))}, nil
|
||||
}
|
||||
|
||||
// unknownForm is why the vault makes nothing for a provider and provision it has no row of while no plan names its
|
||||
// offer's credential form: whether the offer shares one credential (ADR 0158) or keeps a login per consumer (ADR
|
||||
// 0048) is the offer's, and a plan from a controller before issue 768 does not say which.
|
||||
func unknownForm(k pairKey) string {
|
||||
return fmt.Sprintf("the vault holds no row of %s from %s, and no plan says whether its offer shares one credential "+
|
||||
"(ADR 0158) or keeps a login per consumer (ADR 0048, §11); it makes nothing until it knows", k.provision,
|
||||
k.provider)
|
||||
}
|
||||
|
||||
// rotatePerPair is the rotation of a provider whose consumers each keep a login of their own (ADR 0048, ADR 0158
|
||||
// §11): every consumer's own value is remade apart, each a value of its own, and no shared value is made. A value
|
||||
// accepted or issued is left as it is. Refused whole while any holder would never get its new value. Narrowed to one
|
||||
// consumer, only that consumer's own logins are remade (issue 767), and a consumer that holds none is refused.
|
||||
func (v *Vault) rotatePerPair(name string, one Narrow, by string) (Changed, error) {
|
||||
parts := strings.Split(name, "/")
|
||||
k := pairKey{parts[1], parts[2]}
|
||||
rows, err := v.pairRows()
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if len(rows[k]) == 0 {
|
||||
return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s, and %s", name, unknownForm(k))
|
||||
}
|
||||
if _, _, _, _, why := v.oneStamp(rows[k]); why == "" {
|
||||
return Changed{}, fmt.Errorf("not rotated: the rows of %s share one value not adopted yet; the next pass "+
|
||||
"adopts it, then rotate it", name)
|
||||
}
|
||||
if why := v.rotationHeldBack(name); why != "" {
|
||||
return Changed{}, fmt.Errorf("not rotated: %s", why)
|
||||
}
|
||||
if !slices.ContainsFunc(rows[k], one.holds) {
|
||||
return Changed{}, fmt.Errorf("not rotated: %s holds no login of %s from %s", one, k.provision, k.provider)
|
||||
}
|
||||
out := Changed{Name: name, At: v.Now().UTC(), By: by}
|
||||
var failed []error
|
||||
for _, r := range rows[k] {
|
||||
if !one.holds(r) {
|
||||
continue
|
||||
}
|
||||
if r.rec.Origin == OriginAccepted || r.rec.Origin == OriginIssued {
|
||||
continue
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return out, err
|
||||
}
|
||||
changed, err := v.keep(r.rec.Name, base64.RawURLEncoding.EncodeToString(raw), by, ControllerCaller, OriginMade)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %w", r.rec.Name, err))
|
||||
continue
|
||||
}
|
||||
out.Each = append(out.Each, changed)
|
||||
}
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// rotationHeldBack says why a rotation of a provision's one credential would leave a holder behind, or "".
|
||||
func (v *Vault) rotationHeldBack(name string) string {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) != 3 || parts[0] != "provision" || v.Assignments.Keys == nil {
|
||||
return ""
|
||||
}
|
||||
k := pairKey{parts[1], parts[2]}
|
||||
seen, err := readBindings(v.Assignments)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
rows, err := v.pairRows()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return heldBack(k, seen.bound[k], rows[k], seen)
|
||||
}
|
||||
|
||||
// sharedRefusal says why caller (`provisioner.<node>`) may not have the provision's one credential name, or "": it
|
||||
// is handed to the machines the vault made it for, the provider's and each bound consumer's, while the provider's
|
||||
// assignment there is handed to the provisioner, or a consumer's plan there still names it from that provider.
|
||||
func sharedRefusal(store *Store, read AssignmentReader, node, provider, provision string) string {
|
||||
name := "provision/" + provider + "/" + provision
|
||||
if store == nil {
|
||||
return "the vault keeps no record of who holds " + name
|
||||
}
|
||||
sh, ok, err := store.ReadShared(name)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Sprintf("the vault made %s for nobody yet: it is handed to the machines its provider and its "+
|
||||
"consumers run on once their plans bind it (ADR 0349 clause 8)", name)
|
||||
}
|
||||
if !slices.Contains(sh.holderNodes(), node) {
|
||||
return fmt.Sprintf("%s is held by %s, not by a module on %s", name, strings.Join(sh.holderNodes(), ", "), node)
|
||||
}
|
||||
if read == nil {
|
||||
return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name"
|
||||
}
|
||||
if node == provider && sh.ProviderModule != "" {
|
||||
for _, m := range strings.Split(sh.ProviderModule, ",") {
|
||||
if raw, err := read(node + "." + m); err == nil && raw != nil {
|
||||
var a plannedProvisions
|
||||
if json.Unmarshal(raw, &a) == nil && a.Node == node && a.Module == m && a.Provisioning != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range sh.Consumers {
|
||||
parts := strings.SplitN(c, "/", 3)
|
||||
if parts[0] != node {
|
||||
continue
|
||||
}
|
||||
raw, err := read(node + "." + parts[1])
|
||||
if err != nil || raw == nil {
|
||||
continue
|
||||
}
|
||||
var a plannedProvisions
|
||||
if json.Unmarshal(raw, &a) != nil || a.Node != node || a.Module != parts[1] || a.Provisioning == nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range a.Provisioning.Provisions {
|
||||
if p.Name == "" && p.Provision == provision && p.Provider == provider {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("no module on %s that the vault made %s for is handed to the provisioner and bound to it now",
|
||||
node, name)
|
||||
}
|
||||
|
||||
// pairPass is one minute's PairPass, saying each outcome when it differs from the last pass's, so a provider held
|
||||
// back is said once and again when it changes, not every minute.
|
||||
func (e *Env) pairPass() {
|
||||
if e.Vault == nil {
|
||||
return
|
||||
}
|
||||
outcomes, err := e.Vault.PairPass()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if err != nil && err.Error() != e.lastPairFailure {
|
||||
say("the pass over the providers' shared credentials did not finish: %v", err)
|
||||
}
|
||||
e.lastPairFailure = ""
|
||||
if err != nil {
|
||||
e.lastPairFailure = err.Error()
|
||||
}
|
||||
if e.pairsSaid == nil {
|
||||
e.pairsSaid = map[string]string{}
|
||||
}
|
||||
for _, o := range outcomes {
|
||||
said := o.Outcome + ": " + o.Why
|
||||
if o.Outcome == PairUnchanged || e.pairsSaid[o.Name] == said {
|
||||
continue
|
||||
}
|
||||
e.pairsSaid[o.Name] = said
|
||||
if o.Why == "" {
|
||||
say("%s %s (generation %d)", o.Name, o.Outcome, o.Generation)
|
||||
} else {
|
||||
say("%s %s: %s", o.Name, o.Outcome, o.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pairRowRefusal says why caller's machine node may not have the pair row name (`pair/<provider>/<provision>/
|
||||
// <consumer>/<module>[/<local>]`, a consumer's own login, ADR 0048), or "": it is handed to that consumer's machine
|
||||
// while the consumer's plan there names the provision from that provider under that local name, and to the
|
||||
// provider's machine while the provider module that plan names is handed to the provisioner there. Another
|
||||
// consumer, on the same machine or not, is refused: each gets only its own.
|
||||
func pairRowRefusal(read AssignmentReader, node string, parts []string) string {
|
||||
provider, provision, consumer, module, local := parts[1], parts[2], parts[3], parts[4], ""
|
||||
if len(parts) == 6 {
|
||||
local = parts[5]
|
||||
}
|
||||
if node != consumer && node != provider {
|
||||
return fmt.Sprintf("%s is the login of %s on %s from %s, and %s is neither", strings.Join(parts, "/"),
|
||||
module, consumer, provider, node)
|
||||
}
|
||||
if read == nil {
|
||||
return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name"
|
||||
}
|
||||
raw, err := read(consumer + "." + module)
|
||||
if err != nil || raw == nil {
|
||||
return fmt.Sprintf("%s is not on %s", module, consumer)
|
||||
}
|
||||
var a plannedProvisions
|
||||
if json.Unmarshal(raw, &a) != nil || a.Node != consumer || a.Module != module {
|
||||
return fmt.Sprintf("the assignment %s.%s is not the controller's", consumer, module)
|
||||
}
|
||||
if a.Provisioning == nil {
|
||||
return fmt.Sprintf("%s on %s is not handed to the provisioner, so its login reaches it in its declaration "+
|
||||
"(ADR 0349 clause 17)", module, consumer)
|
||||
}
|
||||
for _, p := range a.Provisioning.Provisions {
|
||||
// An unnamed entry, or a named one of the same form (the consumer's login file, issue 779): one rule.
|
||||
if (p.Name != "" && p.CredentialForm != FormPair) || p.Provision != provision ||
|
||||
p.Provider != provider || p.Local != local {
|
||||
continue
|
||||
}
|
||||
if node == consumer {
|
||||
return ""
|
||||
}
|
||||
if p.ProviderModule != "" {
|
||||
if praw, err := read(provider + "." + p.ProviderModule); err == nil && praw != nil {
|
||||
var pa plannedProvisions
|
||||
if json.Unmarshal(praw, &pa) == nil && pa.Node == provider && pa.Provisioning != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("the provider of %s on %s is not handed to the provisioner", provision, provider)
|
||||
}
|
||||
return fmt.Sprintf("%s's plan on %s does not name %s from %s under that name", module, consumer, provision, provider)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package main
|
||||
|
||||
// ADR 0349's provider test, the vault's part (design 57 V5, issue 677), with a stand-in provider: a consumer binding
|
||||
// to it remakes its one credential without the provider asking, and a consumer unbinding remakes it for the holders
|
||||
// left; every holder gets it on request after `secret-changed`; an accepted or issued value is not remade; what the
|
||||
// move brought in is adopted when its rows share a generation stamp, and otherwise waits for the provider's
|
||||
// rotation; and no value is made that a holder still filled the old way would never get.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// keys is every key of a stand-in mirror.
|
||||
func (a assignments) keys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range a {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// handedWith is an assignment handed to the provisioner, its plan naming each provision given as
|
||||
// provision@provider/provider-module[#local][=name][+credential-form].
|
||||
func handedWith(node, module string, provisions ...string) string {
|
||||
var entries []string
|
||||
for _, p := range provisions {
|
||||
form := ""
|
||||
if i := strings.Index(p, "+"); i >= 0 {
|
||||
p, form = p[:i], p[i+1:]
|
||||
}
|
||||
name := ""
|
||||
if i := strings.Index(p, "="); i >= 0 {
|
||||
p, name = p[:i], p[i+1:]
|
||||
}
|
||||
local := ""
|
||||
if i := strings.Index(p, "#"); i >= 0 {
|
||||
p, local = p[:i], p[i+1:]
|
||||
}
|
||||
provision, rest, _ := strings.Cut(p, "@")
|
||||
provider, providerModule, _ := strings.Cut(rest, "/")
|
||||
e := `{"provision":"` + provision + `","provider":"` + provider + `","provider-module":"` + providerModule + `"`
|
||||
if local != "" {
|
||||
e += `,"local":"` + local + `"`
|
||||
}
|
||||
if name != "" {
|
||||
e += `,"name":"` + name + `","path":"/s/` + name + `"`
|
||||
}
|
||||
if form != "" {
|
||||
e += `,"credential-form":"` + form + `"`
|
||||
}
|
||||
entries = append(entries, e+"}")
|
||||
}
|
||||
return `{"node":"` + node + `","module":"` + module + `","provisioning":{"templates":{"source":"x","digest":"y"},` +
|
||||
`"files":[],"provisions":[` + strings.Join(entries, ",") + `]}}`
|
||||
}
|
||||
|
||||
// unmoved is an assignment the controller has not handed to the provisioner.
|
||||
func unmoved(node, module string) string {
|
||||
return `{"node":"` + node + `","module":"` + module + `"}`
|
||||
}
|
||||
|
||||
const standIn = "provision/anchor/stand-in-api"
|
||||
|
||||
// pairVault is a vault reading mirror for its binds and its rule.
|
||||
func pairVault(t *testing.T, mirror assignments) testVault {
|
||||
t.Helper()
|
||||
v := newVault(t, nil)
|
||||
v.Askers = Declared{Read: mirror.read, Store: v.Store}
|
||||
v.Assignments = Mirror{Keys: mirror.keys, Read: mirror.read}
|
||||
return v
|
||||
}
|
||||
|
||||
// pass runs one pass and is the outcome for name.
|
||||
func pass(t *testing.T, v testVault, name string) PairOutcome {
|
||||
t.Helper()
|
||||
outcomes, err := v.PairPass()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, o := range outcomes {
|
||||
if o.Name == name {
|
||||
return o
|
||||
}
|
||||
}
|
||||
t.Fatalf("the pass said nothing of %s: %+v", name, outcomes)
|
||||
return PairOutcome{}
|
||||
}
|
||||
|
||||
// asks is what caller is handed for name, opened, or the refusal.
|
||||
func asks(v testVault, caller, name string) (string, int, error) {
|
||||
key, _ := seal.GenerateKeyPair()
|
||||
a, err := v.Get(caller, name, key.PublicKey)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
got, err := seal.Open(a.Sealed, key.PrivateKey)
|
||||
return got, a.Generation, err
|
||||
}
|
||||
|
||||
// changedSaid is how many times `secret-changed` was said for name, and that none carried a value.
|
||||
func changedSaid(t *testing.T, v testVault, name, value string) int {
|
||||
t.Helper()
|
||||
v.bus.mu.Lock()
|
||||
defer v.bus.mu.Unlock()
|
||||
n := 0
|
||||
for i, e := range v.bus.events {
|
||||
if value != "" && strings.Contains(v.bus.raw[i], value) {
|
||||
t.Fatalf("the bus carried the value: %s", v.bus.raw[i])
|
||||
}
|
||||
if e["event"] == ChangedEvent && e["body"].(map[string]any)["name"] == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestAConsumerBindingOrUnbindingRemakesTheProvidersOneCredentialForEveryHolder(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "shared-from-the-move", "stamp-1", OriginMade)
|
||||
|
||||
// The move's shared value is adopted, and said to have changed in the vault.
|
||||
if o := pass(t, v, standIn); o.Outcome != PairAdopted || o.Generation != 1 {
|
||||
t.Fatalf("the first pass: %+v", o)
|
||||
}
|
||||
first, _, err := asks(v, "provisioner.anchor", standIn)
|
||||
if err != nil {
|
||||
t.Fatalf("the provider's machine was refused its credential: %v", err)
|
||||
}
|
||||
if got, _, err := asks(v, "provisioner.g14", standIn); err != nil || got != first {
|
||||
t.Fatalf("the consumer got %q (%v), the provider %q", got, err, first)
|
||||
}
|
||||
if first != "shared-from-the-move" || changedSaid(t, v, standIn, "") != 1 {
|
||||
t.Fatal("the adopted value is not the move's, or secret-changed was not said once for it")
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.ace", standIn); err == nil {
|
||||
t.Fatal("a machine holding nothing of it was handed the credential")
|
||||
}
|
||||
|
||||
// Nothing changed: nothing is remade.
|
||||
if o := pass(t, v, standIn); o.Outcome != PairUnchanged {
|
||||
t.Fatalf("a pass with no bind or unbind: %+v", o)
|
||||
}
|
||||
|
||||
// A second consumer binds: the vault remakes it without the provider asking, one new value for all three.
|
||||
mirror["ace.other"] = handedWith("ace", "other", "stand-in-api@anchor/stand-in")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade || o.Generation != 2 {
|
||||
t.Fatalf("a consumer binding: %+v", o)
|
||||
}
|
||||
second, gen, _ := asks(v, "provisioner.anchor", standIn)
|
||||
if second == first || gen != 2 {
|
||||
t.Fatal("the value was not remade when a consumer bound")
|
||||
}
|
||||
for _, c := range []string{"provisioner.g14", "provisioner.ace"} {
|
||||
if got, _, err := asks(v, c, standIn); err != nil || got != second {
|
||||
t.Fatalf("%s got %q (%v) after the bind, not the one new value", c, got, err)
|
||||
}
|
||||
}
|
||||
if changedSaid(t, v, standIn, "") != 2 {
|
||||
t.Fatal("secret-changed was not said for the remade value")
|
||||
}
|
||||
|
||||
// It unbinds (its plan no longer names it): remade for the holders left, and it is handed nothing more.
|
||||
mirror["ace.other"] = handedWith("ace", "other")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade || o.Generation != 3 {
|
||||
t.Fatalf("a consumer unbinding: %+v", o)
|
||||
}
|
||||
third, _, _ := asks(v, "provisioner.anchor", standIn)
|
||||
if got, _, _ := asks(v, "provisioner.g14", standIn); third == second || got != third {
|
||||
t.Fatal("the holders left do not share one new value after the unbind")
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.ace", standIn); err == nil {
|
||||
t.Fatal("a consumer that unbound was still handed the credential")
|
||||
}
|
||||
|
||||
// Its assignment goes: an unbind too.
|
||||
delete(mirror, "g14.app")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade {
|
||||
t.Fatalf("a consumer taken off its machine: %+v", o)
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.g14", standIn); err == nil {
|
||||
t.Fatal("a machine whose consumer left was still handed the credential")
|
||||
}
|
||||
}
|
||||
|
||||
// Two local names of one provision on one consumer are two binds of the same value; a credential per holder (issue
|
||||
// 757) has no one shared value.
|
||||
func TestLocalNamesAreBindsAndAPerHolderCredentialIsNotShared(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in#reader"),
|
||||
"novox.gitea": handedWith("novox", "gitea"),
|
||||
"g14.claude-code": handedWith("g14", "claude-code", "gitea-read@novox/gitea=gitea-read"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade, "reader")
|
||||
pass(t, v, standIn)
|
||||
mirror["g14.app"] = handedWith("g14", "app", "stand-in-api@anchor/stand-in#reader",
|
||||
"stand-in-api@anchor/stand-in#writer")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade {
|
||||
t.Fatalf("a second local name binding: %+v", o)
|
||||
}
|
||||
if o := pass(t, v, "provision/novox/gitea-read"); o.Outcome != PairPerHolder {
|
||||
t.Fatalf("a credential per holder: %+v", o)
|
||||
}
|
||||
if _, held, _ := v.Store.Read("provision/novox/gitea-read"); held {
|
||||
t.Fatal("a shared value was made for a credential issued per holder")
|
||||
}
|
||||
}
|
||||
|
||||
// An accepted value is opened by the vault alone and not remade (ADR 0158 §8), nor is one the provider's software
|
||||
// issued (ADR 0305 §9): a bind records its holder, who gets the value as it is.
|
||||
func TestAnAcceptedOrIssuedValueIsNotRemadeOnABind(t *testing.T) {
|
||||
for _, origin := range []string{OriginAccepted, OriginIssued} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if _, err := v.keep(standIn, "given-by-a-person", "person.jochen", ControllerCaller, origin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRecorded || o.Generation != 1 {
|
||||
t.Fatalf("the first pass: %+v", o)
|
||||
}
|
||||
mirror["ace.other"] = handedWith("ace", "other", "stand-in-api@anchor/stand-in")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRecorded || o.Generation != 1 {
|
||||
t.Fatalf("a bind to a value %s: %+v", origin, o)
|
||||
}
|
||||
for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} {
|
||||
if got, gen, err := asks(v, c, standIn); err != nil || got != "given-by-a-person" || gen != 1 {
|
||||
t.Fatalf("%s got %q generation %d (%v)", c, got, gen, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// movedRow is a pair row as the move (V3) keeps it: its value, the controller's generation stamp and origin.
|
||||
func movedRow(t *testing.T, v testVault, consumer, module, value, stamp, origin string, local ...string) {
|
||||
t.Helper()
|
||||
name, err := PairSecret("anchor", "stand-in-api", consumer, module, strings.Join(local, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.keepWith(name, value, "node."+consumer, "", origin, keepOptions{neverReplace: true,
|
||||
madeAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
|
||||
movedFrom: &MovedFrom{By: "node." + consumer, Generation: stamp}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows a provider shares carry one generation stamp (ADR 0158): the vault adopts their value as the provision's one
|
||||
// credential, keeps its origin and when it was made, and remakes nothing, so the move rotates no credential.
|
||||
func TestPairRowsSharingAGenerationStampAreAdoptedAndNothingIsRemade(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "the-value-they-share", "20260901T000000-1", OriginMade)
|
||||
movedRow(t, v, "ace", "other", "the-value-they-share", "20260901T000000-1", OriginMade)
|
||||
|
||||
if o := pass(t, v, standIn); o.Outcome != PairAdopted || o.Generation != 1 {
|
||||
t.Fatalf("stamped rows: %+v", o)
|
||||
}
|
||||
for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} {
|
||||
if got, _, err := asks(v, c, standIn); err != nil || got != "the-value-they-share" {
|
||||
t.Fatalf("%s got %q (%v), not the value it had", c, got, err)
|
||||
}
|
||||
}
|
||||
rec, _, _ := v.Store.Read(standIn)
|
||||
if rec.Origin != OriginMade || !rec.MadeAt.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) ||
|
||||
rec.MovedFrom == nil || rec.MovedFrom.Generation != "20260901T000000-1" {
|
||||
t.Fatalf("the adopted value does not say where it came from: %+v", rec)
|
||||
}
|
||||
if o := pass(t, v, standIn); o.Outcome != PairUnchanged {
|
||||
t.Fatalf("the pass after the adoption: %+v", o)
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 {
|
||||
t.Fatal("the adopted value was remade with no bind or unbind")
|
||||
}
|
||||
}
|
||||
|
||||
// Rows of a provider with a login per consumer (ADR 0048) carry no stamp: each consumer keeps a value of its own
|
||||
// for ever (ADR 0158 §11, issue 677's note 12). A bind makes no shared value, and nor does a rotation.
|
||||
func TestUnstampedPairRowsAreNeverMadeOneSharedValue(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "login-of-app", "", OriginMade)
|
||||
movedRow(t, v, "ace", "other", "login-of-other", "", OriginMade)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if o := pass(t, v, standIn); o.Outcome != PairPerPair {
|
||||
t.Fatalf("unstamped rows, pass %d: %+v", i, o)
|
||||
}
|
||||
}
|
||||
mirror["novox.third"] = handedWith("novox", "third", "stand-in-api@anchor/stand-in")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairPerPair {
|
||||
t.Fatalf("a bind to a provider with logins of their own: %+v", o)
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "its ordinary rotation"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatal("a shared value was made for a provider whose consumers keep logins of their own")
|
||||
}
|
||||
if changedSaid(t, v, standIn, "") != 0 {
|
||||
t.Fatal("secret-changed was said for a shared value")
|
||||
}
|
||||
}
|
||||
|
||||
// Two consumers of postgres-database (a login each, ADR 0048) hold different values after a rotation, each remade
|
||||
// apart, and each consumer's machine is handed only its own; the provider's machine is handed both.
|
||||
func TestARotationRemakesEachPostgresConsumersOwnLoginApart(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"novox.postgres": handedWith("novox", "postgres"),
|
||||
"g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres"),
|
||||
"ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami"
|
||||
for name, value := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} {
|
||||
if _, err := v.keepWith(name, value, "node.novox", "", OriginMade, keepOptions{neverReplace: true,
|
||||
movedFrom: &MovedFrom{By: "node.novox"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
changed, err := v.Rotate(ControllerCaller, "provision/novox/postgres-database", "person.jochen", "yearly")
|
||||
if err != nil || len(changed.Each) != 2 {
|
||||
t.Fatalf("the rotation: %+v (%v)", changed, err)
|
||||
}
|
||||
ofGitea, _, err := asks(v, "provisioner.g14", gitea)
|
||||
if err != nil {
|
||||
t.Fatalf("gitea's machine was refused its own login: %v", err)
|
||||
}
|
||||
ofUmami, _, err := asks(v, "provisioner.ace", umami)
|
||||
if err != nil {
|
||||
t.Fatalf("umami's machine was refused its own login: %v", err)
|
||||
}
|
||||
if ofGitea == ofUmami || ofGitea == "login-of-gitea" || ofUmami == "login-of-umami" {
|
||||
t.Fatalf("after the rotation gitea holds %q and umami %q: not two new values of their own", ofGitea, ofUmami)
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.g14", umami); err == nil {
|
||||
t.Fatal("gitea's machine was handed umami's login")
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.ace", gitea); err == nil {
|
||||
t.Fatal("umami's machine was handed gitea's login")
|
||||
}
|
||||
for name, want := range map[string]string{gitea: ofGitea, umami: ofUmami} {
|
||||
if got, _, err := asks(v, "provisioner.novox", name); err != nil || got != want {
|
||||
t.Fatalf("the provider's machine got %q for %s (%v)", got, name, err)
|
||||
}
|
||||
}
|
||||
if _, held, _ := v.Store.Read("provision/novox/postgres-database"); held {
|
||||
t.Fatal("a shared value was made for postgres-database")
|
||||
}
|
||||
}
|
||||
|
||||
// A provider and provision the vault holds no row of: whether its offer shares one credential or keeps a login per
|
||||
// consumer is not said where the vault reads, so it makes nothing rather than guess.
|
||||
func TestAProviderTheVaultHoldsNoRowOfIsNotGuessed(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "no plan says whether") {
|
||||
t.Fatalf("a provider with no row: %+v", o)
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil {
|
||||
t.Fatal("a rotation guessed the form of a provider with no row")
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatal("a value was made for a provider whose form the vault does not know")
|
||||
}
|
||||
}
|
||||
|
||||
// The vault makes no value a holder would never get (the lead's rule on issue 677): a provider not handed to the
|
||||
// provisioner, or a consumer the move brought in that is still filled the old way, holds every make and remake
|
||||
// back, and a rotation too.
|
||||
func TestNoValueIsMadeThatAHolderFilledTheOldWayWouldNeverGet(t *testing.T) {
|
||||
t.Run("an unmoved provider", func(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": unmoved("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairHeld || !strings.Contains(o.Why, "stand-in on anchor") {
|
||||
t.Fatalf("an unmoved provider: %+v", o)
|
||||
}
|
||||
if _, held, _ := v.Store.Read(standIn); held {
|
||||
t.Fatal("a value was made that the provider would never get")
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil {
|
||||
t.Fatal("a rotation made a value the provider would never get")
|
||||
}
|
||||
})
|
||||
t.Run("an unmoved consumer", func(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
"ace.old": unmoved("ace", "old"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade)
|
||||
movedRow(t, v, "ace", "old", "shared", "stamp-1", OriginMade)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairAdopted {
|
||||
t.Fatalf("adopting changes no value, so it is not held back: %+v", o)
|
||||
}
|
||||
mirror["novox.new"] = handedWith("novox", "new", "stand-in-api@anchor/stand-in")
|
||||
o := pass(t, v, standIn)
|
||||
if o.Outcome != PairHeld || !strings.Contains(o.Why, "old on ace") {
|
||||
t.Fatalf("a bind while a consumer is filled the old way: %+v", o)
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "shared" || gen != 1 {
|
||||
t.Fatal("the value was remade while a consumer filled the old way holds it")
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, standIn, "person.jochen", "why not"); err == nil ||
|
||||
!strings.Contains(err.Error(), "old on ace") {
|
||||
t.Fatalf("a rotation while a consumer is filled the old way: %v", err)
|
||||
}
|
||||
// Once it moves, the remake the bind asked for follows.
|
||||
mirror["ace.old"] = handedWith("ace", "old", "stand-in-api@anchor/stand-in")
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade {
|
||||
t.Fatalf("after the consumer moved: %+v", o)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A pass that cannot read every assignment makes nothing: a bind it missed would read as an unbind.
|
||||
func TestAPassThatCannotReadTheAssignmentsMakesNothing(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade)
|
||||
pass(t, v, standIn)
|
||||
mirror["g14.broken"] = "not json"
|
||||
if _, err := v.PairPass(); err == nil {
|
||||
t.Fatal("a pass over an unreadable assignment said nothing")
|
||||
}
|
||||
v.Assignments.Keys = func() ([]string, error) { return nil, errors.New("the bus is away") }
|
||||
if _, err := v.PairPass(); err == nil {
|
||||
t.Fatal("a pass that could not list the assignments said nothing")
|
||||
}
|
||||
if _, gen, _ := asks(v, "provisioner.g14", standIn); gen != 1 {
|
||||
t.Fatal("a pass that could not read the assignments remade the value")
|
||||
}
|
||||
}
|
||||
|
||||
// A module only pulled in by another has a value of its own in the mirror (issue 765), naming what requires it: its
|
||||
// plan binds like an assigned module's.
|
||||
func TestAPulledInConsumerBindsLikeAnAssignedOne(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
}
|
||||
v := pairVault(t, mirror)
|
||||
movedRow(t, v, "g14", "app", "shared", "stamp-1", OriginMade)
|
||||
pass(t, v, standIn)
|
||||
mirror["ace.helper"] = strings.Replace(handedWith("ace", "helper", "stand-in-api@anchor/stand-in"),
|
||||
`"module":"helper",`, `"module":"helper","required-by":"desk",`, 1)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairRemade {
|
||||
t.Fatalf("a pulled-in consumer binding: %+v", o)
|
||||
}
|
||||
if _, _, err := asks(v, "provisioner.ace", standIn); err != nil {
|
||||
t.Fatalf("the pulled-in consumer's machine was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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). The forge mints one read token per agent account (ADR 0365 clause 4): one value per provision, sealed to
|
||||
// every holder alike, cannot carry that. So an issuing provider's tool may answer one value per holder — a holder
|
||||
// being a consuming module on one machine — and the vault keeps each as that holder's own and hands it to that
|
||||
// holder alone. A tool that answers one value keeps the provision's one credential, as before.
|
||||
//
|
||||
// Names in the store: `provision/<provider node>/<provision>/<holder node>/<holder module>`.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Holder is who one issued value is for: a consuming module on one machine.
|
||||
type Holder struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
func (h Holder) String() string { return h.Module + " on " + h.Node }
|
||||
|
||||
// HolderSecret is the vault's name for the value a provider's software issued for one holder of a provision.
|
||||
func HolderSecret(provider, provision string, h Holder) (string, error) {
|
||||
base, err := ProvisionSecret(provider, provision)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.valid(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base + "/" + h.Node + "/" + h.Module, nil
|
||||
}
|
||||
|
||||
func (h Holder) valid() error {
|
||||
if !namePart.MatchString(h.Node) {
|
||||
return fmt.Errorf("the holder's machine %q is not a name the mesh writes", h.Node)
|
||||
}
|
||||
if !namePart.MatchString(h.Module) {
|
||||
return fmt.Errorf("the holder's module %q is not a name the mesh writes", h.Module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// holderOf is the holder a secret's name is for, when it is one holder's issued value.
|
||||
func holderOf(name string) (Holder, bool) {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) != 5 || parts[0] != "provision" {
|
||||
return Holder{}, false
|
||||
}
|
||||
return Holder{Node: parts[3], Module: parts[4]}, true
|
||||
}
|
||||
|
||||
// isTheHolder is whether caller, the bus user the runtime proved, is the holder: its machine's provisioner client
|
||||
// (`provisioner.<node>`, an account of its own, mesh-controller internal/broker) or the module itself under an
|
||||
// account of its own (`<node>.<module>`, ADR 0259 §8's runs-as). Any other caller is not, whatever else allows it.
|
||||
func isTheHolder(caller string, h Holder) bool {
|
||||
return caller == "provisioner."+h.Node || caller == h.Node+"."+h.Module
|
||||
}
|
||||
|
||||
// notTheHolder says why caller may not have a holder's value, or "".
|
||||
func notTheHolder(caller, name string) string {
|
||||
h, per := holderOf(name)
|
||||
if !per || isTheHolder(caller, h) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s was issued for %s alone, and is handed only to that holder: provisioner.%s or %s.%s",
|
||||
name, h, h.Node, h.Node, h.Module)
|
||||
}
|
||||
|
||||
// checkHolders refuses a list of holders that is not names the mesh writes, each once.
|
||||
func checkHolders(holders []Holder) error {
|
||||
seen := map[Holder]bool{}
|
||||
for _, h := range holders {
|
||||
if err := h.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[h] {
|
||||
return fmt.Errorf("%s is named twice", h)
|
||||
}
|
||||
seen[h] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssuedHolder is what the vault kept for one holder. Never the value.
|
||||
type IssuedHolder struct {
|
||||
Holder
|
||||
Secret string `json:"secret"`
|
||||
Generation int `json:"generation"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
}
|
||||
|
||||
// sealedFor is one holder's value as the tool answers it: sealed to the call's key.
|
||||
type sealedFor struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Sealed string `json:"sealed"`
|
||||
}
|
||||
|
||||
// takePerHolder opens every holder's value the tool sealed to the call's key and keeps each as that holder's own.
|
||||
// It takes the answer whole or not at all: a holder not asked about, a holder twice, a value not sealed to the call
|
||||
// or an empty one keeps nothing for anybody. A holder asked about and not answered is said, and keeps nothing.
|
||||
func (v *Vault) takePerHolder(caller, call, provider, provision string, asked []Holder, answered []sealedFor) (Issued, error) {
|
||||
c, err := v.takeCall("issued", call)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
defer func() { c.private = [32]byte{} }()
|
||||
if len(asked) == 0 {
|
||||
return Issued{}, fmt.Errorf("%s answered a value per holder, and no holder was named to it; nothing was taken", c.by)
|
||||
}
|
||||
want := map[Holder]bool{}
|
||||
for _, h := range asked {
|
||||
want[h] = true
|
||||
}
|
||||
type opened struct {
|
||||
holder Holder
|
||||
secret string
|
||||
value string
|
||||
}
|
||||
var all []opened
|
||||
got := map[Holder]bool{}
|
||||
for _, a := range answered {
|
||||
h := Holder{Node: a.Node, Module: a.Module}
|
||||
switch {
|
||||
case !want[h]:
|
||||
return Issued{}, fmt.Errorf("%s answered a value for %s, which it was not asked about; nothing was taken", c.by, h)
|
||||
case got[h]:
|
||||
return Issued{}, fmt.Errorf("%s answered %s twice; nothing was taken", c.by, h)
|
||||
}
|
||||
got[h] = true
|
||||
raw, err := c.openOne(a.Sealed)
|
||||
if err != nil {
|
||||
return Issued{}, fmt.Errorf("%s's value: %w", h, err)
|
||||
}
|
||||
value := strings.TrimSpace(string(raw))
|
||||
zero(raw)
|
||||
if value == "" {
|
||||
return Issued{}, fmt.Errorf("%s answered an empty value for %s; nothing was taken", c.by, h)
|
||||
}
|
||||
secret, err := HolderSecret(provider, provision, h)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
all = append(all, opened{holder: h, secret: secret, value: value})
|
||||
}
|
||||
base, _ := ProvisionSecret(provider, provision)
|
||||
out := Issued{Secret: base, Unchanged: true}
|
||||
var failed []error
|
||||
for i := range all {
|
||||
changed, err := v.keep(all[i].secret, all[i].value, c.by, caller, OriginIssued)
|
||||
all[i].value = ""
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %w", all[i].holder, err))
|
||||
continue
|
||||
}
|
||||
out.Holders = append(out.Holders, IssuedHolder{Holder: all[i].holder, Secret: changed.Name,
|
||||
Generation: changed.Generation, Unchanged: changed.Unchanged})
|
||||
if !changed.Unchanged {
|
||||
out.Unchanged = false
|
||||
}
|
||||
}
|
||||
for _, h := range asked {
|
||||
if !got[h] {
|
||||
out.NotAnswered = append(out.NotAnswered, h)
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return out, fmt.Errorf("not every holder's value was kept: %w", errors.Join(failed...))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// holdersArg reads the holders the controller names, refusing anything else.
|
||||
func holdersArg(a map[string]any) ([]Holder, error) {
|
||||
raw, ok := a["holders"]
|
||||
if !ok || raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("holders is a list of {node, module}")
|
||||
}
|
||||
out := make([]Holder, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("holders is a list of {node, module}")
|
||||
}
|
||||
node, nok := m["node"].(string)
|
||||
module, mok := m["module"].(string)
|
||||
if !nok || !mok || len(m) != 2 {
|
||||
return nil, errors.New("each holder is {node, module} and nothing else")
|
||||
}
|
||||
out = append(out, Holder{Node: strings.TrimSpace(node), Module: strings.TrimSpace(module)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package main
|
||||
|
||||
// Issue 749's proof on a real bus: a NATS server whose users are the accounts of the mesh's parts (the vault's own,
|
||||
// the provider's machine's runtime, each machine's provisioner client), each allowed only its own subjects, so the
|
||||
// caller a tool sees is the one the bus proved — as the mesh's runtime does (subjects ending in the caller's token,
|
||||
// which only that caller may publish; mesh-controller internal/broker). A listener on every subject keeps every byte
|
||||
// that crosses.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// callerToken is a caller's name as the last token of a subject.
|
||||
func callerToken(user string) string { return strings.ReplaceAll(user, ".", "_") }
|
||||
|
||||
func untoken(token string) string { return strings.ReplaceAll(token, "_", ".") }
|
||||
|
||||
const busConf = `listen: 127.0.0.1:-1
|
||||
authorization {
|
||||
users = [
|
||||
{ user: "novox.mesh-vault", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.mod.gitea.call.gitea_agent_token.novox.novox_mesh-vault"] }
|
||||
subscribe: { allow: ["mesh.seat.mesh-vault.call.get.*", "_INBOX.novox.mesh-vault.>"] }
|
||||
allow_responses: true } }
|
||||
{ user: "novox.node-tools", password: "pw", permissions: {
|
||||
publish: { deny: [">"] }
|
||||
subscribe: { allow: ["mesh.mod.gitea.call.gitea_agent_token.novox.*"] }
|
||||
allow_responses: true } }
|
||||
{ user: "provisioner.anchor", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.get.provisioner_anchor"] }
|
||||
subscribe: { allow: ["_INBOX.provisioner.anchor.>"] } } }
|
||||
{ user: "provisioner.laptop", password: "pw", permissions: {
|
||||
publish: { allow: ["mesh.seat.mesh-vault.call.get.provisioner_laptop"] }
|
||||
subscribe: { allow: ["_INBOX.provisioner.laptop.>"] } } }
|
||||
{ user: "tap", password: "pw", permissions: { publish: { deny: [">"] }, subscribe: { allow: [">"] } } }
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
type busAnswer struct {
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func connectAs(t *testing.T, url, user string) *nats.Conn {
|
||||
t.Helper()
|
||||
conn, err := nats.Connect(url, nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(*nats.Conn, *nats.Subscription, error) {}))
|
||||
if err != nil {
|
||||
t.Fatalf("%s cannot connect: %v", user, err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
return conn
|
||||
}
|
||||
|
||||
// forgeOnABus is the forge's read tokens issued into a vault keeping rule, on a real bus: what a machine's client is
|
||||
// answered when it asks over the bus with a key of its own (opened), and every byte that crossed.
|
||||
func forgeOnABus(t *testing.T, rule Askers) (ask func(user, name string) (string, error), heard func() string) {
|
||||
t.Helper()
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte(busConf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
url := s.ClientURL()
|
||||
|
||||
// Every byte that crosses.
|
||||
var mu sync.Mutex
|
||||
var crossed []string
|
||||
tap := connectAs(t, url, "tap")
|
||||
if _, err := tap.Subscribe(">", func(m *nats.Msg) {
|
||||
mu.Lock()
|
||||
crossed = append(crossed, m.Subject+" "+string(m.Data))
|
||||
mu.Unlock()
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = tap.Flush()
|
||||
|
||||
// The forge's machine's runtime serves the stand-in issuing tool, naming the caller the subject proves.
|
||||
tool := perHolderProvider(agentTokens, nil)
|
||||
runtime := connectAs(t, url, "novox.node-tools")
|
||||
if _, err := runtime.Subscribe("mesh.mod.gitea.call.gitea_agent_token.novox.*", func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
out, err := tool(untoken(parts[len(parts)-1]), args)
|
||||
answer := busAnswer{}
|
||||
if err != nil {
|
||||
answer.Error = err.Error()
|
||||
} else {
|
||||
answer.Result, _ = json.Marshal(out)
|
||||
}
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = m.Respond(raw)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runtime.Flush()
|
||||
|
||||
// The vault, on its own account: it asks the tool over the bus in its own name, and answers `get` to the caller
|
||||
// the subject proves. Its rule for who may ask would let both clients ask for both values: its rule for a
|
||||
// holder's value is what keeps each to its own.
|
||||
v := newVault(t, rule)
|
||||
vaultConn := connectAs(t, url, "novox.mesh-vault")
|
||||
v.Ask = func(key string, body any) (json.RawMessage, error) {
|
||||
call, node, _ := strings.Cut(key, "@")
|
||||
module, tl, _ := strings.Cut(call, ".")
|
||||
raw, _ := json.Marshal(body)
|
||||
msg, err := vaultConn.Request(fmt.Sprintf("mesh.mod.%s.call.%s.%s.%s", module, tl, node,
|
||||
callerToken("novox.mesh-vault")), raw, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a busAnswer
|
||||
if err := json.Unmarshal(msg.Data, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Error != "" {
|
||||
return nil, fmt.Errorf("%s", a.Error)
|
||||
}
|
||||
return a.Result, nil
|
||||
}
|
||||
if _, err := vaultConn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) {
|
||||
parts := strings.Split(m.Subject, ".")
|
||||
var args struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
answer := busAnswer{}
|
||||
got, err := v.Get(untoken(parts[len(parts)-1]), args.Name, args.PublicKey)
|
||||
if err != nil {
|
||||
answer.Error = err.Error()
|
||||
} else {
|
||||
answer.Result, _ = json.Marshal(got)
|
||||
}
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = m.Respond(raw)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vaultConn.Flush()
|
||||
|
||||
// The controller's `secret issued` for the forge's read tokens, naming both holders.
|
||||
issued, err := v.TakeIssuedFor(ControllerCaller, "gitea-read", "novox", "gitea", "gitea_agent_token", twoAgents)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(issued.Holders) != 2 {
|
||||
t.Fatalf("kept %+v", issued)
|
||||
}
|
||||
|
||||
// Each machine's client asks over the bus, with a key of its own.
|
||||
ask = func(user, name string) (string, error) {
|
||||
conn := connectAs(t, url, user)
|
||||
key, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]string{"name": name, "public_key": key.PublicKey})
|
||||
msg, err := conn.Request("mesh.seat.mesh-vault.call.get."+callerToken(user), raw, 3*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a busAnswer
|
||||
if err := json.Unmarshal(msg.Data, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Error != "" {
|
||||
return "", fmt.Errorf("%s", a.Error)
|
||||
}
|
||||
var got Answer
|
||||
if err := json.Unmarshal(a.Result, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return seal.Open(got.Sealed, key.PrivateKey)
|
||||
}
|
||||
// No client can ask in another's name: the bus refuses laptop's client anchor's subject.
|
||||
laptop := connectAs(t, url, "provisioner.laptop")
|
||||
key, _ := seal.GenerateKeyPair()
|
||||
raw, _ := json.Marshal(map[string]string{"name": anchorsToken, "public_key": key.PublicKey})
|
||||
if _, err := laptop.Request("mesh.seat.mesh-vault.call.get."+callerToken("provisioner.anchor"), raw,
|
||||
time.Second); err == nil {
|
||||
t.Error("laptop's client asked in anchor's name and was answered")
|
||||
}
|
||||
heard = func() string {
|
||||
_ = tap.Flush()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return strings.Join(crossed, "\n")
|
||||
}
|
||||
return ask, heard
|
||||
}
|
||||
|
||||
// noneInTheClear fails when an issued value, or its base64, crossed the bus.
|
||||
func noneInTheClear(t *testing.T, all string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(all, "mesh.mod.gitea.call.gitea_agent_token.novox.novox_mesh-vault") ||
|
||||
!strings.Contains(all, "mesh.seat.mesh-vault.call.get.provisioner_anchor") {
|
||||
t.Fatalf("the listener heard nothing of the calls:\n%s", all)
|
||||
}
|
||||
for _, token := range agentTokens {
|
||||
for _, form := range []string{token, base64.StdEncoding.EncodeToString([]byte(token)),
|
||||
base64.RawURLEncoding.EncodeToString([]byte(token))} {
|
||||
if strings.Contains(all, form) {
|
||||
t.Fatalf("an issued value crossed the bus in the clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnARealBusTwoHoldersOnTwoMachinesEachGetOnlyTheirOwnIssuedValueAndNoneCrossesInTheClear(t *testing.T) {
|
||||
ask, heard := forgeOnABus(t, bothMayAsk)
|
||||
if got, err := ask("provisioner.anchor", anchorsToken); err != nil || got != agentTokens["anchor/claude-code"] {
|
||||
t.Errorf("anchor's client got %q, %v", got, err)
|
||||
}
|
||||
if got, err := ask("provisioner.laptop", laptopsToken); err != nil || got != agentTokens["laptop/claude-code"] {
|
||||
t.Errorf("laptop's client got %q, %v", got, err)
|
||||
}
|
||||
if got, err := ask("provisioner.anchor", laptopsToken); err == nil || !strings.Contains(err.Error(), "alone") {
|
||||
t.Errorf("anchor's client was handed laptop's value %q: %v", got, err)
|
||||
}
|
||||
if got, err := ask("provisioner.laptop", anchorsToken); err == nil || !strings.Contains(err.Error(), "alone") {
|
||||
t.Errorf("laptop's client was handed anchor's value %q: %v", got, err)
|
||||
}
|
||||
noneInTheClear(t, heard())
|
||||
}
|
||||
|
||||
// Issue 757's proof on a real bus, with the rule the running vault keeps: a machine's client is handed the value the
|
||||
// forge issued for its claude-code because the module's plan there names gitea-read from the forge's machine, and
|
||||
// is refused another machine's and another module's, sealed to its key and never in the clear.
|
||||
func TestOnARealBusTheRunningRuleHandsAClientTheIssuedValueItsModulesPlanNamesAndNoOther(t *testing.T) {
|
||||
plan := `{"templates":{"source":"x","digest":"y"},"files":[],"provisions":[{"name":"gitea-read",` +
|
||||
`"provision":"gitea-read","provider":"novox","path":"/var/lib/claude-code/gitea-read.secret"}]}`
|
||||
mirror := assignments{
|
||||
"anchor.claude-code": `{"node":"anchor","module":"claude-code","provisioning":` + plan + `}`,
|
||||
"laptop.claude-code": `{"node":"laptop","module":"claude-code","provisioning":` + plan + `}`,
|
||||
"anchor.zsh": `{"node":"anchor","module":"zsh","provisioning":{"secrets":[{"name":"token"}]}}`,
|
||||
}
|
||||
ask, heard := forgeOnABus(t, Declared{Read: mirror.read})
|
||||
if got, err := ask("provisioner.anchor", anchorsToken); err != nil || got != agentTokens["anchor/claude-code"] {
|
||||
t.Errorf("anchor's client got %q, %v", got, err)
|
||||
}
|
||||
if got, err := ask("provisioner.laptop", laptopsToken); err != nil || got != agentTokens["laptop/claude-code"] {
|
||||
t.Errorf("laptop's client got %q, %v", got, err)
|
||||
}
|
||||
for _, r := range []struct{ user, name, why string }{
|
||||
{"provisioner.anchor", laptopsToken, "another machine's holder value"},
|
||||
{"provisioner.laptop", anchorsToken, "another machine's holder value"},
|
||||
{"provisioner.anchor", "provision/novox/gitea-read/anchor/zsh", "another module's, its plan naming it not"},
|
||||
} {
|
||||
if got, err := ask(r.user, r.name); err == nil {
|
||||
t.Errorf("%s was handed %s, %s: %q", r.user, r.name, r.why, got)
|
||||
}
|
||||
}
|
||||
noneInTheClear(t, heard())
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A plan names, for each provision its module requires, the provider serving it (issue 760); only an entry with a
|
||||
// name is a credential the machine's client fills (issue 757). The rule hands the value issued for the holder only
|
||||
// for such an entry: naming a provider alone widens nothing.
|
||||
func TestARuleHandsOnlyACredentialThePlanNamesNotAProviderAlone(t *testing.T) {
|
||||
plan := `{"templates":{"source":"x","digest":"y"},"files":[],"provisions":[` +
|
||||
`{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` +
|
||||
`{"name":"gitea-read","provision":"gitea-read","provider":"novox","provider-module":"gitea",` +
|
||||
`"path":"/s/gitea-read.secret"}]}`
|
||||
mirror := assignments{"anchor.app": `{"node":"anchor","module":"app","provisioning":` + plan + `}`}
|
||||
rule := Declared{Read: mirror.read}
|
||||
if ok, why := rule.May("provisioner.anchor", "provision/novox/gitea-read/anchor/app"); !ok {
|
||||
t.Fatalf("the credential the plan names was refused: %s", why)
|
||||
}
|
||||
if ok, _ := rule.May("provisioner.anchor", "provision/novox/postgres-database/anchor/app"); ok {
|
||||
t.Fatal("a provision the plan names only with its provider was handed out")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// A module only pulled in by another on a node has a value of its own in the assignments mirror (mesh-controller
|
||||
// internal/inventory AssignmentValues, issue 765): its plan, what requires it, and no time it was assigned. The
|
||||
// running rule hands that node's client what the plan names, as for an assigned module, and nothing else.
|
||||
func TestTheRunningVaultAnswersAMachinesClientForWhatAPulledInModulesPlanNames(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"g14.app": `{"node":"g14","module":"app","required-by":"desk","provisioning":` +
|
||||
`{"templates":{"source":"x","digest":"y"},"files":[],"secrets":[{"name":"token"}],` +
|
||||
`"provisions":[{"name":"gitea-read","provision":"gitea-read","provider":"novox"}]}}`,
|
||||
"g14.kept": `{"node":"g14","module":"kept","required-by":"desk"}`,
|
||||
}
|
||||
v := newVault(t, Declared{Read: mirror.read})
|
||||
for _, name := range []string{"own/g14/app/token", "own/g14/app/other", "own/g14/kept/token",
|
||||
"provision/novox/gitea-read/g14/app"} {
|
||||
if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for _, name := range []string{"own/g14/app/token", "provision/novox/gitea-read/g14/app"} {
|
||||
answer, err := v.Get("provisioner.g14", name, asker.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("the machine's client was refused %s, which its pulled-in module's plan names: %v", name, err)
|
||||
}
|
||||
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-"+name {
|
||||
t.Fatalf("the answer opens to %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
for _, r := range []struct{ caller, name, why string }{
|
||||
{"provisioner.g14", "own/g14/app/other", "a secret the pulled-in module's plan does not name"},
|
||||
{"provisioner.g14", "own/g14/kept/token", "a pulled-in module not handed to the provisioner"},
|
||||
{"provisioner.ace", "own/g14/app/token", "another machine's client"},
|
||||
} {
|
||||
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
|
||||
t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
// The vault's runtime health (novox/hq ADR 0366 §4): the vault re-seals its operator copies every minute
|
||||
// (adoptOperatorKey), and a vault that keeps failing to is loud before secrets move into it. The node-engine
|
||||
// asks vault_health on the vault's own runtime (`runtime-health` in module.json); the controller raises
|
||||
// module.mesh-vault.<machine>.unhealthy from it, with these words, and clears it on the first healthy answer.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// FailedPassesUnhealthy is how many failed re-seal passes in a row make the vault unhealthy: one minute's
|
||||
// pass can fail for a moment's reason and be finished by the next.
|
||||
const FailedPassesUnhealthy = 3
|
||||
|
||||
// pass is one minute's re-seal, its outcome kept for vault_health.
|
||||
func (e *Env) pass() {
|
||||
err := adoptOperatorKey(e)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if err == nil {
|
||||
e.failedPasses, e.lastFailure = 0, ""
|
||||
return
|
||||
}
|
||||
e.failedPasses++
|
||||
e.lastFailure = err.Error()
|
||||
}
|
||||
|
||||
// health is vault_health's answer.
|
||||
func (e *Env) health() health.Answer {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.failedPasses >= FailedPassesUnhealthy {
|
||||
return health.Unhealthy(fmt.Sprintf("re-sealing the vault's operator copies failed on %d passes in a row "+
|
||||
"(one a minute); the last said: %s", e.failedPasses, e.lastFailure))
|
||||
}
|
||||
if e.failedPasses > 0 {
|
||||
return health.Healthy(fmt.Sprintf("the last %d re-seal pass(es) failed, tried again every minute; "+
|
||||
"unhealthy after %d in a row", e.failedPasses, FailedPassesUnhealthy))
|
||||
}
|
||||
return health.Healthy("every re-seal pass succeeds")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0366 §4: the vault's runtime health is a tool that says unhealthy once re-sealing has failed on
|
||||
// three passes in a row, saying what failed and why, and healthy again after one pass succeeds.
|
||||
func TestTheVaultIsUnhealthyAfterThreeFailedReSealPassesAndHealthyAfterOne(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &Env{Vault: v.Vault}
|
||||
answer := func() health.Answer {
|
||||
t.Helper()
|
||||
got, err := toolNamed(t, definitions(env), "vault_health").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := health.Read(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the node-engine cannot read %s: %v", raw, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
if a := answer(); !a.Healthy {
|
||||
t.Fatalf("a vault before any pass says %+v", a)
|
||||
}
|
||||
// A new operator key whose copies cannot be written: every pass fails.
|
||||
pub, _ := operatorKey(t)
|
||||
v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") }
|
||||
_, _ = v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
for pass := 1; pass <= 3; pass++ {
|
||||
env.pass()
|
||||
a := answer()
|
||||
if pass < 3 && !a.Healthy {
|
||||
t.Fatalf("after %d failed pass(es) the vault says %+v; it is unhealthy after three", pass, a)
|
||||
}
|
||||
if pass == 3 && (a.Healthy || a.State != health.StateUnhealthy || !strings.Contains(a.Why, "disk full") ||
|
||||
!strings.Contains(a.Why, "3 passes") || !strings.Contains(a.Why, "pair/postgres/gitea")) {
|
||||
t.Fatalf("after three failed passes the vault says %+v", a)
|
||||
}
|
||||
}
|
||||
// One pass that succeeds makes it healthy again, and the streak starts over.
|
||||
v.Store.failWrite = nil
|
||||
env.pass()
|
||||
if a := answer(); !a.Healthy {
|
||||
t.Fatalf("after a pass that re-sealed the vault says %+v", a)
|
||||
}
|
||||
v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") }
|
||||
pub, _ = operatorKey(t)
|
||||
_, _ = v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
env.pass()
|
||||
if a := answer(); !a.Healthy {
|
||||
t.Fatalf("one failed pass after a good one says %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A vault that could not be built re-seals nothing: its passes fail, saying why.
|
||||
func TestABrokenVaultsPassesFail(t *testing.T) {
|
||||
env := &Env{Broken: fmt.Errorf("the vault could not open its store: no such directory")}
|
||||
for i := 0; i < 3; i++ {
|
||||
env.pass()
|
||||
}
|
||||
got, err := toolNamed(t, definitions(env), "vault_health").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := got.(health.Answer); a.Healthy || !strings.Contains(a.Why, "could not open its store") {
|
||||
t.Errorf("a broken vault says %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
// The controller's `rotate <provision> [--consumer <machine>] [--module <module>]` reaches the vault (issue 767): a
|
||||
// provider's one shared value is rotated for every holder together, and it is never rotated for one consumer alone;
|
||||
// a per-login provider (ADR 0158 §11) rotates the named consumer's own login and no other.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postgresLogins is a vault holding a login each for gitea on g14 and umami on ace, from postgres on novox.
|
||||
func postgresLogins(t *testing.T) (testVault, string, string) {
|
||||
t.Helper()
|
||||
v := pairVault(t, assignments{
|
||||
"novox.postgres": handedWith("novox", "postgres"),
|
||||
"g14.gitea": handedWith("g14", "gitea", "postgres-database@novox/postgres"),
|
||||
"ace.umami": handedWith("ace", "umami", "postgres-database@novox/postgres"),
|
||||
})
|
||||
gitea, umami := "pair/novox/postgres-database/g14/gitea", "pair/novox/postgres-database/ace/umami"
|
||||
for name, value := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} {
|
||||
if _, err := v.keepWith(name, value, "node.novox", "", OriginMade, keepOptions{neverReplace: true,
|
||||
movedFrom: &MovedFrom{By: "node.novox"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return v, gitea, umami
|
||||
}
|
||||
|
||||
func TestARotationNamingOneConsumerRemakesThatConsumersLoginAlone(t *testing.T) {
|
||||
for _, one := range []Narrow{{Consumer: "g14"}, {ConsumerModule: "gitea"}, {Consumer: "g14", ConsumerModule: "gitea"}} {
|
||||
v, gitea, umami := postgresLogins(t)
|
||||
before := changedSaid(t, v, umami, "")
|
||||
changed, err := v.RotateFor(ControllerCaller, "provision/novox/postgres-database", one, "person.jochen",
|
||||
"suspected on g14")
|
||||
if err != nil || len(changed.Each) != 1 || changed.Each[0].Name != gitea {
|
||||
t.Fatalf("%+v: the rotation %+v (%v)", one, changed, err)
|
||||
}
|
||||
if got, _, err := asks(v, "provisioner.g14", gitea); err != nil || got == "login-of-gitea" {
|
||||
t.Fatalf("%+v: gitea's login was not remade: %q (%v)", one, got, err)
|
||||
}
|
||||
if got, gen, err := asks(v, "provisioner.ace", umami); err != nil || got != "login-of-umami" || gen != 1 {
|
||||
t.Fatalf("%+v: umami's login was touched: %q generation %d (%v)", one, got, gen, err)
|
||||
}
|
||||
if changedSaid(t, v, umami, "") != before {
|
||||
t.Fatalf("%+v: secret-changed was said for umami's login", one)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARotationNamingAConsumerThatHoldsNoLoginChangesNothing(t *testing.T) {
|
||||
v, gitea, umami := postgresLogins(t)
|
||||
_, err := v.RotateFor(ControllerCaller, "provision/novox/postgres-database", Narrow{Consumer: "g14",
|
||||
ConsumerModule: "umami"}, "person.jochen", "suspected")
|
||||
if err == nil || !strings.Contains(err.Error(), "umami on g14") {
|
||||
t.Fatalf("a consumer holding no login was not refused by name: %v", err)
|
||||
}
|
||||
for name, want := range map[string]string{gitea: "login-of-gitea", umami: "login-of-umami"} {
|
||||
if got, _, _ := asks(v, "provisioner.novox", name); got != want {
|
||||
t.Fatalf("%s changed on a refused rotation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A shared value is one for every holder (ADR 0158): rotated, every holder gets the new one after secret-changed; it
|
||||
// is never rotated for one consumer alone, which would change the value for all of them.
|
||||
func TestASharedValueIsRotatedForEveryHolderAndNeverForOneConsumer(t *testing.T) {
|
||||
v := pairVault(t, assignments{
|
||||
"anchor.stand-in": handedWith("anchor", "stand-in"),
|
||||
"g14.app": handedWith("g14", "app", "stand-in-api@anchor/stand-in"),
|
||||
"ace.other": handedWith("ace", "other", "stand-in-api@anchor/stand-in"),
|
||||
})
|
||||
movedRow(t, v, "g14", "app", "the-value-they-share", "20260901T000000-1", OriginMade)
|
||||
movedRow(t, v, "ace", "other", "the-value-they-share", "20260901T000000-1", OriginMade)
|
||||
if o := pass(t, v, standIn); o.Outcome != PairAdopted {
|
||||
t.Fatalf("stamped rows: %+v", o)
|
||||
}
|
||||
|
||||
if _, err := v.RotateFor(ControllerCaller, standIn, Narrow{Consumer: "g14"}, "person.jochen", "suspected"); err == nil ||
|
||||
!strings.Contains(err.Error(), "every holder") {
|
||||
t.Fatalf("a shared value was rotated for one consumer: %v", err)
|
||||
}
|
||||
if got, gen, _ := asks(v, "provisioner.g14", standIn); got != "the-value-they-share" || gen != 1 {
|
||||
t.Fatal("a refused rotation changed the shared value")
|
||||
}
|
||||
|
||||
changed, err := v.RotateFor(ControllerCaller, standIn, Narrow{}, "person.jochen", "yearly")
|
||||
if err != nil || changed.Generation != 2 {
|
||||
t.Fatalf("the rotation: %+v (%v)", changed, err)
|
||||
}
|
||||
var seen string
|
||||
for _, c := range []string{"provisioner.anchor", "provisioner.g14", "provisioner.ace"} {
|
||||
got, gen, err := asks(v, c, standIn)
|
||||
if err != nil || got == "the-value-they-share" || gen != 2 || (seen != "" && got != seen) {
|
||||
t.Fatalf("%s got %q generation %d (%v): not the one new value", c, got, gen, err)
|
||||
}
|
||||
seen = got
|
||||
}
|
||||
if changedSaid(t, v, standIn, seen) == 0 {
|
||||
t.Fatal("secret-changed was not said for the shared value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARotationOfAModulesOwnSecretNamesNoConsumer(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.RotateFor(ControllerCaller, "own/g14/app/key", Narrow{Consumer: "g14"}, "person.jochen", "x"); err == nil {
|
||||
t.Fatal("a module's own secret was rotated with a consumer named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRotateToolTakesTheConsumerAndItsModule(t *testing.T) {
|
||||
n, err := narrowNamed(map[string]any{"consumer": "g14", "consumer_module": "gitea"})
|
||||
if err != nil || n != (Narrow{Consumer: "g14", ConsumerModule: "gitea"}) {
|
||||
t.Fatalf("%+v (%v)", n, err)
|
||||
}
|
||||
if _, err := narrowNamed(map[string]any{"consumer": "g14/x"}); err == nil {
|
||||
t.Fatal("a consumer that is not a name was taken")
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,15 @@ 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"`
|
||||
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
|
||||
// handed it in and the controller's generation for it, kept as it was.
|
||||
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
@@ -58,6 +67,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 +171,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 +188,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 +345,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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -33,6 +34,14 @@ type Env struct {
|
||||
refused string
|
||||
// Broken is why the vault could not be built, said by `get`.
|
||||
Broken error
|
||||
// failedPasses is how many re-seal passes in a row failed, and lastFailure why the last one did
|
||||
// (vault_health, novox/hq ADR 0366 §4).
|
||||
failedPasses int
|
||||
lastFailure string
|
||||
// pairsSaid is what the last pass over the shared credentials said of each, and lastPairFailure why it last
|
||||
// did not finish (pair.go): each said once, and again when it changes.
|
||||
pairsSaid map[string]string
|
||||
lastPairFailure string
|
||||
}
|
||||
|
||||
func (e *Env) keyRefused() string {
|
||||
@@ -83,6 +92,194 @@ 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. With " +
|
||||
"origin issued it keeps a covered bus account the controller minted, replacing it in place.",
|
||||
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",
|
||||
"origin": "issued for a bus account the controller minted for a core or moved module and hands at " +
|
||||
"once (ADR 0374, issue 787), kept as that module's own secret and replaced in place on a " +
|
||||
"re-issue; left out for a value given at the terminal"}),
|
||||
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"), arg(a, "origin"))
|
||||
}},
|
||||
{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), " +
|
||||
"asked by the controller alone: node, module, name, desk and asked_by. The ask is recorded before any " +
|
||||
"prompt opens — one open ask per secret and three an hour — and the vault asks the desk's prompt itself, " +
|
||||
"in its own name, with a key made for the call; what is typed opens in the vault alone. Answers how the " +
|
||||
"prompt ended (given, dismissed, timed-out, empty, refused or failed) and who asked as recorded; never " +
|
||||
"the value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"node": str("the machine the secret is for"), "module": str("the module"),
|
||||
"name": str("the secret's name in the module"), "desk": str("the machine whose prompt asks"),
|
||||
"asked_by": str("who asked the controller, as the bus named its caller")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.AskAtDesk(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), " +
|
||||
"asked by the controller alone: provision, provider (its machine), module and tool. The vault asks that " +
|
||||
"tool itself, in its own name, with a key made for the call, and keeps what it sealed as the provision's " +
|
||||
"one credential, or, when the tool answers a value per holder named in holders, each as that holder's " +
|
||||
"own, handed to it alone; the same value again writes nothing. Never answers the value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"provision": str("the provision"), "provider": str("the provider's machine"),
|
||||
"module": str("the provider's module, whose software issued it"),
|
||||
"tool": str("the provider's tool that answers it, as its offer names it"),
|
||||
"holders": map[string]any{"type": "array", "description": "the provision's holders from that " +
|
||||
"provider, each {node, module}: the tool may answer a value for each, kept as that holder's own " +
|
||||
"and handed to it alone",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"node": str("the holder's machine"), "module": str("the consuming module")}}}}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holders, err := holdersArg(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.TakeIssuedFor(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module"), arg(a, "tool"),
|
||||
holders)
|
||||
}},
|
||||
{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. From the controller alone, and with a why.",
|
||||
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"),
|
||||
"consumer": str("a provision's credential of a provider whose consumers keep a login each: only " +
|
||||
"this consumer's machine's own"),
|
||||
"consumer_module": str("with it or alone: only this consuming module's own login"),
|
||||
"by": str("who asked, as the controller names them when it carries the act"),
|
||||
"why": str("why it is rotated: required")}},
|
||||
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
|
||||
}
|
||||
one, err := narrowNamed(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.RotateFor(caller, secret, one, arg(a, "by"), arg(a, "why"))
|
||||
}},
|
||||
{Name: Seat + ".move-in",
|
||||
Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " +
|
||||
"node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " +
|
||||
"module and name for a module's own; provider, provision, consumer, consumer_module and local for a " +
|
||||
"pair credential), its origin and the controller's generation, and get a call and a key made for it; " +
|
||||
"then send the call, the value sealed to that key and read_back_to, a key of your own. Never replaces " +
|
||||
"a value the vault holds; answers what it kept, read back from its store sealed to read_back_to.",
|
||||
Input: intakeInput(map[string]string{"node": "a module's own secret: its machine", "module": "its module",
|
||||
"name": "its name in the module", "provider": "a pair credential: the provider's machine",
|
||||
"provision": "the provision", "consumer": "the consumer's machine",
|
||||
"consumer_module": "the consuming module", "local": "the credential's local name, when it has one",
|
||||
"origin": "where the value came from in the controller's store: made, accepted or issued",
|
||||
"generation": "the controller's generation for the value, kept as it is",
|
||||
"made_at": "when the controller made or took it (RFC 3339)",
|
||||
"read_back_to": "the second step: a raw X25519 public key in base64 the read-back is sealed to",
|
||||
"sealing_key": "the first step, optional: this machine's sealing key, a raw X25519 public key in " +
|
||||
"base64, which the core's credentials are sealed to from then on (ADR 0349 §16)"}),
|
||||
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.MoveTake(caller, call, arg(a, "sealed"), arg(a, "read_back_to"))
|
||||
}
|
||||
m := MoveOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Provider: arg(a, "provider"), Provision: arg(a, "provision"), Consumer: arg(a, "consumer"),
|
||||
ConsumerModule: arg(a, "consumer_module"), Local: arg(a, "local"), Origin: arg(a, "origin"),
|
||||
Generation: arg(a, "generation"), SealingKey: arg(a, "sealing_key")}
|
||||
if at := arg(a, "made_at"); at != "" {
|
||||
if m.MadeAt, err = time.Parse(time.RFC3339Nano, at); err != nil {
|
||||
return nil, fmt.Errorf("made_at %q is not a time (RFC 3339)", at)
|
||||
}
|
||||
}
|
||||
return v.MoveOpen(caller, m)
|
||||
}},
|
||||
{Name: Seat + ".node-key",
|
||||
Description: "State this machine's sealing key to the vault, in its own name (ADR 0372, issue 786): from the " +
|
||||
"node-engine of the machine alone, at its start and whenever the key changes; the machine is the caller's, " +
|
||||
"as the bus proved it. A first or changed key is recorded and `secret-changed` is raised for the machine's " +
|
||||
"core secrets, so the controller asks again; the same key again changes nothing.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"key": str("this machine's sealing key, a raw X25519 public key in base64, as move-in's sealing_key carries it")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := v.StateNodeKey(caller, arg(a, "key")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"recorded": true}, nil
|
||||
}},
|
||||
{Name: Seat + ".move-asks",
|
||||
Description: "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once " +
|
||||
"(design 57's V3): from the controller alone, at most 500 at a time. No value is in any of them.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"asks": map[string]any{"type": "array", "description": "the controller's rows: id, node, module, name, " +
|
||||
"desk, asked_by, opened_at, and ended_at and outcome when the ask ended",
|
||||
"items": map[string]any{"type": "object"}}}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(a["asks"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var asks []MovedAsk
|
||||
if err := json.Unmarshal(raw, &asks); err != nil {
|
||||
return nil, fmt.Errorf("asks is not the controller's rows: %w", err)
|
||||
}
|
||||
return v.MoveAsks(caller, asks)
|
||||
}},
|
||||
{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 " +
|
||||
@@ -204,6 +401,11 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil
|
||||
}},
|
||||
{Name: "vault_health",
|
||||
Description: "The vault's runtime health, as the node-engine asks it (novox/hq ADR 0366): unhealthy once " +
|
||||
"re-sealing the operator copies has failed on three passes in a row, saying what failed and why; " +
|
||||
"healthy again after one pass succeeds.",
|
||||
run: func(string, map[string]any) (any, error) { return env.health(), nil }},
|
||||
{Name: "vault_status",
|
||||
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
|
||||
"key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.",
|
||||
@@ -237,7 +439,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 +449,70 @@ 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")
|
||||
}
|
||||
|
||||
// narrowNamed is the one consumer a rotation names, if any (issue 767).
|
||||
func narrowNamed(a map[string]any) (Narrow, error) {
|
||||
n := Narrow{Consumer: arg(a, "consumer"), ConsumerModule: arg(a, "consumer_module")}
|
||||
for what, v := range map[string]string{"the consumer's machine": n.Consumer, "the consuming module": n.ConsumerModule} {
|
||||
if v != "" && !namePart.MatchString(v) {
|
||||
return Narrow{}, fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
@@ -30,11 +31,21 @@ type Vault struct {
|
||||
Own seal.KeyPair
|
||||
// Askers decides who may ask `get` for what. Nil allows nobody.
|
||||
Askers Askers
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
// Assignments is the controller's assignments mirror, where the vault sees a consumer bind a provider and
|
||||
// unbind (pair.go). Empty sees nothing, and makes no provision's credential.
|
||||
Assignments Mirror
|
||||
Emit func(event string, body map[string]any) error
|
||||
// Ask calls a tool on the mesh as the vault, through the runtime that launched it, under the vault's own bus
|
||||
// account (ADR 0259 §8's runs-as, issue 723): the desks' secret prompt and each issuing provider's tool, which
|
||||
// the bus lets the vault's account alone call in its own name. Nil asks nobody.
|
||||
Ask func(key string, body any) (json.RawMessage, error)
|
||||
Now func() time.Time
|
||||
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.
|
||||
@@ -44,6 +55,8 @@ type Changed struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
// Each is, for a rotation of a provider whose consumers keep logins of their own, each one's own value remade.
|
||||
Each []Changed `json:"each,omitempty"`
|
||||
}
|
||||
|
||||
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
|
||||
@@ -65,6 +78,25 @@ 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) {
|
||||
return v.keepWith(name, value, by, via, origin, keepOptions{})
|
||||
}
|
||||
|
||||
// keepOptions are what the move keeps beside a value (move.go): that it never replaces another, when the value
|
||||
// was first made, and where it was moved from.
|
||||
type keepOptions struct {
|
||||
neverReplace bool
|
||||
madeAt time.Time
|
||||
movedFrom *MovedFrom
|
||||
}
|
||||
|
||||
// keepWith is keep with the move's options.
|
||||
func (v *Vault) keepWith(name, value, by, via, origin string, opt keepOptions) (Changed, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
@@ -94,6 +126,11 @@ func (v *Vault) Put(name, value, by string) (Changed, error) {
|
||||
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
|
||||
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
|
||||
}
|
||||
if opt.neverReplace {
|
||||
return Changed{}, fmt.Errorf("the vault already holds another value for %s (generation %d, changed by %s "+
|
||||
"at %s), and the move never replaces a value the vault holds: nothing was taken", name,
|
||||
before.Generation, before.ChangedBy, before.ChangedAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
atRest, err := seal.Seal(value, v.Own.PublicKey)
|
||||
if err != nil {
|
||||
@@ -104,11 +141,21 @@ 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
|
||||
} else if !opt.madeAt.IsZero() {
|
||||
rec.MadeAt = opt.madeAt.UTC()
|
||||
}
|
||||
if opt.movedFrom != nil {
|
||||
moved := *opt.movedFrom
|
||||
moved.At = now
|
||||
rec.MovedFrom = &moved
|
||||
}
|
||||
if err := v.Store.Write(rec); err != nil {
|
||||
return Changed{}, err
|
||||
@@ -176,7 +223,20 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if ok, why := askers.May(caller, name); !ok {
|
||||
// A value issued for one holder is that holder's alone, whatever else would allow it (issue 749).
|
||||
if why := notTheHolder(caller, name); why != "" {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = 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)
|
||||
}
|
||||
// The core's own credentials, to the controller alone, sealed only to their machine's key (ADR 0349 §16).
|
||||
if caller == ControllerCaller {
|
||||
if why := v.coreRefusal(name, publicKey); why != "" {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = 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)
|
||||
}
|
||||
} else if ok, why := askers.May(caller, name); !ok {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = 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)
|
||||
|
||||
@@ -129,7 +129,8 @@ func filesUnder(t *testing.T, dir string) map[string]string {
|
||||
// ---- no bucket holds a secret ---------------------------------------------------------------------------
|
||||
|
||||
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
|
||||
// store on its own disk (ADR 0349 §7, ADR 0201 §13).
|
||||
// store on its own disk (ADR 0349 §7, ADR 0201 §13). It reads one bucket of another's, and only reads it: the
|
||||
// controller's assignments, for what each node's plan names (askers.go, design 57 V4).
|
||||
func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
@@ -142,6 +143,9 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
if s, ok := manifest["state"]; ok {
|
||||
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
|
||||
}
|
||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` {
|
||||
t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r)
|
||||
}
|
||||
sources, _ := filepath.Glob("*.go")
|
||||
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
|
||||
for _, f := range sources {
|
||||
@@ -149,6 +153,10 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
code, _ := os.ReadFile(f)
|
||||
// The reads it makes, and nothing that writes: Get and Keys alone, on the assignments (Keys for the
|
||||
// binds it remakes a provider's credential on, pair.go).
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Keys(", "", 1))
|
||||
if m := bucket.Find(code); m != nil {
|
||||
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
|
||||
}
|
||||
@@ -367,17 +375,143 @@ func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault
|
||||
// that runs answers nobody.
|
||||
func TestTheRunningVaultAnswersNobodyYet(t *testing.T) {
|
||||
v := newVault(t, nil) // what main builds: no list
|
||||
// A vault given no rule answers nobody.
|
||||
func TestAVaultWithNoRuleAnswersNobody(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
|
||||
for _, caller := range []string{"provisioner.g14", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
|
||||
if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil {
|
||||
t.Errorf("the running vault answered %s", caller)
|
||||
t.Errorf("a vault with no rule answered %s", caller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assignments stands in for the controller's assignments mirror as the vault reads it.
|
||||
type assignments map[string]string
|
||||
|
||||
func (a assignments) read(key string) ([]byte, error) {
|
||||
if v, ok := a[key]; ok {
|
||||
return []byte(v), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The running vault's rule (ADR 0349 §11, design 57 V4): a node's provisioner client, under its own account, for a
|
||||
// secret of a module on its node that the module's plan there names, and nobody else for anything else. Each
|
||||
// refusal is recorded with why.
|
||||
func TestTheRunningVaultAnswersAMachinesClientForWhatItsPlanNames(t *testing.T) {
|
||||
mirror := assignments{
|
||||
"g14.dunst": `{"node":"g14","module":"dunst","assigned":"2026-10-14T20:00:00Z","provisioning":` +
|
||||
`{"templates":{"source":"x","digest":"y"},"files":[],"secrets":[{"name":"token"}]}}`,
|
||||
"g14.picom": `{"node":"g14","module":"picom","assigned":"2026-10-14T20:00:00Z"}`,
|
||||
"ace.dunst": `{"node":"ace","module":"dunst","assigned":"2026-10-14T20:00:00Z","provisioning":{"secrets":[{"name":"token"}]}}`,
|
||||
"g14.liar": `{"node":"ace","module":"liar","provisioning":{"secrets":[{"name":"token"}]}}`,
|
||||
"g14.unparsed": `not json`,
|
||||
}
|
||||
v := newVault(t, Declared{Read: mirror.read})
|
||||
for _, name := range []string{"own/g14/dunst/token", "own/g14/dunst/other", "own/g14/picom/token",
|
||||
"own/ace/dunst/token", "own/g14/liar/token", "own/g14/unparsed/token", "provision/novox/postgres",
|
||||
"own/g14/absent/token"} {
|
||||
if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
|
||||
answer, err := v.Get("provisioner.g14", "own/g14/dunst/token", asker.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("the machine's client was refused the secret its module's plan names: %v", err)
|
||||
}
|
||||
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-own/g14/dunst/token" {
|
||||
t.Fatalf("the answer opens to %q (%v)", got, err)
|
||||
}
|
||||
|
||||
refusals := []struct{ caller, name, why string }{
|
||||
{"provisioner.g14", "own/g14/dunst/other", "a secret the plan does not name"},
|
||||
{"provisioner.g14", "own/g14/picom/token", "a module not handed to the provisioner"},
|
||||
{"provisioner.g14", "own/ace/dunst/token", "another machine's secret"},
|
||||
{"provisioner.ace", "own/g14/dunst/token", "another machine's client"},
|
||||
{"provisioner.g14", "own/g14/absent/token", "a module not assigned there"},
|
||||
{"provisioner.g14", "own/g14/liar/token", "an assignment whose value names another machine"},
|
||||
{"provisioner.g14", "own/g14/unparsed/token", "an assignment that is not the controller's"},
|
||||
{"provisioner.g14", "provision/novox/postgres", "a provision's credential the vault made for nobody there"},
|
||||
{"g14.node-tools", "own/g14/dunst/token", "the tool runner's account, which an agent shares"},
|
||||
{"g14.dunst", "own/g14/dunst/token", "a module's own account, until issue 569"},
|
||||
{"controller", "own/g14/dunst/token", "the controller"},
|
||||
{"provisioner.", "own/g14/dunst/token", "a client naming no machine"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
|
||||
t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a)
|
||||
}
|
||||
}
|
||||
handed, err := v.Store.Handed(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused := 0
|
||||
for _, h := range handed {
|
||||
if h.Outcome == "refused" {
|
||||
if h.Why == "" {
|
||||
t.Errorf("a refusal of %s to %s is recorded without why", h.Name, h.To)
|
||||
}
|
||||
refused++
|
||||
}
|
||||
}
|
||||
// Every refusal but the last, whose caller is not a bus user's shape and so fills no record (vault.go).
|
||||
if refused != len(refusals)-1 {
|
||||
t.Errorf("%d refusals recorded, want %d", refused, len(refusals)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// A value a provider's software issued for one holder (issue 757): the running rule hands
|
||||
// provision/<provider>/<provision>/<node>/<module> to that node's client when the module's plan there names the
|
||||
// provision from that provider, and refuses another node's, another module's, another provider's and the provision's
|
||||
// one credential.
|
||||
func TestTheRunningVaultAnswersAMachinesClientForAnIssuedValueItsPlanNames(t *testing.T) {
|
||||
plan := `{"templates":{"source":"x","digest":"y"},"files":[],` +
|
||||
`"provisions":[{"name":"gitea-read","provision":"gitea-read","provider":"novox","path":"/s/gitea-read.secret"}]}`
|
||||
mirror := assignments{
|
||||
"anchor.claude-code": `{"node":"anchor","module":"claude-code","provisioning":` + plan + `}`,
|
||||
"laptop.claude-code": `{"node":"laptop","module":"claude-code","provisioning":` + plan + `}`,
|
||||
"anchor.zsh": `{"node":"anchor","module":"zsh","provisioning":{"secrets":[{"name":"token"}]}}`,
|
||||
"anchor.picom": `{"node":"anchor","module":"picom"}`,
|
||||
}
|
||||
v := newVault(t, Declared{Read: mirror.read})
|
||||
for _, name := range []string{anchorsToken, laptopsToken, "provision/novox/gitea-read/anchor/zsh",
|
||||
"provision/novox/gitea-read/anchor/picom", "provision/ace/gitea-read/anchor/claude-code",
|
||||
"provision/novox/other/anchor/claude-code", "provision/novox/gitea-read",
|
||||
"provision/novox/gitea-read/anchor/absent"} {
|
||||
if _, err := v.Put(name, "value-of-"+name, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
answer, err := v.Get("provisioner.anchor", anchorsToken, asker.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("the machine's client was refused the value issued for its module, which its plan names: %v", err)
|
||||
}
|
||||
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "value-of-"+anchorsToken {
|
||||
t.Fatalf("the answer opens to %q (%v)", got, err)
|
||||
}
|
||||
refusals := []struct{ caller, name, why string }{
|
||||
{"provisioner.anchor", laptopsToken, "another machine's holder value"},
|
||||
{"provisioner.laptop", anchorsToken, "another machine's holder value, the other way"},
|
||||
{"provisioner.anchor", "provision/novox/gitea-read/anchor/zsh", "another module's, whose plan names it not"},
|
||||
{"provisioner.anchor", "provision/novox/gitea-read/anchor/picom", "a module not handed to the provisioner"},
|
||||
{"provisioner.anchor", "provision/novox/gitea-read/anchor/absent", "a module not assigned there"},
|
||||
{"provisioner.anchor", "provision/ace/gitea-read/anchor/claude-code", "another provider's"},
|
||||
{"provisioner.anchor", "provision/novox/other/anchor/claude-code", "a provision the plan names not"},
|
||||
{"provisioner.anchor", "provision/novox/gitea-read", "the provision's one credential"},
|
||||
{"anchor.node-tools", anchorsToken, "the tool runner's account, which an agent shares"},
|
||||
{"anchor.claude-code", anchorsToken, "the module's own account, until issue 569"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
|
||||
t.Errorf("get answered %s (%s asking %s): %+v", r.why, r.caller, r.name, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,19 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.43.0 // indirect
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"module": "mesh-vault",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-vault",
|
||||
"provides": [
|
||||
{
|
||||
"name": "secret",
|
||||
@@ -15,6 +16,9 @@
|
||||
"rotated",
|
||||
"secret-changed"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments"
|
||||
],
|
||||
"receives": {
|
||||
"secret": "${dir:grants}/mesh.json"
|
||||
},
|
||||
@@ -57,32 +61,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-vault",
|
||||
"runtime-health": {
|
||||
"kind": "tool",
|
||||
"tool": "vault_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-vault",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-vault"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-vault",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"id": "ledger",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"id": "root",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "mesh-vault"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -112,19 +140,28 @@
|
||||
"secret_holders",
|
||||
"secret_verify",
|
||||
"secret_handed",
|
||||
"secret_asks",
|
||||
"secret_vault_export",
|
||||
"provisioner_retirement",
|
||||
"provisioner_retire_approve",
|
||||
"provisioner_retire_reject",
|
||||
"provisioner_delete",
|
||||
"vault_status"
|
||||
"vault_status",
|
||||
"vault_health"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"get"
|
||||
"get",
|
||||
"accept",
|
||||
"secret-ask",
|
||||
"issued",
|
||||
"rotate",
|
||||
"move-in",
|
||||
"node-key",
|
||||
"move-asks"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user