# ARCHITECTURE.md Design document for sgard (Shimmering Clarity Gardener), a dotfiles manager. ## Overview sgard manages dotfiles by checkpointing them into a portable repository and restoring them to their original locations. The repository is a single directory that can live anywhere — local disk, USB drive, NFS mount — making it portable between machines. ## Tech Stack **Language: Go** (`github.com/kisom/sgard`) - Static binaries by default, no runtime dependencies on target machines. - First-class gRPC and protobuf support for the future remote mode. - Standard library covers all core needs: file I/O (`os`, `path/filepath`), hashing (`crypto/sha256`), and cross-platform path handling. - Trivial cross-compilation via `GOOS`/`GOARCH`. **CLI framework: cobra** **Manifest format: YAML** (via `gopkg.in/yaml.v3`) - Human-readable and supports comments (unlike JSON). - Natural syntax for lists of structured entries (unlike TOML's `[[array_of_tables]]`). - File modes stored as quoted strings (`"0644"`) to avoid YAML's octal coercion. ## Repository Layout on Disk A sgard repository is a single directory with this structure: ``` / manifest.yaml # single manifest tracking all files .gitignore # excludes blobs/ (created by sgard init) blobs/ a1/b2/a1b2c3d4... # content-addressable file storage ``` ### Manifest Schema ```yaml version: 1 created: "2026-03-23T12:00:00Z" updated: "2026-03-23T14:30:00Z" message: "pre-upgrade checkpoint" # optional files: - path: ~/.bashrc # plaintext file hash: a1b2c3d4e5f6... # SHA-256 of file contents type: file mode: "0644" updated: "2026-03-23T14:30:00Z" - path: ~/.vimrc type: link target: ~/.config/nvim/init.vim updated: "2026-03-23T14:30:00Z" - path: ~/.ssh/config # encrypted file hash: f8e9d0c1... # SHA-256 of encrypted blob plaintext_hash: e5f6a7... # SHA-256 of plaintext encrypted: true type: file mode: "0600" updated: "2026-03-23T14:30:00Z" # Encryption config — only present if sgard encrypt init has been run. # Travels with the manifest so a new machine can decrypt after pull. # KEK slots are a map keyed by user-chosen label. encryption: algorithm: xchacha20-poly1305 kek_slots: passphrase: type: passphrase argon2_time: 3 argon2_memory: 65536 argon2_threads: 4 salt: "base64..." wrapped_dek: "base64..." fido2/workstation: type: fido2 credential_id: "base64..." salt: "base64..." wrapped_dek: "base64..." fido2/laptop: type: fido2 credential_id: "base64..." salt: "base64..." wrapped_dek: "base64..." ``` ### Blob Store Files are stored by their SHA-256 hash in a two-level directory structure: ``` blobs/// ``` Example: a file with hash `a1b2c3d4e5...` is stored at `blobs/a1/b2/a1b2c3d4e5...` Properties: - **Deduplication**: identical files across different paths share one blob. - **Rename-safe**: moving a dotfile to a new path updates only the manifest. - **Integrity**: the filename *is* the expected hash — corruption is trivially detectable. - **Directories and symlinks** are manifest-only entries. No blobs are stored for them. ## CLI Commands All commands operate on a repository directory (default: `~/.sgard`, override with `--repo`). ### Local | Command | Description | |---|---| | `sgard init [--repo ]` | Create a new repository | | `sgard add ...` | Track files, directories (recursed), or symlinks | | `sgard remove ...` | Untrack files; run `prune` to clean orphaned blobs | | `sgard checkpoint [-m ]` | Re-hash all tracked files, store changed blobs, update manifest | | `sgard restore [...] [--force]` | Restore files from manifest to their original locations | | `sgard status` | Compare current files against manifest: modified, missing, ok | | `sgard verify` | Check all blobs against manifest hashes (integrity check) | | `sgard list` | List all tracked files | | `sgard diff ` | Show content diff between current file and stored blob | | `sgard prune` | Remove orphaned blobs not referenced by the manifest | | `sgard mirror up ...` | Sync filesystem → manifest (add new, remove deleted, rehash) | | `sgard mirror down ... [--force]` | Sync manifest → filesystem (restore + delete untracked) | **Workflow example:** ```sh # Initialize a repo on a USB drive sgard init --repo /mnt/usb/dotfiles # Track some files sgard add ~/.bashrc ~/.gitconfig ~/.ssh/config --repo /mnt/usb/dotfiles # Checkpoint current state sgard checkpoint -m "initial" --repo /mnt/usb/dotfiles # On a new machine, restore sgard restore --repo /mnt/usb/dotfiles ``` ### Remote | Command | Description | |---|---| | `sgard push` | Push checkpoint to remote gRPC server | | `sgard pull` | Pull checkpoint from remote gRPC server | | `sgard prune` | With `--remote`, prunes orphaned blobs on the server | | `sgardd` | Run the gRPC sync daemon | ## gRPC Protocol The GardenSync service uses four RPCs for sync plus one for maintenance: ``` service GardenSync { rpc PushManifest(PushManifestRequest) returns (PushManifestResponse); rpc PushBlobs(stream PushBlobsRequest) returns (PushBlobsResponse); rpc PullManifest(PullManifestRequest) returns (PullManifestResponse); rpc PullBlobs(PullBlobsRequest) returns (stream PullBlobsResponse); rpc Prune(PruneRequest) returns (PruneResponse); } ``` **Push flow:** Client sends manifest → server compares `manifest.Updated` timestamps → if client newer, server returns list of missing blob hashes → client streams those blobs (64 KiB chunks) → server replaces its manifest. **Pull flow:** Client requests server manifest → compares timestamps locally → if server newer, requests missing blobs → server streams them → client replaces its manifest. **Last timestamp wins** for conflict resolution (single-user, personal sync). ## Authentication Authentication is designed to be transparent — the user never explicitly logs in or manages credentials. It uses SSH keys they already have. ### Overview Two mechanisms, layered: 1. **SSH key signing** — used to obtain a token or when no valid token exists 2. **JWT token** — used for all subsequent requests, cached on disk From the user's perspective, authentication is automatic. The client handles token acquisition, caching, and renewal without prompting. ### Token-Based Auth (Primary Path) The server issues signed JWT tokens valid for 30 days. The client caches the token and attaches it as gRPC metadata on every call. ``` service GardenSync { rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse); // ... other RPCs } ``` **Authenticate RPC:** - Client sends an SSH-signed challenge (nonce + timestamp + public key) - Server verifies the signature against its `authorized_keys` file - Server returns a JWT signed with its own secret key - JWT claims: public key fingerprint, issued-at, 30-day expiry **Normal request flow:** 1. Client reads cached token from `$XDG_STATE_HOME/sgard/token` (falls back to `~/.local/state/sgard/token`) 2. Client attaches token as `x-sgard-auth-token` gRPC metadata 3. Server verifies JWT signature and expiry 4. If valid → request proceeds **Token rejection — two cases:** The server distinguishes between an expired-but-previously-valid token and a completely invalid one: - **Expired token** (valid signature, known fingerprint still in authorized_keys, but past expiry): server returns `Unauthenticated` with a `ReauthChallenge` — a server-generated nonce embedded in the error details. This is the fast path. - **Invalid token** (bad signature, unknown fingerprint, corrupted): server returns a plain `Unauthenticated` with no challenge. The client falls back to the full Authenticate flow. **Fast re-auth flow (expired token, transparent to user):** 1. Client sends request with expired token 2. Server returns `Unauthenticated` + `ReauthChallenge{nonce, timestamp}` 3. Client signs the server-provided nonce+timestamp with SSH key 4. Client calls `Authenticate` with the signature 5. Server verifies, issues new JWT 6. Client caches new token to disk 7. Client retries the original request with the new token This saves a round trip compared to full re-auth — the server provides the nonce, so the client doesn't need to generate one and hope it's accepted. The server controls the challenge, which also prevents any client-side nonce reuse. **Full auth flow (no valid token, transparent to user):** 1. Client has no cached token or token is completely invalid 2. Client calls `Authenticate` with a self-generated nonce+timestamp, signed with SSH key 3. Server verifies, issues JWT 4. Client caches token, proceeds with original request ### SSH Key Signing Used during the `Authenticate` RPC to prove possession of an authorized SSH private key. The challenge can come from the server (re-auth fast path) or be generated by the client (initial auth). **Challenge payload:** `nonce (32 random bytes) || timestamp (big-endian int64)` **Authenticate RPC request fields:** - `nonce` — 32-byte nonce (from server's ReauthChallenge or client-generated) - `timestamp` — Unix seconds - `signature` — SSH signature over (nonce || timestamp) - `public_key` — SSH public key in authorized_keys format **Server verification:** - Parse public key, check fingerprint against `authorized_keys` file - Verify SSH signature over the payload - Check timestamp is within 5-minute window (prevents replay) ### Server-Side Token Management The server does not store tokens. JWTs are stateless — the server signs them with a secret key and verifies its own signature on each request. **Secret key:** Generated on first startup, stored at `/jwt.key` (32 random bytes). If the key file is deleted, all outstanding tokens become invalid and clients re-authenticate automatically. **No revocation mechanism.** For a single-user personal sync tool, revocation is unnecessary. Removing a key from `authorized_keys` prevents new token issuance. Existing tokens expire naturally within 30 days. Deleting `jwt.key` invalidates all tokens immediately. ### Client-Side Token Storage Token cached at `$XDG_STATE_HOME/sgard/token` (per XDG Base Directory spec, state is "data that should persist between restarts but isn't important enough to back up"). Falls back to `~/.local/state/sgard/token`. The token file contains the raw JWT string. File permissions are set to `0600`. ### Key Resolution SSH key resolution order (for initial authentication): 1. `--ssh-key` flag (explicit path to private key) 2. `SGARD_SSH_KEY` environment variable 3. ssh-agent (if `SSH_AUTH_SOCK` is set, uses first available key) 4. Default paths: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa` ## Encryption sgard supports optional at-rest encryption for individual files. Encryption is per-file, not per-repo — any file can be marked as encrypted at add time. A repo may contain a mix of encrypted and plaintext blobs. ### Key Hierarchy A two-layer key hierarchy separates the encryption key from the user's secret (passphrase or FIDO2 key): ``` User Secret (passphrase or FIDO2 hmac-secret) │ ▼ KEK (Key Encryption Key) — derived from user secret │ ▼ DEK (Data Encryption Key) — random, encrypts/decrypts file blobs ``` **DEK (Data Encryption Key):** - 256-bit random key, generated once when encryption is first enabled - Used with XChaCha20-Poly1305 (AEAD) to encrypt file blobs - Never stored in plaintext — always wrapped by the KEK - Each KEK source stores its own wrapped copy in the manifest (`encryption.kek_sources[].wrapped_dek`, base64-encoded) **KEK (Key Encryption Key):** - Derived from the user's secret - Used only to wrap/unwrap the DEK, never to encrypt data directly - Never stored on disk — derived on demand This separation means changing a passphrase or adding a FIDO2 key only requires re-wrapping the DEK, not re-encrypting every blob. ### KEK Derivation Two slot types. A repo has one `passphrase` slot and zero or more `fido2/