package secrets import ( "crypto/rand" "encoding/base64" "fmt" "strings" "golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/secretbox" ) // A value the mesh keeps encrypted so ONE node — and nothing else, not this database on its own — // can read it back. // // **Why this exists at all, and why it is not the seal above.** The per-holder seal (Seal / Make / // Accept) is one-way delivery: the mesh closes a value to a node's public key, the node opens it // once with the private half the mesh never saw, and the mesh keeps nothing it can read. That is // the whole guarantee, and for every credential the mesh handles it is the right one — there is // nothing to rotate, so nothing has to be read back. // // A `refreshable-grant` credential (novox/hq ADR 0050) breaks that, and the ADR says so in as many // words: it cannot be *sealed so the mesh cannot read it* and *rotated centrally* at once, because // rotating it means some node reads the refresh token back, repeatedly, every time the grant is // refreshed. The carve-out the ADR draws is exactly and only this: the **manager node** holds the // refresh token **encrypted at rest**, readable **by that node**, because rotation requires it. // // So this is a genuinely different mechanism from the anonymous-box seal, not a second caller of it: // // - The payload is under a **symmetric** data key (NaCl secretbox), because the same node decrypts // it again and again — an anonymous sealed box is nonce-less one-shot delivery, not a store its // writer reopens. // - Only the **data key** is sealed to the manager's public sealing key, with the very same // anonymous box the per-holder seal uses (Seal, below). This is envelope encryption: the bulk // is symmetric so it can be reopened, the key is asymmetric so only the manager can recover it. // // **Why this database alone cannot read it.** What is stored is the secretbox ciphertext and the // data key *wrapped to the manager node's public sealing key*. Recovering the data key needs the // manager node's Curve25519 private half, which never leaves that machine (novox/hq ADR 0004) and // which the control plane has never held. A copy of this database is therefore a directory of // ciphertexts and wrapped keys with nothing to open either — which is the property a plain // encrypted-at-rest column does not have, because there the key sits beside the data. // // **Where each half runs.** SealAtRest and OpenAtRest are the mechanism, kept here in one audited // place. In production only the **manager node** runs them — it produces the envelope when the grant // is first adopted, and opens it to refresh (novox/hq ADR 0050, Phase C). The control plane stores // and forwards the envelope as an opaque blob and never calls OpenAtRest on a live path; it holds no // private key that could. OpenAtRest lives here so the round trip and the security bounds are // testable, and so the manager-side code has one implementation to reuse rather than a second to // keep in step. type AtRest struct { // Token is base64( nonce ‖ secretbox(dataKey, plaintext) ) — the refresh token under the // symmetric data key, the nonce carried in front of the box as its convention allows. Token string // WrappedKey is base64( anonymous-box(managerSealingKey, dataKey) ) — the data key closed to the // manager node, openable only by that node's private half. WrappedKey string // ManagerKey is the manager's public sealing key the data key was wrapped to. Kept for the same // reason licence_holder.node_key and module_secret.node_key are: a manager that has since // regenerated its key can be told it can no longer open this, rather than discovering it as a // refresh that fails to decrypt. ManagerKey string } // SealAtRest wraps a value so only the holder of managerSealingKey's private half can read it. // // A fresh random data key each time, so two envelopes of the same refresh token look nothing alike // and a rotation that changed nothing is indistinguishable from one that changed everything — the // same property the per-holder seal has, kept here deliberately. func SealAtRest(value, managerSealingKey string) (AtRest, error) { if strings.TrimSpace(value) == "" { return AtRest{}, fmt.Errorf("there is nothing to seal") } if managerSealingKey == "" { return AtRest{}, fmt.Errorf( "the manager has no sealing key, so a refresh token cannot be kept for it") } var dataKey [32]byte if _, err := rand.Read(dataKey[:]); err != nil { return AtRest{}, err } // Zeroed on the way out. The plaintext data key exists for the length of this call and no // longer, which is what keeps the envelope's secrecy resting on the wrapped copy alone. defer func() { for i := range dataKey { dataKey[i] = 0 } }() var nonce [24]byte if _, err := rand.Read(nonce[:]); err != nil { return AtRest{}, err } // secretbox.Seal prepends nothing; the nonce is our prefix, carried so OpenAtRest can recover it. sealedToken := secretbox.Seal(nonce[:], []byte(value), &nonce, &dataKey) wrapped, err := Seal(managerSealingKey, dataKey[:]) if err != nil { return AtRest{}, err } return AtRest{ Token: base64.StdEncoding.EncodeToString(sealedToken), WrappedKey: wrapped, ManagerKey: managerSealingKey, }, nil } // OpenAtRest recovers the value, given the manager node's own key pair. // // This is the manager-node / test half of the mechanism (see the type comment): the control plane // has no private key and never calls it on a live path. func OpenAtRest(a AtRest, managerPublicKey, managerPrivateKey string) (string, error) { pub, err := base64.StdEncoding.DecodeString(managerPublicKey) if err != nil || len(pub) != 32 { return "", fmt.Errorf("%q is not a sealing key", managerPublicKey) } priv, err := base64.StdEncoding.DecodeString(managerPrivateKey) if err != nil || len(priv) != 32 { return "", fmt.Errorf("the manager private key is not 32 bytes") } var pubArr, privArr [32]byte copy(pubArr[:], pub) copy(privArr[:], priv) wrapped, err := base64.StdEncoding.DecodeString(a.WrappedKey) if err != nil { return "", fmt.Errorf("the wrapped key is not base64: %w", err) } keyBytes, ok := box.OpenAnonymous(nil, wrapped, &pubArr, &privArr) if !ok { return "", fmt.Errorf("this refresh token was not wrapped to this manager's key") } if len(keyBytes) != 32 { return "", fmt.Errorf("the wrapped key is the wrong length") } var dataKey [32]byte copy(dataKey[:], keyBytes) defer func() { for i := range dataKey { dataKey[i] = 0 } }() raw, err := base64.StdEncoding.DecodeString(a.Token) if err != nil { return "", fmt.Errorf("the sealed token is not base64: %w", err) } if len(raw) < 24 { return "", fmt.Errorf("the sealed token is too short to hold a nonce") } var nonce [24]byte copy(nonce[:], raw[:24]) out, ok := secretbox.Open(nil, raw[24:], &nonce, &dataKey) if !ok { return "", fmt.Errorf("the refresh token would not open under its data key") } return string(out), nil }