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.
199 lines
8.4 KiB
Go
199 lines
8.4 KiB
Go
package secrets
|
||
|
||
import (
|
||
"crypto/ecdh"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"golang.org/x/crypto/nacl/box"
|
||
)
|
||
|
||
// Secrets the mesh delivers and cannot read.
|
||
//
|
||
// **What this is not.** The obvious arrangement is a credentials column, encrypted at rest. It
|
||
// has been built, in another mesh, and that mesh's own tooling records what it bought: a query
|
||
// against the encrypted column returns zero rows and proves nothing, so auditing moved to the
|
||
// decrypted copies on the nodes; and the tool for finding a secret has to search **by value**
|
||
// rather than by name, because the same password sits in the provisions table, in the environment
|
||
// table, in each node's environment file in plain text, and inside every connection string
|
||
// composed from it — copies its own documentation calls "often the only copies actually in use".
|
||
//
|
||
// Two faults there, and encryption at rest addresses neither. **The control plane can read what
|
||
// it stores**, so a copy of its database is a copy of every credential in the mesh. And **one
|
||
// secret has many homes with nothing tracking them.**
|
||
//
|
||
// So here the value is sealed to the node that will use it before it is stored, with a key that
|
||
// node generated and whose private half the mesh has never seen. What gets written is unusable by
|
||
// whoever holds it, the mesh included. And nothing is composed centrally — a connection string is
|
||
// assembled on the machine that needs one, so the mesh never mints a second copy in a shape
|
||
// nothing tracks.
|
||
|
||
// Sealed is one value, closed to both ends of a provision.
|
||
//
|
||
// Two blobs of the same secret rather than one shared key: a key both ends hold is a key the mesh
|
||
// would have to distribute, which is this problem again one level down.
|
||
type Sealed struct {
|
||
ForConsumer string
|
||
ForProvider string
|
||
// Which key each was sealed to, kept so a node that regenerated its key can be told what it
|
||
// can no longer open rather than discovering it as a service that will not start.
|
||
ConsumerKey string
|
||
ProviderKey string
|
||
}
|
||
|
||
// Make generates a secret and seals it to both ends, keeping no readable copy.
|
||
//
|
||
// The plaintext exists for the length of this call. Rotation is therefore generating a new one
|
||
// 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 := MakeWithOperator(consumerKey, providerKey, "")
|
||
return sealed, err
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// 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{}, "", 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
|
||
// S3 access key accepts (8–40), the tightest of the backends a minted password reaches — the same
|
||
// "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{}, "", 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.
|
||
password := base64.RawURLEncoding.EncodeToString(value)
|
||
|
||
forConsumer, err := Seal(consumerKey, []byte(password))
|
||
if err != nil {
|
||
return Sealed{}, "", err
|
||
}
|
||
forProvider, err := Seal(providerKey, []byte(password))
|
||
if err != nil {
|
||
return Sealed{}, "", err
|
||
}
|
||
var forOperator string
|
||
if operatorKey != "" {
|
||
if forOperator, err = Seal(operatorKey, []byte(password)); err != nil {
|
||
return Sealed{}, "", err
|
||
}
|
||
}
|
||
return Sealed{
|
||
ForConsumer: forConsumer, ForProvider: forProvider,
|
||
ConsumerKey: consumerKey, ProviderKey: providerKey,
|
||
}, forOperator, nil
|
||
}
|
||
|
||
// Accept seals a value somebody supplied, rather than one the mesh made.
|
||
//
|
||
// **The mesh generates most of what it hands out and discards the plaintext.** An API key for a
|
||
// hosted service does not work that way: it comes from a person, and the mesh's job is to carry it
|
||
// to the machines that need it without being able to read it afterwards
|
||
// (novox/hq ADR 0024).
|
||
//
|
||
// So the value is sealed on the way in and **the plaintext is not kept**. That is the whole of the
|
||
// difference from the arrangement this replaces, where an operator-supplied key sits in a column
|
||
// the control plane can read — which makes a copy of the database a copy of every account the mesh
|
||
// touches.
|
||
//
|
||
// The consequence is deliberate and worth stating: **the mesh cannot show it back.** Somebody who
|
||
// loses the key gets a new one from wherever it came from; there is no "reveal" and there cannot
|
||
// be one, because a mesh that can reveal a secret is a mesh that holds it.
|
||
func Accept(value string, consumerKey, providerKey string) (Sealed, error) {
|
||
if strings.TrimSpace(value) == "" {
|
||
return Sealed{}, fmt.Errorf("there is nothing to seal")
|
||
}
|
||
if consumerKey == "" || providerKey == "" {
|
||
return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be kept")
|
||
}
|
||
forConsumer, err := Seal(consumerKey, []byte(value))
|
||
if err != nil {
|
||
return Sealed{}, err
|
||
}
|
||
forProvider, err := Seal(providerKey, []byte(value))
|
||
if err != nil {
|
||
return Sealed{}, err
|
||
}
|
||
return Sealed{
|
||
ForConsumer: forConsumer, ForProvider: forProvider,
|
||
ConsumerKey: consumerKey, ProviderKey: providerKey,
|
||
}, nil
|
||
}
|
||
|
||
// Open is the other half of Seal, for the one holder of a private key this program ever acts for:
|
||
// the operator, recovering a secret with the key that never entered the mesh (novox/hq ADR 0085,
|
||
// amended). A node opens its own blobs in the host; the controller opens nothing of a node's, and
|
||
// cannot — it has no node's private key, which is the whole point of sealing.
|
||
func Open(privateKey string, sealed string) ([]byte, error) {
|
||
private, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKey))
|
||
if err != nil || len(private) != 32 {
|
||
return nil, fmt.Errorf("that is not a sealing key")
|
||
}
|
||
key, err := ecdh.X25519().NewPrivateKey(private)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("that is not a usable sealing key: %w", err)
|
||
}
|
||
blob, err := base64.StdEncoding.DecodeString(sealed)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("this is not a sealed value: %w", err)
|
||
}
|
||
var pub, priv [32]byte
|
||
copy(pub[:], key.PublicKey().Bytes())
|
||
copy(priv[:], private)
|
||
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
|
||
if !ok {
|
||
return nil, fmt.Errorf("this was not sealed to that key")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Keypair makes a sealing keypair for a holder outside the mesh — the operator. The private half is
|
||
// returned to be written where the caller says and nowhere else; the public half is what the mesh
|
||
// records and seals to.
|
||
func Keypair() (public, private string, err error) {
|
||
key, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()),
|
||
base64.StdEncoding.EncodeToString(key.Bytes()), nil
|
||
}
|
||
|
||
// Fingerprint names a public key without being one: the first bytes of its hash, so two people can
|
||
// agree which key they mean out loud.
|
||
func Fingerprint(publicKey string) string {
|
||
sum := sha256.Sum256([]byte(strings.TrimSpace(publicKey)))
|
||
return "sha256:" + hex.EncodeToString(sum[:8])
|
||
}
|
||
|
||
// Seal closes a value to a node's public sealing key.
|
||
func Seal(publicKey string, value []byte) (string, error) {
|
||
public, err := base64.StdEncoding.DecodeString(publicKey)
|
||
if err != nil || len(public) != 32 {
|
||
return "", fmt.Errorf("%q is not a sealing key", publicKey)
|
||
}
|
||
var pub [32]byte
|
||
copy(pub[:], public)
|
||
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||
}
|