Secrets vault: operator key, a second seal on every secret, recovery and export #34
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user