132 lines
5.6 KiB
Go
132 lines
5.6 KiB
Go
package secrets
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"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) {
|
||
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
|
||
}
|
||
return Sealed{
|
||
ForConsumer: forConsumer, ForProvider: forProvider,
|
||
ConsumerKey: consumerKey, ProviderKey: providerKey,
|
||
}, 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
|
||
}
|
||
|
||
// 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
|
||
}
|