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

@@ -1,12 +1,17 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var encryptFlag bool
var addCmd = &cobra.Command{
Use: "add <path>...",
Short: "Track files, directories, or symlinks",
@@ -17,7 +22,16 @@ var addCmd = &cobra.Command{
return err
}
if err := g.Add(args); err != nil {
if encryptFlag {
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
}
if err := g.UnlockDEK(promptPassphrase); err != nil {
return err
}
}
if err := g.Add(args, encryptFlag); err != nil {
return err
}
@@ -26,6 +40,16 @@ var addCmd = &cobra.Command{
},
}
func promptPassphrase() (string, error) {
fmt.Fprint(os.Stderr, "Passphrase: ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text()), nil
}
return "", fmt.Errorf("no passphrase provided")
}
func init() {
addCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "encrypt file contents before storing")
rootCmd.AddCommand(addCmd)
}

166
cmd/sgard/encrypt.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"fmt"
"sort"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var encryptCmd = &cobra.Command{
Use: "encrypt",
Short: "Manage encryption keys and slots",
}
var fido2InitFlag bool
var encryptInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize encryption (creates DEK and passphrase slot)",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
passphrase, err := promptPassphrase()
if err != nil {
return err
}
if err := g.EncryptInit(passphrase); err != nil {
return err
}
fmt.Println("Encryption initialized with passphrase slot.")
if fido2InitFlag {
fmt.Println("FIDO2 support requires a hardware device implementation.")
fmt.Println("Run 'sgard encrypt add-fido2' when a FIDO2 device is available.")
}
return nil
},
}
var fido2LabelFlag string
var addFido2Cmd = &cobra.Command{
Use: "add-fido2",
Short: "Add a FIDO2 KEK slot",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
}
if err := g.UnlockDEK(promptPassphrase); err != nil {
return err
}
// Real FIDO2 device implementation would go here.
// For now, this is a placeholder that explains the requirement.
return fmt.Errorf("FIDO2 hardware support not yet implemented; requires libfido2 binding")
},
}
var removeSlotCmd = &cobra.Command{
Use: "remove-slot <name>",
Short: "Remove a KEK slot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.RemoveSlot(args[0]); err != nil {
return err
}
fmt.Printf("Removed slot %q.\n", args[0])
return nil
},
}
var listSlotsCmd = &cobra.Command{
Use: "list-slots",
Short: "List all KEK slots",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
slots := g.ListSlots()
if len(slots) == 0 {
fmt.Println("No encryption configured.")
return nil
}
// Sort for consistent output.
names := make([]string, 0, len(slots))
for name := range slots {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%-30s %s\n", name, slots[name])
}
return nil
},
}
var changePassphraseCmd = &cobra.Command{
Use: "change-passphrase",
Short: "Change the passphrase for the passphrase KEK slot",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized")
}
// Unlock with current passphrase.
fmt.Println("Enter current passphrase:")
if err := g.UnlockDEK(promptPassphrase); err != nil {
return err
}
// Get new passphrase.
fmt.Println("Enter new passphrase:")
newPassphrase, err := promptPassphrase()
if err != nil {
return err
}
if err := g.ChangePassphrase(newPassphrase); err != nil {
return err
}
fmt.Println("Passphrase changed.")
return nil
},
}
func init() {
encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also set up FIDO2 (placeholder)")
addFido2Cmd.Flags().StringVar(&fido2LabelFlag, "label", "", "slot label (default: fido2/<hostname>)")
encryptCmd.AddCommand(encryptInitCmd)
encryptCmd.AddCommand(addFido2Cmd)
encryptCmd.AddCommand(removeSlotCmd)
encryptCmd.AddCommand(listSlotsCmd)
encryptCmd.AddCommand(changePassphraseCmd)
rootCmd.AddCommand(encryptCmd)
}