Step 19: Encryption CLI, slot management, proto updates.

CLI: sgard encrypt init [--fido2], add-fido2 [--label], remove-slot,
list-slots, change-passphrase. sgard add --encrypt flag with
passphrase prompt for DEK unlock.

Garden: RemoveSlot (refuses last slot), ListSlots, ChangePassphrase
(re-wraps DEK with new passphrase, fresh salt).

Proto: ManifestEntry gains encrypted + plaintext_hash fields. New
KekSlot and Encryption messages. Manifest gains encryption field.

server/convert.go: full round-trip conversion for encryption section
including KekSlot map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 09:25:20 -07:00
parent 5bb65795c8
commit 76a53320c1
8 changed files with 661 additions and 125 deletions

View File

@@ -135,6 +135,84 @@ 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
}
// NeedsDEK reports whether any of the given entries are encrypted.
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
for _, e := range entries {