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>
450 lines
12 KiB
Go
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)
|
|
}
|