Encryption config lives in the manifest, syncs with push/pull.

Wrapped DEKs and salts stored inline as base64 in the manifest's
encryption section. No separate files (encryption.yaml, salt files,
dek.enc.*) — the manifest is fully self-contained.

Pulling to a new machine gives you everything needed to decrypt.
Server never has the DEK. FIDO2 cross-machine note: device-bound
hmac-secret requires add-fido2 on each machine; passphrase fallback
enables cross-machine decryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 08:23:25 -07:00
parent 079b235c9d
commit e24b66776c

View File

@@ -48,29 +48,39 @@ updated: "2026-03-23T14:30:00Z"
message: "pre-upgrade checkpoint" # optional message: "pre-upgrade checkpoint" # optional
files: files:
- path: ~/.bashrc # original location (default restore target) - path: ~/.bashrc # plaintext file
hash: a1b2c3d4e5f6... # SHA-256 of file contents hash: a1b2c3d4e5f6... # SHA-256 of file contents
type: file # file | directory | link type: file
mode: "0644" # permissions (quoted to avoid YAML coercion) mode: "0644"
updated: "2026-03-23T14:30:00Z" # last checkpoint time for this file
- path: ~/.config/nvim
type: directory
mode: "0755"
updated: "2026-03-23T14:30:00Z" updated: "2026-03-23T14:30:00Z"
# directories have no hash or blob — they're structural entries
- path: ~/.vimrc - path: ~/.vimrc
type: link type: link
target: ~/.config/nvim/init.vim # symlink target target: ~/.config/nvim/init.vim
updated: "2026-03-23T14:30:00Z" updated: "2026-03-23T14:30:00Z"
# links have no hash or blob — just the target path
- path: ~/.ssh/config - path: ~/.ssh/config # encrypted file
hash: d4e5f6a1b2c3... hash: f8e9d0c1... # SHA-256 of encrypted blob
plaintext_hash: e5f6a7... # SHA-256 of plaintext
encrypted: true
type: file type: file
mode: "0600" mode: "0600"
updated: "2026-03-23T14:30:00Z" 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.
encryption:
algorithm: xchacha20-poly1305
kek_sources:
- type: fido2
salt: "base64-encoded-16-byte-salt"
wrapped_dek: "base64-encoded-nonce+ciphertext+tag"
- type: passphrase
argon2_time: 3
argon2_memory: 65536
argon2_threads: 4
salt: "base64-encoded-16-byte-salt"
wrapped_dek: "base64-encoded-nonce+ciphertext+tag"
``` ```
### Blob Store ### Blob Store
@@ -308,9 +318,10 @@ DEK (Data Encryption Key) — random, encrypts/decrypts file blobs
**DEK (Data Encryption Key):** **DEK (Data Encryption Key):**
- 256-bit random key, generated once when encryption is first enabled - 256-bit random key, generated once when encryption is first enabled
- Used with XChaCha20-Poly1305 (AEAD) to encrypt every blob - Used with XChaCha20-Poly1305 (AEAD) to encrypt file blobs
- Never stored in plaintext — always wrapped by the KEK - Never stored in plaintext — always wrapped by the KEK
- Stored as `<repo>/dek.enc` (KEK-encrypted) - Each KEK source stores its own wrapped copy in the manifest
(`encryption.kek_sources[].wrapped_dek`, base64-encoded)
**KEK (Key Encryption Key):** **KEK (Key Encryption Key):**
- Derived from the user's secret - Derived from the user's secret
@@ -326,13 +337,13 @@ Two methods. A repo may have either or both:
**Passphrase:** **Passphrase:**
- KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4) - KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4)
- Salt stored at `<repo>/kek-passphrase.salt` (16 random bytes) - Salt and Argon2id parameters stored in the manifest
- Argon2id parameters stored in `encryption.yaml` for forward compat (`encryption.kek_sources[]` with `type: passphrase`)
**FIDO2 hmac-secret:** **FIDO2 hmac-secret:**
- KEK = HMAC-SHA256 output from the FIDO2 authenticator - KEK = HMAC-SHA256 output from the FIDO2 authenticator
- The authenticator computes `HMAC(device_secret, salt)` where the salt - The authenticator computes `HMAC(device_secret, salt)` where the salt
is stored at `<repo>/kek-fido2.salt` is stored in the manifest (`encryption.kek_sources[]` with `type: fido2`)
- Requires a FIDO2 key that supports the `hmac-secret` extension - Requires a FIDO2 key that supports the `hmac-secret` extension
- User touch is required to derive the KEK - User touch is required to derive the KEK
@@ -406,37 +417,30 @@ behavior), and `plaintext_hash` and `encrypted` are omitted.
### DEK Storage ### DEK Storage
The DEK is encrypted with the KEK using XChaCha20-Poly1305 and stored The DEK is wrapped (encrypted) by each KEK source using
at `<repo>/dek.enc`: XChaCha20-Poly1305 and stored in the manifest as base64:
``` ```
[24-byte nonce][encrypted DEK + 16-byte tag] wrapped_dek = base64([24-byte nonce][encrypted DEK + 16-byte tag])
``` ```
### KEK Sources and Unlock Resolution Each KEK source in `encryption.kek_sources[]` carries its own
`wrapped_dek`. This means the manifest is fully self-contained —
pulling it to a new machine gives you everything needed to decrypt
(given the user's secret).
A repo can have one or both KEK sources. Each wraps the same DEK ### Unlock Resolution
independently:
``` When sgard needs the DEK, it reads the `encryption` section of the
<repo>/dek.enc.passphrase # DEK wrapped by passphrase-derived KEK manifest to discover which sources are configured, then tries them
<repo>/dek.enc.fido2 # DEK wrapped by FIDO2-derived KEK in preference order:
```
Either source can unwrap the DEK. Adding a second source requires the 1. **FIDO2** (if a `type: fido2` source exists):
DEK (unlocked by the existing source) to create the new wrapped copy.
**Automatic unlock resolution (no user flags needed):**
When sgard needs the DEK, it reads `encryption.yaml` to discover which
sources are configured, then tries them in order:
1. **FIDO2** (if `dek.enc.fido2` exists):
- Check if a FIDO2 device is connected - Check if a FIDO2 device is connected
- If yes → prompt for touch, derive KEK, unwrap DEK - If yes → prompt for touch, derive KEK, unwrap DEK
- If device not found or touch times out → fall through - If device not found or touch times out → fall through
2. **Passphrase** (if `dek.enc.passphrase` exists): 2. **Passphrase** (if a `type: passphrase` source exists):
- Prompt for passphrase on stdin - Prompt for passphrase on stdin
- Derive KEK via Argon2id, unwrap DEK - Derive KEK via Argon2id, unwrap DEK
@@ -447,31 +451,14 @@ passphrase is the fallback for when the FIDO2 key isn't physically
present (e.g., on a different machine). The user never specifies which present (e.g., on a different machine). The user never specifies which
method to use; sgard figures it out. method to use; sgard figures it out.
### Repo Configuration The `kek_sources` list in the manifest is ordered by preference
(FIDO2 first). The presence of the `encryption` section indicates the
Encryption config stored at `<repo>/encryption.yaml`: repo has encryption capability. Individual files opt in via `--encrypt`
at add time.
```yaml
algorithm: xchacha20-poly1305
kek_sources:
- type: fido2
salt_file: kek-fido2.salt
dek_file: dek.enc.fido2
- type: passphrase
argon2_time: 3
argon2_memory: 65536 # KiB
argon2_threads: 4
salt_file: kek-passphrase.salt
dek_file: dek.enc.passphrase
```
The `kek_sources` list is ordered by preference (FIDO2 first). The
presence of `encryption.yaml` indicates the repo has encryption
capability. Individual files opt in via `--encrypt` at add time.
### CLI Integration ### CLI Integration
**Setting up encryption (creates DEK and wraps it):** **Setting up encryption (creates DEK, adds `encryption` to manifest):**
```sh ```sh
sgard encrypt init # passphrase only sgard encrypt init # passphrase only
sgard encrypt init --fido2 # FIDO2 + passphrase fallback sgard encrypt init --fido2 # FIDO2 + passphrase fallback
@@ -480,7 +467,8 @@ sgard encrypt init --fido2 # FIDO2 + passphrase fallback
When `--fido2` is specified, sgard creates both sources: the FIDO2 When `--fido2` is specified, sgard creates both sources: the FIDO2
wrap (primary) and immediately prompts for a passphrase to create the wrap (primary) and immediately prompts for a passphrase to create the
fallback wrap. This ensures the user is never locked out if they lose fallback wrap. This ensures the user is never locked out if they lose
the FIDO2 key. the FIDO2 key. Both wrapped DEKs and salts are stored inline in the
manifest as base64.
Without `--fido2`, only the passphrase source is created. Without `--fido2`, only the passphrase source is created.
@@ -536,9 +524,34 @@ daemon or on-disk secret, both of which expand the attack surface.
### Repos Without Encryption ### Repos Without Encryption
A repo with no `encryption.yaml` has no DEK and cannot have encrypted A manifest with no `encryption` section has no DEK and cannot have
entries. The `--encrypt` flag on `add` will error, prompting the user encrypted entries. The `--encrypt` flag on `add` will error, prompting
to run `sgard encrypt init` first. All existing behavior is unchanged. the user to run `sgard encrypt init` first. All existing behavior is
unchanged.
### Encryption and Remote Sync
The server never has the DEK. Push/pull transfers the manifest
(including the `encryption` section with wrapped DEKs and salts) and
encrypted blobs as opaque bytes. The server cannot decrypt file
contents.
When pulling to a new machine:
1. The manifest arrives with the `encryption` section intact
2. The wrapped DEKs and salts are present in the manifest
3. The user provides their passphrase (or touches their FIDO2 key)
4. sgard derives the KEK, unwraps the DEK, decrypts blobs on restore
No additional setup is needed on the new machine beyond having the
passphrase or a FIDO2 key. The manifest carries everything.
**FIDO2 cross-machine note:** FIDO2 hmac-secret is device-bound. A
different physical key on machine B produces a different KEK, so the
FIDO2 `wrapped_dek` from machine A won't unwrap. The passphrase
fallback is what enables cross-machine decryption. A user who wants
FIDO2 on multiple machines must run `sgard encrypt add-fido2` on each
machine (which re-wraps the DEK with that machine's FIDO2 key and adds
a new source to the manifest).
### Future: Manifest Signing ### Future: Manifest Signing