mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
An adopted plaintext keeps the same digest as the pre-seal snapshot, so while the process kept adopting, a rollback that replanted that snapshot would be re-admitted — the same bytes adoption had just taken. Seal.Persisted, called by both stores once a sealed write reaches disk or the Secret, ends adoption: the store is sealed now, so a later unsealed store is a different store and is refused. Reads of the sealed store are unaffected. It fires on the write that LANDS, not the one attempted — a conflicted SharedStore write does not reach it, so the retry re-read of a still-unsealed object is unchanged. The signal is a method on the Seal contract rather than a runtime type check: the store tells the seal a sealed write persisted, and what a seal does with that is the seal's own concern; the identity seal does nothing.
313 lines
11 KiB
Go
313 lines
11 KiB
Go
package kms
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Seal encrypts the ACME state document at rest.
|
|
//
|
|
// The document holds the ACME account key and every leaf private key the edge
|
|
// has been issued: whoever reads it can serve every domain the estate serves
|
|
// and can re-issue for the rest. Sealed, the file is inert — the key that opens
|
|
// it is fetched from KMS at startup and exists only in this process's memory.
|
|
//
|
|
// Envelope, not raw encryption. Each write mints a fresh 256-bit data key,
|
|
// encrypts the document under it, and encrypts THAT key under the key from KMS.
|
|
// Two consequences worth the twenty extra lines: the long-lived key encrypts
|
|
// only 48 bytes per write rather than the whole document, so it is nowhere near
|
|
// any usage bound; and recovering one write's data key opens that write and
|
|
// nothing else.
|
|
//
|
|
// AES-256-GCM both times, and both layers are bound to the same four facts:
|
|
// the format version, WHICH store the bytes belong to, WHICH key sealed them,
|
|
// and WHICH write they are. Those facts travel in the envelope and in the
|
|
// additional data of both seals, so the bytes open only where they were
|
|
// written, under the key they were written with, at the write they were made
|
|
// at. Moving an envelope between stores, re-pointing it at another key, or
|
|
// editing its count all fail to open rather than opening into something the
|
|
// ACME provider would act on.
|
|
type Seal struct {
|
|
kek cipher.AEAD
|
|
id string
|
|
|
|
mu sync.Mutex
|
|
adopt bool
|
|
taken *[32]byte // the one unsealed document this seal admitted
|
|
}
|
|
|
|
// envelope is the sealed document as written. It is JSON so that a file which
|
|
// is sealed announces it, and so a reader can tell a sealed document from one
|
|
// written before sealing was configured without guessing.
|
|
type envelope struct {
|
|
Seal int `json:"seal"` // format version
|
|
ID string `json:"id"` // fingerprint of the key this was sealed under
|
|
Count uint64 `json:"count"` // which write this is, per store
|
|
Key string `json:"key"` // nonce || data key sealed under the KMS key
|
|
Data string `json:"data"` // nonce || document sealed under the data key
|
|
}
|
|
|
|
const version = 1
|
|
|
|
// ErrUnsealed reports bytes that carry no envelope. It is a distinct error
|
|
// because it is the one an operator can answer: a store written before sealing
|
|
// was configured is taken under seal by adopting it once.
|
|
var ErrUnsealed = errors.New("kms: the stored document is not sealed")
|
|
|
|
// NewSeal builds a seal over a 256-bit key.
|
|
//
|
|
// adopt says what to do with a store that is not sealed. False — the default
|
|
// everywhere — reads it as an error: the store this process was pointed at is
|
|
// not the store this key wrote, and continuing would mean writing this edge's
|
|
// account key over whatever is there. True opens ONE such store and hands it
|
|
// back for the caller to write under seal, which is how a store that predates
|
|
// sealing keeps its certificates instead of re-ordering every one of them.
|
|
func NewSeal(key []byte, adopt bool) (*Seal, error) {
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("kms: seal key is %d bytes, want 32", len(key))
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kek, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Seal{kek: kek, id: fingerprint(key), adopt: adopt}, nil
|
|
}
|
|
|
|
// fingerprint names a key without revealing it: half a SHA-256 over a fixed
|
|
// label and the key. It goes in the envelope so a document sealed under a
|
|
// retired key says so, instead of reading as corrupt.
|
|
func fingerprint(key []byte) string {
|
|
sum := sha256.Sum256(append([]byte("hanzo ingress acme seal\x00"), key...))
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// SealFrom fetches the sealing key from KMS. The value is 32 bytes as hex or
|
|
// base64 — both, because a key generated with `openssl rand -hex 32` and one
|
|
// generated with `-base64 32` are the same key and neither spelling should be
|
|
// the wrong one.
|
|
func SealFrom(ctx context.Context, c *Client, name string, adopt bool) (*Seal, error) {
|
|
raw, err := c.Get(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := decodeKey(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kms: %s: %w", name, err)
|
|
}
|
|
return NewSeal(key, adopt)
|
|
}
|
|
|
|
func decodeKey(raw string) ([]byte, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
|
return b, nil
|
|
}
|
|
if b, err := base64.StdEncoding.DecodeString(raw); err == nil && len(b) == 32 {
|
|
return b, nil
|
|
}
|
|
if b, err := base64.RawURLEncoding.DecodeString(raw); err == nil && len(b) == 32 {
|
|
return b, nil
|
|
}
|
|
return nil, errors.New("not a 32-byte key in hex or base64")
|
|
}
|
|
|
|
// ID is the fingerprint of the key this seal holds. A store logs it so an
|
|
// operator can see which key an edge is running under.
|
|
func (s *Seal) ID() string { return s.id }
|
|
|
|
// Wrap seals plain as the count-th write of the store called name.
|
|
//
|
|
// Counting starts at one. Zero is the count of a document that was never
|
|
// written under seal, so it is not a count a write may claim.
|
|
func (s *Seal) Wrap(name string, count uint64, plain []byte) ([]byte, error) {
|
|
if count == 0 {
|
|
return nil, errors.New("kms: a sealed write is counted from one")
|
|
}
|
|
aad := bind(version, name, s.id, count)
|
|
|
|
dek := make([]byte, 32)
|
|
if _, err := rand.Read(dek); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := newAEAD(dek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sealedDoc, err := seal(data, plain, aad)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sealedKey, err := seal(s.kek, dek, aad)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(envelope{
|
|
Seal: version,
|
|
ID: s.id,
|
|
Count: count,
|
|
Key: base64.StdEncoding.EncodeToString(sealedKey),
|
|
Data: base64.StdEncoding.EncodeToString(sealedDoc),
|
|
}, "", " ")
|
|
}
|
|
|
|
// Unwrap returns the document stored for the store called name, the write it
|
|
// was made at, and whether it was under seal.
|
|
//
|
|
// The envelope's own version, store name, key id and count are the additional
|
|
// data both layers were sealed with, so every one of them is verified by the
|
|
// decryption rather than trusted from the JSON. An envelope written for another
|
|
// store, under another key, or edited to claim another count does not open.
|
|
//
|
|
// Bytes that carry no envelope are ErrUnsealed, unless this seal was built to
|
|
// adopt one: then they come back as they are, with sealed false, and the caller
|
|
// writes them under seal.
|
|
func (s *Seal) Unwrap(name string, stored []byte) (plain []byte, count uint64, sealed bool, err error) {
|
|
env, ok := parse(stored)
|
|
if !ok {
|
|
if !s.take(stored) {
|
|
return nil, 0, false, ErrUnsealed
|
|
}
|
|
return stored, 0, false, nil
|
|
}
|
|
if env.Seal != version {
|
|
return nil, 0, true, fmt.Errorf("kms: sealed document version %d, want %d", env.Seal, version)
|
|
}
|
|
if env.ID != s.id {
|
|
return nil, 0, true, fmt.Errorf("kms: document sealed under key %s, this edge holds %s", env.ID, s.id)
|
|
}
|
|
if env.Count == 0 {
|
|
return nil, 0, true, errors.New("kms: sealed document claims write 0")
|
|
}
|
|
aad := bind(env.Seal, name, env.ID, env.Count)
|
|
|
|
sealedKey, err := base64.StdEncoding.DecodeString(env.Key)
|
|
if err != nil {
|
|
return nil, 0, true, fmt.Errorf("kms: sealed key: %w", err)
|
|
}
|
|
dek, err := open(s.kek, sealedKey, aad)
|
|
if err != nil {
|
|
return nil, 0, true, fmt.Errorf("kms: unwrap data key: %w", err)
|
|
}
|
|
data, err := newAEAD(dek)
|
|
if err != nil {
|
|
return nil, 0, true, err
|
|
}
|
|
sealedDoc, err := base64.StdEncoding.DecodeString(env.Data)
|
|
if err != nil {
|
|
return nil, 0, true, fmt.Errorf("kms: sealed document: %w", err)
|
|
}
|
|
doc, err := open(data, sealedDoc, aad)
|
|
if err != nil {
|
|
return nil, 0, true, fmt.Errorf("kms: unseal document: %w", err)
|
|
}
|
|
return doc, env.Count, true, nil
|
|
}
|
|
|
|
// take spends the operator's adoption on ONE document.
|
|
//
|
|
// The first unsealed document is admitted and remembered. That same document is
|
|
// admitted again, because a store is read more than once before the write that
|
|
// seals it lands and a replica that cannot write reads it on every poll. A
|
|
// DIFFERENT unsealed document is not: adoption covers the store the operator
|
|
// was looking at, not every unsealed store this process is handed for as long
|
|
// as it runs.
|
|
//
|
|
// The memory is this seal's, so it lasts one process. A seal built to adopt
|
|
// will adopt again after a restart — which is why the setting is removed once
|
|
// it has done its work, and why it announces itself on every boot it is set.
|
|
func (s *Seal) take(stored []byte) bool {
|
|
sum := sha256.Sum256(stored)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.adopt {
|
|
return false
|
|
}
|
|
if s.taken == nil {
|
|
s.taken = &sum
|
|
return true
|
|
}
|
|
return *s.taken == sum
|
|
}
|
|
|
|
// Persisted ends adoption once a sealed write has reached the store.
|
|
//
|
|
// Adoption admits the first unsealed store this process is handed, so a store
|
|
// that predates sealing can be taken over. Once that store has been written
|
|
// back under seal, adoption has done its work: a store handed to this process
|
|
// afterwards that is STILL unsealed is a different store — a snapshot from
|
|
// before the seal, replanted — and it is refused. Reads of the now-sealed store
|
|
// are unaffected: a sealed envelope opens whether or not adoption is still on.
|
|
func (s *Seal) Persisted() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.adopt = false
|
|
s.taken = nil
|
|
}
|
|
|
|
// bind renders the facts the ciphertext is tied to. Each variable-length field
|
|
// is written after its length, so no combination of a store name and a key id
|
|
// can produce the bytes of another combination.
|
|
func bind(version int, name, id string, count uint64) []byte {
|
|
b := binary.BigEndian.AppendUint64(nil, uint64(version))
|
|
b = field(b, name)
|
|
b = field(b, id)
|
|
return binary.BigEndian.AppendUint64(b, count)
|
|
}
|
|
|
|
func field(b []byte, s string) []byte {
|
|
b = binary.BigEndian.AppendUint64(b, uint64(len(s)))
|
|
return append(b, s...)
|
|
}
|
|
|
|
// parse reports whether b is a sealed document. A plaintext ACME store is a
|
|
// JSON OBJECT of resolvers, so it decodes into this shape with a zero version —
|
|
// which is what distinguishes the two, not the presence of the keys.
|
|
func parse(b []byte) (envelope, bool) {
|
|
var env envelope
|
|
if err := json.Unmarshal(b, &env); err != nil {
|
|
return envelope{}, false
|
|
}
|
|
return env, env.Seal != 0
|
|
}
|
|
|
|
func newAEAD(key []byte) (cipher.AEAD, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
// seal returns nonce || ciphertext. The nonce is random per call and carried
|
|
// with the ciphertext, so no caller has to keep a counter and no two writes can
|
|
// reuse one.
|
|
func seal(a cipher.AEAD, plain, aad []byte) ([]byte, error) {
|
|
nonce := make([]byte, a.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
return a.Seal(nonce, nonce, plain, aad), nil
|
|
}
|
|
|
|
func open(a cipher.AEAD, b, aad []byte) ([]byte, error) {
|
|
if len(b) < a.NonceSize() {
|
|
return nil, errors.New("ciphertext shorter than its nonce")
|
|
}
|
|
return a.Open(nil, b[:a.NonceSize()], b[a.NonceSize():], aad)
|
|
}
|