Recoverable means sealed to the current operator key; recovery names the provider

From review: the export counted any operator-sealed row as recoverable, so a
secret sealed to a replaced key was reported as openable with the current one;
replacing the key counted orphans in one table of two; and a pair credential
held from two providers was recovered as whichever row came first. The export
now lists what the current key opens, what an earlier key opens, and what has
no copy; `secret recover` takes --provider and refuses ambiguity; files that
must not exist are created exclusively; one constructor builds the export for
the operator's file and the vault's disk alike.
This commit is contained in:
2026-09-21 01:16:32 +02:00
parent 565f144a20
commit 77e6c1a684
9 changed files with 308 additions and 141 deletions
+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.