Files
sgard/garden/encrypt.go
Kyle Isom 5529fff649 Step 24: DEK rotation.
RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
re-wraps with all existing KEK slots (passphrase + FIDO2). CLI wired
as `sgard encrypt rotate-dek`. 4 tests covering rotation, persistence,
FIDO2 re-wrap, and requires-unlock guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:01:57 -07:00

450 lines
12 KiB
Go

package garden
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/kisom/sgard/manifest"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
)
const (
dekSize = 32 // 256-bit DEK
saltSize = 16
algorithmName = "xchacha20-poly1305"
defaultArgon2Time = 3
defaultArgon2Memory = 64 * 1024 // 64 MiB in KiB
defaultArgon2Threads = 4
)
// EncryptInit sets up encryption on the repo by generating a DEK and
// wrapping it with a passphrase-derived KEK. The encryption config is
// stored in the manifest.
func (g *Garden) EncryptInit(passphrase string) error {
if g.manifest.Encryption != nil {
return fmt.Errorf("encryption already initialized")
}
// Generate DEK.
dek := make([]byte, dekSize)
if _, err := rand.Read(dek); err != nil {
return fmt.Errorf("generating DEK: %w", err)
}
// Generate salt for passphrase KEK.
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
// Derive KEK from passphrase.
kek := derivePassphraseKEK(passphrase, salt, defaultArgon2Time, defaultArgon2Memory, defaultArgon2Threads)
// Wrap DEK.
wrappedDEK, err := wrapDEK(dek, kek)
if err != nil {
return fmt.Errorf("wrapping DEK: %w", err)
}
g.manifest.Encryption = &manifest.Encryption{
Algorithm: algorithmName,
KekSlots: map[string]*manifest.KekSlot{
"passphrase": {
Type: "passphrase",
Argon2Time: defaultArgon2Time,
Argon2Memory: defaultArgon2Memory,
Argon2Threads: defaultArgon2Threads,
Salt: base64.StdEncoding.EncodeToString(salt),
WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK),
},
},
}
g.dek = dek
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// UnlockDEK attempts to unwrap the DEK using available KEK slots.
// Resolution order: try all fido2/* slots first (if a device is provided),
// then fall back to the passphrase slot. The DEK is cached on the Garden
// for the duration of the command.
func (g *Garden) UnlockDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
if g.dek != nil {
return nil // already unlocked
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized; run sgard encrypt init")
}
// 1. Try FIDO2 slots first.
if len(fido2Device) > 0 && fido2Device[0] != nil {
if g.unlockFIDO2(fido2Device[0]) {
return nil
}
}
// 2. Fall back to passphrase slot.
if slot, ok := enc.KekSlots["passphrase"]; ok {
if promptPassphrase == nil {
return fmt.Errorf("passphrase required but no prompt available")
}
passphrase, err := promptPassphrase()
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
return fmt.Errorf("decoding salt: %w", err)
}
kek := derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK)
if err != nil {
return fmt.Errorf("decoding wrapped DEK: %w", err)
}
dek, err := unwrapDEK(wrappedDEK, kek)
if err != nil {
return fmt.Errorf("wrong passphrase or corrupted DEK: %w", err)
}
g.dek = dek
return nil
}
return fmt.Errorf("no usable KEK slot found")
}
// HasEncryption reports whether the repo has encryption configured.
func (g *Garden) HasEncryption() bool {
return g.manifest.Encryption != nil
}
// RemoveSlot removes a KEK slot by name. Refuses to remove the last slot.
func (g *Garden) RemoveSlot(name string) error {
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
if _, ok := enc.KekSlots[name]; !ok {
return fmt.Errorf("slot %q not found", name)
}
if len(enc.KekSlots) <= 1 {
return fmt.Errorf("cannot remove the last KEK slot")
}
delete(enc.KekSlots, name)
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// ListSlots returns the slot names and types.
func (g *Garden) ListSlots() map[string]string {
enc := g.manifest.Encryption
if enc == nil {
return nil
}
result := make(map[string]string, len(enc.KekSlots))
for name, slot := range enc.KekSlots {
result[name] = slot.Type
}
return result
}
// ChangePassphrase re-wraps the DEK with a new passphrase. The DEK must
// already be unlocked.
func (g *Garden) ChangePassphrase(newPassphrase string) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked")
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
slot, ok := enc.KekSlots["passphrase"]
if !ok {
return fmt.Errorf("no passphrase slot to change")
}
// Generate new salt.
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
kek := derivePassphraseKEK(newPassphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
wrappedDEK, err := wrapDEK(g.dek, kek)
if err != nil {
return fmt.Errorf("wrapping DEK: %w", err)
}
slot.Salt = base64.StdEncoding.EncodeToString(salt)
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
// re-wraps the new DEK with all existing KEK slots. The old DEK must
// already be unlocked. A passphrase prompt is required to re-derive
// the KEK for the passphrase slot. An optional FIDO2 device re-wraps
// FIDO2 slots; FIDO2 slots without a matching device are dropped.
func (g *Garden) RotateDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked")
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
oldDEK := g.dek
// Generate new DEK.
newDEK := make([]byte, dekSize)
if _, err := rand.Read(newDEK); err != nil {
return fmt.Errorf("generating new DEK: %w", err)
}
// Re-encrypt all encrypted blobs.
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
if !entry.Encrypted || entry.Hash == "" {
continue
}
// Read encrypted blob.
ciphertext, err := g.store.Read(entry.Hash)
if err != nil {
return fmt.Errorf("reading blob %s for %s: %w", entry.Hash, entry.Path, err)
}
// Decrypt with old DEK.
g.dek = oldDEK
plaintext, err := g.decryptBlob(ciphertext)
if err != nil {
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
}
// Re-encrypt with new DEK.
g.dek = newDEK
newCiphertext, err := g.encryptBlob(plaintext)
if err != nil {
return fmt.Errorf("re-encrypting %s: %w", entry.Path, err)
}
// Write new blob.
newHash, err := g.store.Write(newCiphertext)
if err != nil {
return fmt.Errorf("writing re-encrypted blob for %s: %w", entry.Path, err)
}
entry.Hash = newHash
// PlaintextHash stays the same — the plaintext didn't change.
}
// Re-wrap new DEK with all existing KEK slots.
for name, slot := range enc.KekSlots {
var kek []byte
switch slot.Type {
case "passphrase":
if promptPassphrase == nil {
return fmt.Errorf("passphrase required to re-wrap slot %q", name)
}
passphrase, err := promptPassphrase()
if err != nil {
return fmt.Errorf("reading passphrase: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
return fmt.Errorf("decoding salt for slot %q: %w", name, err)
}
kek = derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
case "fido2":
var device FIDO2Device
if len(fido2Device) > 0 {
device = fido2Device[0]
}
if device == nil || !device.Available() {
// Drop FIDO2 slots without a matching device.
delete(enc.KekSlots, name)
continue
}
credID, err := base64.StdEncoding.DecodeString(slot.CredentialID)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if !device.MatchesCredential(credID) {
delete(enc.KekSlots, name)
continue
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
fido2KEK, err := device.Derive(credID, salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if len(fido2KEK) < dekSize {
delete(enc.KekSlots, name)
continue
}
kek = fido2KEK[:dekSize]
default:
return fmt.Errorf("unknown slot type %q for slot %q", slot.Type, name)
}
wrappedDEK, err := wrapDEK(newDEK, kek)
if err != nil {
return fmt.Errorf("re-wrapping DEK for slot %q: %w", name, err)
}
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
}
g.dek = newDEK
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// NeedsDEK reports whether any of the given entries are encrypted.
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
for _, e := range entries {
if e.Encrypted {
return true
}
}
return false
}
// encryptBlob encrypts plaintext with the DEK and returns the ciphertext.
func (g *Garden) encryptBlob(plaintext []byte) ([]byte, error) {
if g.dek == nil {
return nil, fmt.Errorf("DEK not unlocked")
}
aead, err := chacha20poly1305.NewX(g.dek)
if err != nil {
return nil, fmt.Errorf("creating cipher: %w", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generating nonce: %w", err)
}
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// decryptBlob decrypts ciphertext with the DEK and returns the plaintext.
func (g *Garden) decryptBlob(ciphertext []byte) ([]byte, error) {
if g.dek == nil {
return nil, fmt.Errorf("DEK not unlocked")
}
aead, err := chacha20poly1305.NewX(g.dek)
if err != nil {
return nil, fmt.Errorf("creating cipher: %w", err)
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce := ciphertext[:nonceSize]
ct := ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: %w", err)
}
return plaintext, nil
}
// plaintextHash computes the SHA-256 hash of plaintext data.
func plaintextHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// derivePassphraseKEK derives a KEK from a passphrase using Argon2id.
func derivePassphraseKEK(passphrase string, salt []byte, time, memory, threads int) []byte {
return argon2.IDKey([]byte(passphrase), salt, uint32(time), uint32(memory), uint8(threads), dekSize)
}
// wrapDEK encrypts the DEK with the KEK using XChaCha20-Poly1305.
func wrapDEK(dek, kek []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(kek)
if err != nil {
return nil, err
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return aead.Seal(nonce, nonce, dek, nil), nil
}
// unwrapDEK decrypts the DEK with the KEK.
func unwrapDEK(wrapped, kek []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(kek)
if err != nil {
return nil, err
}
nonceSize := aead.NonceSize()
if len(wrapped) < nonceSize {
return nil, fmt.Errorf("wrapped DEK too short")
}
nonce := wrapped[:nonceSize]
ct := wrapped[nonceSize:]
return aead.Open(nil, nonce, ct, nil)
}