Files
sgard/PROJECT_PLAN.md
Kyle Isom 5bb65795c8 Step 18: FIDO2 support with interface and mock.
FIDO2Device interface abstracts hardware interaction (Register, Derive,
Available, MatchesCredential). Real libfido2 implementation deferred;
mock device used for full test coverage.

AddFIDO2Slot: registers FIDO2 credential, derives KEK via HMAC-secret,
wraps DEK, adds fido2/<label> slot to manifest.

UnlockDEK: tries all fido2/* slots first (checks credential_id against
connected device), falls back to passphrase. User never specifies
which method.

6 tests: add slot, reject duplicate, unlock via FIDO2, fallback to
passphrase when device unavailable, slot persistence, encrypted
round-trip unlocked via FIDO2.

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

12 KiB

PROJECT_PLAN.md

Implementation plan for sgard. See ARCHITECTURE.md for design details.

Step 1: Project Scaffolding

Remove old C++ source files and set up the Go project.

  • Remove old files: sgard.cc, proto/, CMakeLists.txt, scripts/, .clang-format, .clang-tidy, .idea/, .trunk/
  • go mod init github.com/kisom/sgard
  • Add dependencies: gopkg.in/yaml.v3, github.com/spf13/cobra
  • Create directory structure: cmd/sgard/, manifest/, store/, garden/
  • Set up cmd/sgard/main.go with cobra root command and --repo persistent flag
  • Update CLAUDE.md to reflect Go project
  • Verify: go build ./... compiles clean

Step 2: Manifest Package

Can be done in parallel with Step 3.

  • manifest/manifest.go: Manifest and Entry structs with YAML tags
    • Entry types: file, directory, link
    • Mode as string type to avoid YAML octal coercion
    • Per-file updated timestamp
  • manifest/manifest.go: Load(path) and Save(path) functions
    • Save uses atomic write (write to .tmp, rename)
  • manifest/manifest_test.go: round-trip marshal/unmarshal, atomic save, entry type validation

Step 3: Store Package

Can be done in parallel with Step 2.

  • store/store.go: Store struct with root path
  • store/store.go: Write(data) (hash, error) — hash content, write to blobs/XX/YY/<hash>
  • store/store.go: Read(hash) ([]byte, error) — read blob by hash
  • store/store.go: Exists(hash) bool — check if blob exists
  • store/store.go: Delete(hash) error — remove a blob
  • store/store_test.go: write/read round-trip, integrity check, missing blob error

Step 4: Garden Core — Init and Add

Depends on Steps 2 and 3.

  • garden/hasher.go: HashFile(path) (string, error) — SHA-256 of a file
  • garden/garden.go: Garden struct tying manifest + store + root path
  • garden/garden.go: Open(root) (*Garden, error) — load existing repo
  • garden/garden.go: Init(root) (*Garden, error) — create new repo (dirs + empty manifest)
  • garden/garden.go: Add(paths []string) error — hash files, store blobs, add manifest entries
  • garden/garden_test.go: init creates correct structure, add stores blob and updates manifest
  • Wire up CLI: cmd/sgard/init.go, cmd/sgard/add.go
  • Verify: go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc

Step 5: Checkpoint and Status

Depends on Step 4.

  • garden/garden.go: Checkpoint(message string) error — re-hash all tracked files, store changed blobs, update manifest timestamps
  • garden/garden.go: Status() ([]FileStatus, error) — compare current hashes to manifest; report modified/missing/ok
  • garden/garden_test.go: checkpoint detects changed files, status reports correctly
  • Wire up CLI: cmd/sgard/checkpoint.go, cmd/sgard/status.go

Step 6: Restore

Depends on Step 5.

  • garden/garden.go: Restore(paths []string, force bool, confirm func) error
    • Restore all files if paths is empty, otherwise just the specified paths
    • Timestamp comparison: skip prompt if manifest updated is newer than file mtime
    • Prompt user if file on disk is newer or times match (unless --force)
    • Create parent directories as needed
    • Recreate symlinks for link type entries
    • Set file permissions from manifest mode
  • garden/garden_test.go: restore writes correct content, respects permissions, handles symlinks
  • Wire up CLI: cmd/sgard/restore.go

Step 7: Remaining Commands

These can be done in parallel with each other.

  • garden/remove.go: Remove(paths []string) error — remove manifest entries
  • garden/verify.go: Verify() ([]VerifyResult, error) — check blobs against manifest hashes
  • garden/list.go: List() []Entry — return all manifest entries
  • garden/diff.go: Diff(path string) (string, error) — diff stored blob vs current file
  • Wire up CLI: cmd/sgard/remove.go, cmd/sgard/verify.go, cmd/sgard/list.go, cmd/sgard/diff.go
  • Tests for each

Step 8: Polish

  • Lint setup (golangci-lint config)
  • Clock abstraction: inject jonboulle/clockwork into Garden for deterministic timestamp tests
  • End-to-end test: init → add → checkpoint → modify file → status → restore → verify
  • Ensure go vet ./... and go test ./... pass clean
  • Update CLAUDE.md, ARCHITECTURE.md, PROGRESS.md

Phase 2: gRPC Remote Sync

Step 9: Proto Definitions + Code Gen

  • Write proto/sgard/v1/sgard.proto — 5 RPCs (PushManifest, PushBlobs, PullManifest, PullBlobs, Prune), all messages
  • Add Makefile target for protoc code generation
  • Add grpc, protobuf, x/crypto deps to go.mod
  • Update flake.nix devShell with protoc tools
  • Verify: go build ./sgardpb compiles

Step 10: Garden Accessor Methods

Can be done in parallel with Step 11.

  • garden/garden.go: GetManifest(), BlobExists(), ReadBlob(), WriteBlob(), ReplaceManifest()
  • Tests for each accessor
  • Verify: go test ./garden/...

Step 11: Proto-Manifest Conversion

Can be done in parallel with Step 10.

  • server/convert.go: ManifestToProto, ProtoToManifest, entry helpers
  • server/convert_test.go: round-trip test
  • Verify: go test ./server/...

Step 12: Server Implementation (No Auth)

Depends on Steps 9, 10, 11.

  • server/server.go: Server struct with RWMutex, 5 RPC handlers (+ Prune)
  • PushManifest: timestamp compare, compute missing blobs
  • PushBlobs: receive stream, write to store, replace manifest
  • PullManifest: return manifest
  • PullBlobs: stream requested blobs (64 KiB chunks)
  • Prune: remove orphaned blobs (added store.List + garden.ListBlobs/DeleteBlob)
  • server/server_test.go: in-process test with bufconn, push+pull+prune

Step 12b: Directory Recursion and Mirror Command

  • garden/garden.go: Add recurses directories — walk all files/symlinks, add each as its own entry
  • garden/mirror.go: MirrorUp(paths []string) error — walk directory, add new files, remove entries for files gone from disk, re-hash changed
  • garden/mirror.go: MirrorDown(paths []string, force bool, confirm func(string) bool) error — restore all tracked files under path, delete anything not in manifest
  • garden/mirror_test.go: tests for recursive add, mirror up (detects new/removed), mirror down (cleans extras)
  • cmd/sgard/mirror.go: sgard mirror up <path>, sgard mirror down <path> [--force]
  • Update existing add tests for directory recursion

Step 13: Client Library (No Auth)

Depends on Step 12.

  • client/client.go: Client struct, Push(), Pull(), Prune() methods
  • client/client_test.go: integration tests (push+pull cycle, server newer, up-to-date, prune)

Step 14: SSH Key Auth

  • server/auth.go: AuthInterceptor, parse authorized_keys, verify SSH signatures
  • client/auth.go: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
  • server/auth_test.go: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
  • client/auth_test.go: metadata generation, no-transport-security
  • Integration tests: authenticated push/pull succeeds, unauthenticated is rejected

Step 15: CLI Wiring + Prune

Depends on Steps 13, 14.

  • garden/prune.go: Prune() (int, error) — collect referenced hashes, delete orphaned blobs
  • garden/prune_test.go: prune removes orphaned, keeps referenced
  • server/server.go: Prune RPC (done in Step 12)
  • proto/sgard/v1/sgard.proto: Prune RPC (done in Step 9)
  • client/client.go: Prune() method (done in Step 13)
  • cmd/sgard/prune.go: local prune; with --remote prunes remote instead
  • cmd/sgard/main.go: add --remote, --ssh-key persistent flags, resolveRemote()
  • cmd/sgard/push.go, cmd/sgard/pull.go
  • cmd/sgardd/main.go: flags, garden open, auth interceptor, gRPC serve
  • Verify: both binaries compile

Step 16: Polish + Release

  • Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
  • Update flake.nix (add sgardd, updated vendorHash)
  • Update .goreleaser.yaml (add sgardd build)
  • E2e integration test: init two repos, push from one, pull into other (with auth)
  • Verify: all tests pass, full push/pull cycle works

Phase 3: Encryption

Step 17: Encryption Core (Passphrase Only)

  • manifest/manifest.go: add Encrypted, PlaintextHash fields to Entry; add Encryption section with KekSlots map to Manifest
  • garden/encrypt.go: EncryptInit(passphrase string) error — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
  • garden/encrypt.go: UnlockDEK(prompt) error — read slots, try passphrase, unwrap DEK; cache in memory for command duration
  • garden/encrypt.go: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
  • garden/garden.go: modify Add to accept encrypt flag — encrypt blob before storing, set encrypted: true and plaintext_hash on entry
  • garden/garden.go: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
  • garden/garden.go: modify Restore to decrypt encrypted blobs before writing
  • garden/diff.go: modify Diff to decrypt stored blob before diffing
  • garden/garden.go: modify Status to use plaintext_hash for encrypted entries
  • Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
  • Verify: go test ./... && go vet ./... && golangci-lint run ./...

Step 18: FIDO2 Support

Depends on Step 17.

  • garden/encrypt_fido2.go: FIDO2Device interface, AddFIDO2Slot, unlockFIDO2, defaultFIDO2Label
  • garden/encrypt.go: UnlockDEK tries fido2/* slots first (credential_id matching), falls back to passphrase
  • garden/encrypt_fido2_test.go: mock FIDO2 device, 6 tests (add slot, duplicate rejected, unlock via FIDO2, fallback to passphrase, persistence, encrypted round-trip with FIDO2)
  • Verify: go test ./... && go vet ./... && golangci-lint run ./...

Step 19: Encryption CLI + Slot Management

Depends on Steps 17, 18.

  • cmd/sgard/encrypt.go: sgard encrypt init [--fido2] — creates DEK + passphrase slot (+ FIDO2 slot if --fido2)
  • cmd/sgard/encrypt.go: sgard encrypt add-fido2 [--label] — adds FIDO2 slot
  • cmd/sgard/encrypt.go: sgard encrypt remove-slot <name> — removes a slot (refuse if it's the last one)
  • cmd/sgard/encrypt.go: sgard encrypt list-slots — print slot names and types
  • cmd/sgard/encrypt.go: sgard encrypt change-passphrase — re-wrap DEK with new passphrase
  • cmd/sgard/add.go: add --encrypt flag
  • Update proto: add encrypted, plaintext_hash to ManifestEntry; add encryption section to Manifest message
  • Update server/convert.go: handle new fields in proto conversion
  • Verify: both binaries compile, go test ./...

Step 20: Encryption Polish + Release

  • E2e test: add encrypted + plaintext files, push to server, pull to fresh repo, decrypt and verify
  • Update ARCHITECTURE.md, README.md, CLAUDE.md
  • Update flake.nix vendorHash if deps changed
  • Verify: all tests pass, lint clean

Future Steps (Not Phase 3)

  • Shell completion via cobra
  • TLS transport (optional --tls-cert/--tls-key on sgardd)
  • Multiple repo support on server
  • Manifest signing (requires trust model design)
  • DEK rotation (sgard encrypt rotate-dek — re-encrypt all blobs)