Secrets vault: operator key, a second seal on every secret, recovery and export #34

Merged
jschoubben merged 3 commits from feat/secrets-vault into main 2026-09-21 07:34:48 +00:00
9 changed files with 308 additions and 141 deletions
Showing only changes of commit 77e6c1a684 - Show all commits
+11 -6
View File
@@ -52,14 +52,13 @@ func operatorKeyMake(args []string) error {
if err := set.Parse(args); err != nil {
return err
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s already exists; this will not overwrite a key somebody may still need", *out)
}
public, private, err := secrets.Keypair()
if err != nil {
return err
}
if err := os.WriteFile(*out, []byte(private+"\n"), 0o600); err != nil {
// Create-exclusive: a key somebody may still need is never overwritten, and there is no window
// between checking and writing in which one could appear.
if err := writeNew(*out, []byte(private+"\n")); err != nil {
return err
}
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
@@ -129,14 +128,20 @@ func operatorKeyShow(ctx context.Context) error {
fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one")
return nil
}
kept, unrecoverable, err := inv.KeptForOperator(ctx)
kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return err
}
fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key)
fmt.Printf(" %d secret(s) recoverable with it\n", len(kept))
if len(earlier) > 0 {
fmt.Printf(" %d secret(s) sealed to an earlier operator key — recoverable with that key only, until issued again:\n", len(earlier))
for _, k := range earlier {
fmt.Printf(" %s %s %s (%s)\n", k.Node, k.Module, k.Name, secrets.Fingerprint(k.Key))
}
}
if len(unrecoverable) > 0 {
fmt.Printf(" %d secret(s) not recoverable — made before it, or sealed to an earlier key:\n", len(unrecoverable))
fmt.Printf(" %d secret(s) not recoverable — made before the mesh had an operator key:\n", len(unrecoverable))
for _, k := range unrecoverable {
fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name)
}
+5 -15
View File
@@ -13,7 +13,6 @@ import (
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences"
"github.com/novox/mesh-controller/internal/secrets"
"net"
"strconv"
)
@@ -464,27 +463,18 @@ func declarationWith(ctx context.Context, open *stores, node string,
}
// And, for a module that keeps them, every operator-sealed secret in the mesh — the vault's
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints.
// copy, outside the store (novox/hq ADR 0085, amended). Read only; nothing here mints. The
// export changes whenever any secret in the mesh is made or rotated, so the vault's declaration
// changes with it and the vault node is sent again: that is what keeps its copy current, and
// the cost is one two-table read per composition of the vault's node, in every mode.
var kept *catalogue.KeptExport
for _, m := range plan.Modules {
if m.Keeps == "" {
continue
}
operator, err := inv.OperatorKey(ctx)
if err != nil {
if kept, err = inv.OperatorExport(ctx); err != nil {
return nil, err
}
if operator == "" {
break // nothing is sealed to an operator, so there is nothing to keep yet
}
recoverable, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return nil, err
}
kept = &catalogue.KeptExport{
Export: 1, OperatorKey: operator, Fingerprint: secrets.Fingerprint(operator),
Kept: recoverable, Unrecoverable: unrecoverable,
}
break
}
+71 -21
View File
@@ -84,7 +84,7 @@ func secretCommand(ctx context.Context, args []string) error {
}
const secretUsage = "secret accept <node> <module> <name> [--from <file>]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]"
// secretRecover is break-glass: a secret opened with the operator's key, written to a file.
@@ -104,6 +104,7 @@ func secretRecover(ctx context.Context, args []string) error {
keyFile := set.String("key", "", "the operator's private key, from `operator key make`")
out := set.String("out", "", "where to write the value (0600); - for standard output. Default <node>.<module>.<name>.secret")
fromExport := set.String("from-export", "", "read the sealed copy from this `secret export` file instead of the store")
provider := set.String("provider", "", "for a pair credential held from more than one provider: which one")
if err := set.Parse(flags); err != nil {
return err
}
@@ -118,7 +119,7 @@ func secretRecover(ctx context.Context, args []string) error {
var kept inventory.Kept
if *fromExport != "" {
kept, err = keptFromExport(*fromExport, node, module, name)
kept, err = keptFromExport(*fromExport, node, module, name, *provider)
if err != nil {
return err
}
@@ -128,7 +129,7 @@ func secretRecover(ctx context.Context, args []string) error {
return err
}
defer open.Close()
kept, err = open.inventory.KeptSecret(ctx, node, module, name)
kept, err = open.inventory.KeptSecret(ctx, node, module, name, *provider)
if err != nil {
return err
}
@@ -147,10 +148,7 @@ func secretRecover(ctx context.Context, args []string) error {
if path == "" {
path = node + "." + module + "." + name + ".secret"
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s already exists; not overwriting it", path)
}
if err := os.WriteFile(path, value, 0o600); err != nil {
if err := writeNew(path, value); err != nil {
return err
}
fmt.Printf("%s on %s: %q recovered to %s (0600) — %d bytes, origin %s\n",
@@ -181,14 +179,11 @@ func secretExport(ctx context.Context, args []string) error {
if key == "" {
return errors.New("the mesh has no operator key, so nothing is sealed to one; `operator key make` and `operator key set` first")
}
kept, unrecoverable, err := inv.KeptForOperator(ctx)
doc, err := inv.OperatorExport(ctx)
if err != nil {
return err
}
body, err := json.MarshalIndent(export{
Export: 1, OperatorKey: key, Fingerprint: secrets.Fingerprint(key),
Kept: kept, Unrecoverable: unrecoverable,
}, "", " ")
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
@@ -197,18 +192,58 @@ func secretExport(ctx context.Context, args []string) error {
_, err := os.Stdout.Write(body)
return err
}
if err := os.WriteFile(*out, body, 0o600); err != nil {
// Replaced whole, and made 0600 whether or not it existed: an export is ciphertext and a public
// key, but it is also the list of every secret the mesh has, and a file left at an earlier mode
// while the command says 0600 is a lie in the one place a person checks.
if err := writeReplacing(*out, body); err != nil {
return err
}
fmt.Printf("%d secret(s) exported to %s (0600), sealed to operator key %s — ciphertext, keep it with the key\n",
len(kept), *out, secrets.Fingerprint(key))
if len(unrecoverable) > 0 {
fmt.Printf(" %d secret(s) are NOT in it: made before the mesh had an operator key\n", len(unrecoverable))
len(doc.Kept), *out, doc.Fingerprint)
if len(doc.EarlierKey) > 0 {
fmt.Printf(" %d secret(s) are sealed to an EARLIER operator key: recoverable with that key only\n", len(doc.EarlierKey))
}
if len(doc.Unrecoverable) > 0 {
fmt.Printf(" %d secret(s) are NOT in it: made before the mesh had an operator key\n", len(doc.Unrecoverable))
}
return nil
}
func keptFromExport(path, node, module, name string) (inventory.Kept, error) {
// writeNew writes a file that must not exist yet, atomically: create-exclusive, 0600. A check
// followed by a write is a window in which a key somebody still needs can be overwritten.
func writeNew(path string, content []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("%s already exists; not overwriting it", path)
}
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
return f.Close()
}
// writeReplacing writes a file whole, creating or truncating it, and leaves it at 0600 either way.
func writeReplacing(path string, content []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(content); err != nil {
f.Close()
return err
}
if err := f.Chmod(0o600); err != nil {
f.Close()
return err
}
return f.Close()
}
func keptFromExport(path, node, module, name, provider string) (inventory.Kept, error) {
raw, err := os.ReadFile(path)
if err != nil {
return inventory.Kept{}, err
@@ -217,12 +252,27 @@ func keptFromExport(path, node, module, name string) (inventory.Kept, error) {
if err := json.Unmarshal(raw, &e); err != nil {
return inventory.Kept{}, fmt.Errorf("%s is not a secret export: %w", path, err)
}
for _, k := range e.Kept {
if k.Node == node && k.Module == module && k.Name == name {
return k, nil
// Sealed to the current key or to an earlier one: both are copies the given key might open,
// and Open says which. Not the unrecoverable list, which holds no copy at all.
var found []inventory.Kept
for _, k := range append(append([]inventory.Kept{}, e.Kept...), e.EarlierKey...) {
if k.Node == node && k.Module == module && k.Name == name && (provider == "" || k.Provider == provider) {
found = append(found, k)
}
}
return inventory.Kept{}, fmt.Errorf("%s holds no copy of %s's %q on %s", path, module, name, node)
switch len(found) {
case 0:
return inventory.Kept{}, fmt.Errorf("%s holds no copy of %s's %q on %s", path, module, name, node)
case 1:
return found[0], nil
default:
providers := make([]string, 0, len(found))
for _, f := range found {
providers = append(providers, f.Provider)
}
return inventory.Kept{}, fmt.Errorf("%s holds %s's %q on %s from more than one provider (%s); say which with --provider",
path, module, name, node, strings.Join(providers, ", "))
}
}
// split separates what this command is about from how it was asked.
+7 -3
View File
@@ -763,10 +763,14 @@ type Kept struct {
// KeptExport is what a person keeps beside the operator key, and what a vault keeps on its disk:
// every operator-sealed copy, and the honest list of what has none.
type KeptExport struct {
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
// Kept is sealed to OperatorKey. EarlierKey is sealed to a key the mesh has since replaced —
// recoverable with that key, if the person still has it, and with nothing else. Unrecoverable
// has no operator copy at all.
Kept []Kept `json:"kept"`
EarlierKey []Kept `json:"sealed-to-earlier-key,omitempty"`
Unrecoverable []Kept `json:"unrecoverable,omitempty"`
}
+108 -19
View File
@@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
)
// The operator's sealing key: the one holder of secrets that is not a node.
@@ -18,6 +20,29 @@ import (
// What is recorded here is the public half, which is all the mesh needs to seal to it; what it
// yields is one more blob per secret that the mesh cannot open.
// operatorColumns is the pair of nullable columns a secret row carries for its operator copy:
// both null when the mesh has no operator key, so a row says plainly that no such copy exists.
func operatorColumns(operator, blob string) (sealed, key *string) {
if operator == "" || blob == "" {
return nil, nil
}
return &blob, &operator
}
// operatorSeal seals a value somebody supplied to the operator key, when the mesh has one.
func (i *Inventory) operatorSeal(ctx context.Context, value string) (sealed, key *string, err error) {
operator, err := i.OperatorKey(ctx)
if err != nil || operator == "" {
return nil, nil, err
}
blob, err := secrets.Seal(operator, []byte(value))
if err != nil {
return nil, nil, err
}
sealed, key = operatorColumns(operator, blob)
return sealed, key, nil
}
// OperatorKey is the public key secrets are also sealed to, or empty when the mesh has none.
func (i *Inventory) OperatorKey(ctx context.Context) (string, error) {
var key string
@@ -44,9 +69,12 @@ func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned
return 0, err
}
defer tx.Rollback(ctx)
// Both tables: a module's own secrets and the pair credentials. A count over one of them said
// "nothing orphaned" about a mesh whose every vault-provided secret had just been.
if err := tx.QueryRow(ctx,
`select count(*) from module_secret
where operator_key is not null and operator_key <> $1`, public).Scan(&orphaned); err != nil {
`select (select count(*) from module_secret where operator_key is not null and operator_key <> $1)
+ (select count(*) from secret where operator_key is not null and operator_key <> $1)`,
public).Scan(&orphaned); err != nil {
return 0, err
}
if _, err := tx.Exec(ctx, `delete from operator_key where public <> $1`, public); err != nil {
@@ -62,12 +90,38 @@ func (i *Inventory) SetOperatorKey(ctx context.Context, public string) (orphaned
// Kept is the catalogue's: one secret as the operator can recover it.
type Kept = catalogue.Kept
// KeptForOperator is every secret the operator can recover, and which cannot.
// OperatorExport is the export as the operator and the vault both keep it: every secret sealed to
// the mesh's current operator key, every one sealed to an earlier key (recoverable with that key,
// if the person still has it), and every one with no operator copy at all. Nil when the mesh has
// no operator key. One constructor, so the file `secret export` writes and the file the mesh puts
// on the vault's disk cannot drift apart.
func (i *Inventory) OperatorExport(ctx context.Context) (*catalogue.KeptExport, error) {
operator, err := i.OperatorKey(ctx)
if err != nil || operator == "" {
return nil, err
}
kept, earlier, unrecoverable, err := i.KeptForOperator(ctx)
if err != nil {
return nil, err
}
return &catalogue.KeptExport{
Export: 1, OperatorKey: operator, Fingerprint: secrets.Fingerprint(operator),
Kept: kept, EarlierKey: earlier, Unrecoverable: unrecoverable,
}, nil
}
// KeptForOperator is every secret by what can open it: the mesh's current operator key, an
// earlier operator key, or nothing.
//
// The second list is the honest half: a secret minted before the mesh had an operator key has no
// operator-sealed copy and cannot get one — the plaintext was discarded. Naming those is what lets
// an export say what it does not cover, rather than being taken for complete.
func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecoverable []Kept, err error) {
// The last two are the honest half. A secret minted before the mesh had an operator key has no
// operator-sealed copy and cannot get one — the plaintext was discarded; one sealed to a key the
// mesh has since replaced is not opened by the current key, however the export is labelled. Naming
// both is what lets an export say what it does not cover, rather than being taken for complete.
func (i *Inventory) KeptForOperator(ctx context.Context) (kept, earlier, unrecoverable []Kept, err error) {
current, err := i.OperatorKey(ctx)
if err != nil {
return nil, nil, nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
coalesce(s.operator_key, ''), s.made_at
@@ -78,27 +132,34 @@ func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecover
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
order by 1, 2, 3, 4`)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
defer rows.Close()
for rows.Next() {
var k Kept
if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil {
return nil, nil, err
return nil, nil, nil, err
}
if k.Sealed == "" {
switch {
case k.Sealed == "":
unrecoverable = append(unrecoverable, k)
continue
case k.Key != current:
earlier = append(earlier, k)
default:
kept = append(kept, k)
}
kept = append(kept, k)
}
return kept, unrecoverable, rows.Err()
return kept, earlier, unrecoverable, rows.Err()
}
// KeptSecret is one secret's operator-sealed copy, for recovery.
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (Kept, error) {
// An own secret first, then a pair credential by the provision's name. A module whose own
// secret and requirement share a name is refused at resolution, so the two cannot both answer.
//
// An own secret first, then a pair credential by the provision's name — a module whose own secret
// and requirement share a name is refused at resolution, so the two cannot both answer. A pair
// credential is keyed by provider as well, and a consumer whose provision moved leaves the old
// provider's row behind: two rows is refused with both providers named, never answered with
// whichever came first, unless `provider` says which.
func (i *Inventory) KeptSecret(ctx context.Context, node, module, name, provider string) (Kept, error) {
var k Kept
err := i.store.Pool().QueryRow(ctx,
`select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''),
@@ -107,12 +168,40 @@ func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (
where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name).
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
if errors.Is(err, pgx.ErrNoRows) {
err = i.store.Pool().QueryRow(ctx,
rows, qerr := i.store.Pool().Query(ctx,
`select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''),
coalesce(s.operator_key, ''), s.created_at
from secret s join node c on c.id = s.consumer join node p on p.id = s.provider
where c.name = $1 and s.consumer_module = $2 and s.name = $3`, node, module, name).
Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt)
where c.name = $1 and s.consumer_module = $2 and s.name = $3 and ($4 = '' or p.name = $4)
order by p.name`, node, module, name, provider)
if qerr != nil {
return Kept{}, qerr
}
defer rows.Close()
var found []Kept
for rows.Next() {
var row Kept
if err := rows.Scan(&row.Kind, &row.Node, &row.Module, &row.Name, &row.Provider, &row.Origin, &row.Sealed, &row.Key, &row.MadeAt); err != nil {
return Kept{}, err
}
found = append(found, row)
}
if err := rows.Err(); err != nil {
return Kept{}, err
}
switch len(found) {
case 0:
err = pgx.ErrNoRows
case 1:
k, err = found[0], nil
default:
providers := make([]string, 0, len(found))
for _, f := range found {
providers = append(providers, f.Provider)
}
return Kept{}, fmt.Errorf("%s on %s holds a %q credential from more than one provider (%s); say which with --provider",
module, node, name, strings.Join(providers, ", "))
}
}
if errors.Is(err, pgx.ErrNoRows) {
return Kept{}, fmt.Errorf("%s on %s holds nothing called %q — neither a secret of its own nor a credential for a provision", module, node, name)
+74 -24
View File
@@ -2,6 +2,7 @@ package inventory
import (
"context"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -20,10 +21,10 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err)
}
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser"); err == nil {
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", ""); err == nil {
t.Fatal("a secret made before the operator key was reported recoverable")
}
kept, unrecoverable, err := inv.KeptForOperator(ctx)
kept, _, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
@@ -46,14 +47,14 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if err := inv.AcceptSecretForModule(ctx, "provider", "postgres", "replication", "given-by-a-person"); err != nil {
t.Fatal(err)
}
kept, unrecoverable, err = inv.KeptForOperator(ctx)
kept, _, unrecoverable, err = inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
if len(kept) != 2 || len(unrecoverable) != 1 {
t.Fatalf("after a key: %d kept, %d unrecoverable", len(kept), len(unrecoverable))
}
got, err := inv.KeptSecret(ctx, "provider", "postgres", "replication")
got, err := inv.KeptSecret(ctx, "provider", "postgres", "replication", "")
if err != nil {
t.Fatal(err)
}
@@ -67,7 +68,7 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
if got.Origin != "accepted" || got.Key != pub {
t.Fatalf("kept as %+v", got)
}
minted, err := inv.KeptSecret(ctx, "provider", "postgres", "superuser")
minted, err := inv.KeptSecret(ctx, "provider", "postgres", "superuser", "")
if err != nil {
t.Fatal(err)
}
@@ -75,26 +76,59 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) {
t.Fatalf("the minted secret did not open to a 40-character value: %v", err)
}
// The old secret, remade for a rejoined node, becomes recoverable — it was issued again.
// The old secret is kept, not resealed: asking again is a read, the plaintext is gone, and it
// stays honestly unrecoverable.
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err)
}
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser"); err == nil {
if _, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", ""); err == nil {
t.Fatal("asking again did not remake, yet it became recoverable")
}
// Until the node rejoins with a new sealing key: then the secret is remade, and the remake is
// sealed to the operator — the one scenario the vault exists for.
rejoined, err := inv.NodeByName(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
newKey, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, rejoined.ID, newKey); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretForModule(ctx, "consumer", "postgres", "superuser"); err != nil {
t.Fatal(err)
}
remade, err := inv.KeptSecret(ctx, "consumer", "postgres", "superuser", "")
if err != nil {
t.Fatalf("the remade secret is not recoverable: %v", err)
}
if _, err := secrets.Open(priv, remade.Sealed); err != nil {
t.Fatal(err)
}
// Replacing the key says how many secrets stay sealed to the old one.
// Replacing the key says how many secrets stay sealed to the old one — and those move out of
// the recoverable list, whatever the export is labelled with.
pub2, _, _ := secrets.Keypair()
orphaned, err := inv.SetOperatorKey(ctx, pub2)
if err != nil {
t.Fatal(err)
}
if orphaned != 2 {
t.Fatalf("replacing the key orphaned %d, and two were sealed to it", orphaned)
if orphaned != 3 {
t.Fatalf("replacing the key orphaned %d, and three were sealed to it", orphaned)
}
if now, _ := inv.OperatorKey(ctx); now != pub2 {
t.Fatal("the new key is not the mesh's key")
}
kept, earlier, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
if len(kept) != 0 || len(earlier) != 3 {
t.Fatalf("after replacing the key: %d recoverable with it, %d sealed to the earlier key", len(kept), len(earlier))
}
doc, err := inv.OperatorExport(ctx)
if err != nil || doc == nil || len(doc.EarlierKey) != 3 || len(doc.Kept) != 0 {
t.Fatalf("the export does not say what the current key cannot open: %+v %v", doc, err)
}
}
// A pair credential — what the vault provides a module — is sealed to the operator too, and the
@@ -134,13 +168,42 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret")
kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", "")
if err != nil {
t.Fatal(err)
}
if kept.Kind != "pair" || kept.Provider != "provider" {
t.Fatalf("kept as %+v", kept)
}
all, _, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
var pairs int
for _, k := range all {
if k.Kind == "pair" {
pairs++
}
}
if pairs != 1 {
t.Fatalf("%d pair credential(s) recoverable, expected 1", pairs)
}
// A second provider of the same provision: two rows, refused rather than the first one taken,
// unless the provider is named. And replacing the key counts pair credentials as orphaned.
if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "consumer"); err != nil {
t.Fatal(err)
}
if _, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", ""); err == nil || !strings.Contains(err.Error(), "--provider") {
t.Fatalf("two providers were not refused: %v", err)
}
if byName, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", "provider"); err != nil || byName.Provider != "provider" {
t.Fatalf("naming the provider did not select it: %+v %v", byName, err)
}
pub2, _, _ := secrets.Keypair()
if orphaned, err := inv.SetOperatorKey(ctx, pub2); err != nil || orphaned != 2 {
t.Fatalf("replacing the key orphaned %d pair credential(s), and two were sealed to it (%v)", orphaned, err)
}
fromOperator, err := secrets.Open(priv, kept.Sealed)
if err != nil {
t.Fatal(err)
@@ -153,17 +216,4 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) {
if string(fromOperator) != string(fromNode) {
t.Fatal("the operator's copy of the pair credential differs from the consumer's")
}
all, _, err := inv.KeptForOperator(ctx)
if err != nil {
t.Fatal(err)
}
var pairs int
for _, k := range all {
if k.Kind == "pair" {
pairs++
}
}
if pairs != 1 {
t.Fatalf("%d pair credential(s) in the export, expected 1", pairs)
}
}
+7 -29
View File
@@ -80,18 +80,11 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
if err != nil {
return Secret{}, err
}
var also []string
if operator != "" {
also = append(also, operator)
}
made, more, err := secrets.MakeAlso(consumerKey, providerKey, also...)
made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator)
if err != nil {
return Secret{}, err
}
var forOperator, operatorKey *string
if operator != "" {
forOperator, operatorKey = &more[0], &operator
}
forOperator, operatorKey := operatorColumns(operator, blob)
_, err = i.store.Pool().Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key)
@@ -246,21 +239,14 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri
if err != nil {
return "", err
}
var also []string
if operator != "" {
also = append(also, operator)
}
made, more, err := secrets.MakeAlso(key, key, also...)
if err != nil {
return "", err
}
// Sealed once to the machine — Make seals to two ends because a provision has two; here both
// are the same machine, and only one copy is kept — and once more to the operator when the
// mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from.
var forOperator, operatorKey *string
if operator != "" {
forOperator, operatorKey = &more[0], &operator
made, blob, err := secrets.MakeWithOperator(key, key, operator)
if err != nil {
return "", err
}
forOperator, operatorKey := operatorColumns(operator, blob)
if _, err := i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'made', $6, $7)
@@ -304,18 +290,10 @@ func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, nam
}
// And to the operator, when the mesh has one: a value a person supplied is the one a person
// most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended).
operator, err := i.OperatorKey(ctx)
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return err
}
var forOperator, operatorKey *string
if operator != "" {
blob, err := secrets.Seal(operator, []byte(value))
if err != nil {
return err
}
forOperator, operatorKey = &blob, &operator
}
_, err = i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
values ($1, $2, $3, $4, $5, 'accepted', $6, $7)
+8 -5
View File
@@ -12,18 +12,21 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) {
if err != nil {
t.Fatal(err)
}
sealed, more, err := MakeAlso(nodePub, nodePub, opPub)
sealed, forOperator, err := MakeWithOperator(nodePub, nodePub, opPub)
if err != nil {
t.Fatal(err)
}
if len(more) != 1 {
t.Fatalf("%d extra blobs for one extra key", len(more))
if forOperator == "" {
t.Fatal("no blob for the operator")
}
if _, none, err := MakeWithOperator(nodePub, nodePub, ""); err != nil || none != "" {
t.Fatalf("no operator key, yet a blob %q (%v)", none, err)
}
fromNode, err := Open(nodePriv, sealed.ForConsumer)
if err != nil {
t.Fatal(err)
}
fromOperator, err := Open(opPriv, more[0])
fromOperator, err := Open(opPriv, forOperator)
if err != nil {
t.Fatal(err)
}
@@ -33,7 +36,7 @@ func TestAThirdRecipientOpensWithItsOwnKeyOnly(t *testing.T) {
if len(fromNode) != 40 {
t.Fatalf("a minted value is %d characters, not 40", len(fromNode))
}
if _, err := Open(nodePriv, more[0]); err == nil {
if _, err := Open(nodePriv, forOperator); err == nil {
t.Fatal("the node's key opened the operator's blob")
}
if _, err := Open(opPriv, sealed.ForConsumer); err == nil {
+17 -19
View File
@@ -51,22 +51,22 @@ type Sealed struct {
// rather than reading the old one back — the only version of rotation that is honest about what
// the mesh knows.
func Make(consumerKey, providerKey string) (Sealed, error) {
sealed, _, err := MakeAlso(consumerKey, providerKey)
sealed, _, err := MakeWithOperator(consumerKey, providerKey, "")
return sealed, err
}
// MakeAlso is Make with further recipients: the same fresh value, sealed once more to each key in
// `also`, returned in that order.
// MakeWithOperator is Make with a third recipient: the same fresh value, sealed once more to the
// operator's key, returned beside the two node blobs — or "" when the mesh has no operator key.
//
// **For the operator key, and nothing else so far** (novox/hq ADR 0085, amended). A secret a module
// holds for itself is sealed to its node and, when the mesh has an operator key, to that as well —
// so a person holding the key can recover it when the node cannot. The plaintext still exists only
// inside this call; a third blob is one more thing the mesh cannot open, not one more copy it can.
func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string, error) {
// The operator is the one holder that is not a node (novox/hq ADR 0085, amended): a person with a
// key that never entered the mesh, who can recover a secret when the node cannot. The plaintext
// still exists only inside this call; a third blob is one more thing the mesh cannot open, not one
// more copy it can.
func MakeWithOperator(consumerKey, providerKey, operatorKey string) (Sealed, string, error) {
if consumerKey == "" || providerKey == "" {
// Sealing to an empty key would produce a blob nobody can open, stored as though it were
// a working credential. The caller knows which node is which and says so.
return Sealed{}, nil, fmt.Errorf("both ends need a sealing key before a secret can be made")
return Sealed{}, "", fmt.Errorf("both ends need a sealing key before a secret can be made")
}
// 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an
@@ -74,7 +74,7 @@ func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string
// "fit the tightest backend" rule ADR 0049 sets for the login, on the secret. 240 bits is ample.
value := make([]byte, 30)
if _, err := rand.Read(value); err != nil {
return Sealed{}, nil, err
return Sealed{}, "", err
}
// Base64 without padding, because it lands in a configuration file something else parses and
// a password containing a newline or a quote is a support call.
@@ -82,24 +82,22 @@ func MakeAlso(consumerKey, providerKey string, also ...string) (Sealed, []string
forConsumer, err := Seal(consumerKey, []byte(password))
if err != nil {
return Sealed{}, nil, err
return Sealed{}, "", err
}
forProvider, err := Seal(providerKey, []byte(password))
if err != nil {
return Sealed{}, nil, err
return Sealed{}, "", err
}
more := make([]string, 0, len(also))
for _, key := range also {
blob, err := Seal(key, []byte(password))
if err != nil {
return Sealed{}, nil, err
var forOperator string
if operatorKey != "" {
if forOperator, err = Seal(operatorKey, []byte(password)); err != nil {
return Sealed{}, "", err
}
more = append(more, blob)
}
return Sealed{
ForConsumer: forConsumer, ForProvider: forProvider,
ConsumerKey: consumerKey, ProviderKey: providerKey,
}, more, nil
}, forOperator, nil
}
// Accept seals a value somebody supplied, rather than one the mesh made.