KEK slots: named map with passphrase + fido2/<label> convention.

Slots are a map keyed by user-chosen label. One passphrase slot
(universal fallback), zero or more fido2/<label> slots (default to
hostname, overridable via --label).

FIDO2 slots carry credential_id to match connected devices without
prompting for touch. Unlock tries all fido2/* slots first, falls
back to passphrase.

CLI: add-fido2 [--label], remove-slot, list-slots, change-passphrase.
New FIDO2 slots propagate to server on next push.

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

View File

@@ -69,18 +69,27 @@ files:
# Encryption config — only present if sgard encrypt init has been run. # Encryption config — only present if sgard encrypt init has been run.
# Travels with the manifest so a new machine can decrypt after pull. # Travels with the manifest so a new machine can decrypt after pull.
# KEK slots are a map keyed by user-chosen label.
encryption: encryption:
algorithm: xchacha20-poly1305 algorithm: xchacha20-poly1305
kek_sources: kek_slots:
- type: fido2 passphrase:
salt: "base64-encoded-16-byte-salt" type: passphrase
wrapped_dek: "base64-encoded-nonce+ciphertext+tag"
- type: passphrase
argon2_time: 3 argon2_time: 3
argon2_memory: 65536 argon2_memory: 65536
argon2_threads: 4 argon2_threads: 4
salt: "base64-encoded-16-byte-salt" salt: "base64..."
wrapped_dek: "base64-encoded-nonce+ciphertext+tag" 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 ### Blob Store
@@ -333,19 +342,21 @@ requires re-wrapping the DEK, not re-encrypting every blob.
### KEK Derivation ### KEK Derivation
Two methods. A repo may have either or both: Two slot types. A repo has one `passphrase` slot and zero or more
`fido2/<label>` slots:
**Passphrase:** **Passphrase slot** (at most one per repo):
- KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4) - KEK = Argon2id(passphrase, salt, time=3, memory=64MB, threads=4)
- Salt and Argon2id parameters stored in the manifest - Salt and Argon2id parameters stored in the slot entry
(`encryption.kek_sources[]` with `type: passphrase`) - Slot key: `passphrase`
**FIDO2 hmac-secret:** **FIDO2 slots** (one per device, labeled):
- 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)` using the
is stored in the manifest (`encryption.kek_sources[]` with `type: fido2`) credential registered for this slot
- Requires a FIDO2 key that supports the `hmac-secret` extension - `credential_id` in the slot entry ties it to a specific FIDO2
- User touch is required to derive the KEK registration, allowing sgard to skip non-matching devices
- Slot key: `fido2/<label>` (defaults to hostname, overridable)
### Blob Encryption ### Blob Encryption
@@ -417,60 +428,56 @@ behavior), and `plaintext_hash` and `encrypted` are omitted.
### DEK Storage ### DEK Storage
The DEK is wrapped (encrypted) by each KEK source using Each slot wraps the DEK independently using XChaCha20-Poly1305,
XChaCha20-Poly1305 and stored in the manifest as base64: stored as base64 in the slot's `wrapped_dek` field:
``` ```
wrapped_dek = base64([24-byte nonce][encrypted DEK + 16-byte tag]) wrapped_dek = base64([24-byte nonce][encrypted DEK + 16-byte tag])
``` ```
Each KEK source in `encryption.kek_sources[]` carries its own The manifest is fully self-contained — pulling it to a new machine
`wrapped_dek`. This means the manifest is fully self-contained — gives you everything needed to decrypt (given the user's secret).
pulling it to a new machine gives you everything needed to decrypt
(given the user's secret).
### Unlock Resolution ### Unlock Resolution
When sgard needs the DEK, it reads the `encryption` section of the When sgard needs the DEK, it reads `encryption.kek_slots` from the
manifest to discover which sources are configured, then tries them manifest and tries slots automatically:
in preference order:
1. **FIDO2** (if a `type: fido2` source exists): 1. **FIDO2 slots** (all `fido2/*` slots, in map order):
- Check if a FIDO2 device is connected - For each: check if a connected FIDO2 device matches the
- If yes → prompt for touch, derive KEK, unwrap DEK slot's `credential_id`
- If device not found or touch times out → fall through - If match found → prompt for touch, derive KEK, unwrap DEK
- If no device matches or touch times out → try next slot
2. **Passphrase** (if a `type: passphrase` source exists): 2. **Passphrase slot** (if `passphrase` slot exists):
- Prompt for passphrase on stdin - Prompt for passphrase on stdin
- Derive KEK via Argon2id, unwrap DEK - Derive KEK via Argon2id, unwrap DEK
3. **No sources succeed** → error 3. **No slots succeed** → error
FIDO2 is preferred because it requires no typing — just a touch. The FIDO2 is tried first because it requires no typing — just a touch.
passphrase is the fallback for when the FIDO2 key isn't physically The `credential_id` check avoids prompting for touch on a device that
present (e.g., on a different machine). The user never specifies which can't unwrap the slot, which matters when multiple FIDO2 keys are
method to use; sgard figures it out. connected. The passphrase slot is the universal fallback.
The `kek_sources` list in the manifest is ordered by preference The user never specifies which slot to use. The presence of the
(FIDO2 first). The presence of the `encryption` section indicates the `encryption` section indicates the repo has encryption capability.
repo has encryption capability. Individual files opt in via `--encrypt` Individual files opt in via `--encrypt` at add time.
at add time.
### CLI Integration ### CLI Integration
**Setting up encryption (creates DEK, adds `encryption` to manifest):** **Setting up encryption (creates DEK, adds `encryption` to manifest):**
```sh ```sh
sgard encrypt init # passphrase only sgard encrypt init # passphrase slot only
sgard encrypt init --fido2 # FIDO2 + passphrase fallback sgard encrypt init --fido2 # fido2/<hostname> + passphrase slots
``` ```
When `--fido2` is specified, sgard creates both sources: the FIDO2 When `--fido2` is specified, sgard creates both slots: the FIDO2 slot
wrap (primary) and immediately prompts for a passphrase to create the (named `fido2/<hostname>` by default) and immediately prompts for a
fallback wrap. This ensures the user is never locked out if they lose passphrase to create the fallback slot. This ensures the user is never
the FIDO2 key. Both wrapped DEKs and salts are stored inline in the locked out if they lose the FIDO2 key.
manifest as base64.
Without `--fido2`, only the passphrase source is created. Without `--fido2`, only the `passphrase` slot is created.
**Adding encrypted files:** **Adding encrypted files:**
```sh ```sh
@@ -478,17 +485,19 @@ sgard add --encrypt ~/.ssh/config ~/.aws/credentials
sgard add ~/.bashrc # not encrypted sgard add ~/.bashrc # not encrypted
``` ```
**Adding a KEK source to an existing repo:** **Managing slots:**
```sh
sgard encrypt add-fido2 # add FIDO2 (auto-unlocks via passphrase first)
sgard encrypt add-passphrase # add passphrase (auto-unlocks via FIDO2 first)
```
**Changing a passphrase:**
```sh ```sh
sgard encrypt add-fido2 # adds fido2/<hostname>
sgard encrypt add-fido2 --label yubikey-5 # adds fido2/yubikey-5
sgard encrypt remove-slot fido2/old-laptop # removes a slot
sgard encrypt list-slots # shows all slot names and types
sgard encrypt change-passphrase # prompts for old and new sgard encrypt change-passphrase # prompts for old and new
``` ```
Adding a slot auto-unlocks the DEK via an existing slot first (e.g.,
`add-fido2` will prompt for the passphrase to unwrap the DEK, then
re-wrap it with the new FIDO2 key).
**Unlocking:** **Unlocking:**
Operations that touch encrypted entries (add --encrypt, checkpoint, Operations that touch encrypted entries (add --encrypt, checkpoint,
restore, diff, mirror on encrypted files) trigger automatic unlock restore, diff, mirror on encrypted files) trigger automatic unlock
@@ -537,21 +546,22 @@ encrypted blobs as opaque bytes. The server cannot decrypt file
contents. contents.
When pulling to a new machine: When pulling to a new machine:
1. The manifest arrives with the `encryption` section intact 1. The manifest arrives with all `kek_slots` intact
2. The wrapped DEKs and salts are present in the manifest 2. The user provides their passphrase (universal fallback)
3. The user provides their passphrase (or touches their FIDO2 key) 3. sgard derives the KEK, unwraps the DEK, decrypts blobs on restore
4. sgard derives the KEK, unwraps the DEK, decrypts blobs on restore
No additional setup is needed on the new machine beyond having the No additional setup is needed beyond having the passphrase.
passphrase or a FIDO2 key. The manifest carries everything.
**FIDO2 cross-machine note:** FIDO2 hmac-secret is device-bound. A **Adding FIDO2 on a new machine:** FIDO2 hmac-secret is device-bound
different physical key on machine B produces a different KEK, so the a different physical key produces a different KEK. After pulling to a
FIDO2 `wrapped_dek` from machine A won't unwrap. The passphrase new machine, the user runs `sgard encrypt add-fido2` which:
fallback is what enables cross-machine decryption. A user who wants 1. Unlocks the DEK via the passphrase slot
FIDO2 on multiple machines must run `sgard encrypt add-fido2` on each 2. Registers a new FIDO2 credential on the local device
machine (which re-wraps the DEK with that machine's FIDO2 key and adds 3. Wraps the DEK with the new FIDO2 KEK
a new source to the manifest). 4. Adds a `fido2/<hostname>` slot to the manifest
On next push, the new slot propagates to the server and other machines.
Each machine accumulates its own FIDO2 slot over time.
### Future: Manifest Signing ### Future: Manifest Signing