Update encryption design: selective per-file encryption, punt signing.
Encryption is per-file (--encrypt flag on add), not per-repo. A repo can have a mix of encrypted and plaintext blobs. Commands that only touch plaintext entries never prompt for the DEK. Manifest signing deferred — the trust model (which key signs, how do pulling clients verify across multiple machines) needs proper design. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -286,10 +286,10 @@ SSH key resolution order (for initial authentication):
|
|||||||
|
|
||||||
## Encryption
|
## Encryption
|
||||||
|
|
||||||
sgard supports optional at-rest encryption for blob contents. When
|
sgard supports optional at-rest encryption for individual files.
|
||||||
enabled, files are encrypted before being stored in the blob store and
|
Encryption is per-file, not per-repo — any file can be marked as
|
||||||
decrypted on restore. Encryption is per-repo — a repo is either
|
encrypted at add time. A repo may contain a mix of encrypted and
|
||||||
encrypted or it isn't.
|
plaintext blobs.
|
||||||
|
|
||||||
### Key Hierarchy
|
### Key Hierarchy
|
||||||
|
|
||||||
@@ -376,23 +376,33 @@ Rationale:
|
|||||||
|
|
||||||
**Manifest changes for encryption:**
|
**Manifest changes for encryption:**
|
||||||
|
|
||||||
To support `status` without decrypting every blob, the manifest entry
|
Encrypted entries gain two fields: `encrypted: true` and
|
||||||
gains an optional `plaintext_hash` field:
|
`plaintext_hash` (SHA-256 of the plaintext, for efficient `status`
|
||||||
|
checks without decryption):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
files:
|
files:
|
||||||
- path: ~/.bashrc
|
- path: ~/.bashrc
|
||||||
hash: a1b2c3d4... # SHA-256 of encrypted blob (post-encryption)
|
hash: a1b2c3d4... # SHA-256 of plaintext — not encrypted
|
||||||
plaintext_hash: e5f6a7... # SHA-256 of plaintext (pre-encryption)
|
|
||||||
type: file
|
type: file
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
updated: "2026-03-24T..."
|
updated: "2026-03-24T..."
|
||||||
|
|
||||||
|
- path: ~/.ssh/config
|
||||||
|
hash: f8e9d0c1... # SHA-256 of encrypted blob (post-encryption)
|
||||||
|
plaintext_hash: e5f6a7... # SHA-256 of plaintext (pre-encryption)
|
||||||
|
encrypted: true
|
||||||
|
type: file
|
||||||
|
mode: "0600"
|
||||||
|
updated: "2026-03-24T..."
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For unencrypted entries, `hash` is the SHA-256 of the plaintext (current
|
||||||
|
behavior), and `plaintext_hash` and `encrypted` are omitted.
|
||||||
|
|
||||||
`status` hashes the current file on disk and compares against
|
`status` hashes the current file on disk and compares against
|
||||||
`plaintext_hash`. This avoids decrypting stored blobs just to check
|
`plaintext_hash` (for encrypted entries) or `hash` (for plaintext).
|
||||||
if a file has changed. `verify` uses `hash` (the encrypted blob hash)
|
`verify` always uses `hash` to check store integrity without the DEK.
|
||||||
to check store integrity without the DEK.
|
|
||||||
|
|
||||||
### DEK Storage
|
### DEK Storage
|
||||||
|
|
||||||
@@ -421,7 +431,6 @@ Either source can unwrap the DEK. Adding a new source requires the DEK
|
|||||||
Encryption config stored at `<repo>/encryption.yaml`:
|
Encryption config stored at `<repo>/encryption.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
enabled: true
|
|
||||||
algorithm: xchacha20-poly1305
|
algorithm: xchacha20-poly1305
|
||||||
kek_sources:
|
kek_sources:
|
||||||
- type: passphrase
|
- type: passphrase
|
||||||
@@ -435,15 +444,25 @@ kek_sources:
|
|||||||
dek_file: dek.enc.fido2
|
dek_file: dek.enc.fido2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The presence of `encryption.yaml` indicates the repo has a DEK
|
||||||
|
(encryption capability). Individual files opt in via `--encrypt` at
|
||||||
|
add time.
|
||||||
|
|
||||||
### CLI Integration
|
### CLI Integration
|
||||||
|
|
||||||
**Enabling encryption:**
|
**Setting up encryption (creates DEK and first KEK source):**
|
||||||
```sh
|
```sh
|
||||||
sgard init --encrypt # prompts for passphrase
|
sgard encrypt init # prompts for passphrase
|
||||||
sgard init --encrypt --fido2 # uses FIDO2 key
|
sgard encrypt init --fido2 # uses FIDO2 key
|
||||||
```
|
```
|
||||||
|
|
||||||
**Adding a KEK source to an existing encrypted repo:**
|
**Adding encrypted files:**
|
||||||
|
```sh
|
||||||
|
sgard add --encrypt ~/.ssh/config ~/.aws/credentials
|
||||||
|
sgard add ~/.bashrc # not encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding a KEK source to an existing repo:**
|
||||||
```sh
|
```sh
|
||||||
sgard encrypt add-passphrase # add passphrase (requires existing unlock)
|
sgard encrypt add-passphrase # add passphrase (requires existing unlock)
|
||||||
sgard encrypt add-fido2 # add FIDO2 key (requires existing unlock)
|
sgard encrypt add-fido2 # add FIDO2 key (requires existing unlock)
|
||||||
@@ -455,9 +474,13 @@ sgard encrypt change-passphrase # prompts for old and new
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Unlocking:**
|
**Unlocking:**
|
||||||
Operations that need the DEK (add, checkpoint, restore, diff, mirror)
|
Operations that touch encrypted entries (add --encrypt, checkpoint,
|
||||||
prompt for the passphrase or FIDO2 touch automatically. The unlocked
|
restore, diff, mirror on encrypted files) prompt for the passphrase
|
||||||
DEK can be cached in memory for the duration of the command.
|
or FIDO2 touch automatically. The DEK is cached in memory for the
|
||||||
|
duration of the command.
|
||||||
|
|
||||||
|
Operations that only touch plaintext entries never prompt — they work
|
||||||
|
exactly as before, even if the repo has encryption configured.
|
||||||
|
|
||||||
There is no long-lived unlock state — each command invocation that needs
|
There is no long-lived unlock state — each command invocation that needs
|
||||||
the DEK obtains it fresh. This is intentional: dotfile operations are
|
the DEK obtains it fresh. This is intentional: dotfile operations are
|
||||||
@@ -466,24 +489,35 @@ daemon or on-disk secret, both of which expand the attack surface.
|
|||||||
|
|
||||||
### Security Properties
|
### Security Properties
|
||||||
|
|
||||||
- **At-rest confidentiality:** Blobs are encrypted. The manifest
|
- **Selective confidentiality:** Only files marked `--encrypt` are
|
||||||
contains paths and hashes but not file contents.
|
encrypted. The manifest contains paths and hashes but not file
|
||||||
|
contents for encrypted entries.
|
||||||
- **Server ignorance:** The server never has the DEK. Push/pull
|
- **Server ignorance:** The server never has the DEK. Push/pull
|
||||||
transfers encrypted blobs. The server cannot read file contents.
|
transfers encrypted blobs opaquely. The server cannot read encrypted
|
||||||
|
file contents.
|
||||||
- **Key rotation:** Changing the passphrase re-wraps the DEK without
|
- **Key rotation:** Changing the passphrase re-wraps the DEK without
|
||||||
re-encrypting blobs.
|
re-encrypting blobs.
|
||||||
- **Compromise recovery:** If the DEK is compromised, all blobs must
|
- **Compromise recovery:** If the DEK is compromised, all encrypted
|
||||||
be re-encrypted (not just re-wrapped). This is an explicit `sgard
|
blobs must be re-encrypted (not just re-wrapped). This is an
|
||||||
encrypt rotate-dek` operation.
|
explicit `sgard encrypt rotate-dek` operation.
|
||||||
- **No plaintext leaks:** `diff` decrypts in memory, never writes
|
- **No plaintext leaks:** `diff` decrypts in memory, never writes
|
||||||
plaintext blobs to disk.
|
decrypted blobs to disk.
|
||||||
|
- **Graceful degradation:** Commands that don't touch encrypted entries
|
||||||
|
work without the DEK. A `status` on a mixed repo can check plaintext
|
||||||
|
entries without prompting.
|
||||||
|
|
||||||
### Non-Encrypted Repos
|
### Repos Without Encryption
|
||||||
|
|
||||||
Encryption is optional. Repos without encryption work exactly as before
|
A repo with no `encryption.yaml` has no DEK and cannot have encrypted
|
||||||
— no `encryption.yaml`, no DEK, blobs stored as plaintext. The `hash`
|
entries. The `--encrypt` flag on `add` will error, prompting the user
|
||||||
field in the manifest is the SHA-256 of the plaintext (same as current
|
to run `sgard encrypt init` first. All existing behavior is unchanged.
|
||||||
behavior). The `plaintext_hash` field is omitted.
|
|
||||||
|
### Future: Manifest Signing
|
||||||
|
|
||||||
|
Manifest signing (to detect tampering) is deferred. The challenge is
|
||||||
|
the trust model: which key signs, and how does a pulling client verify
|
||||||
|
the signature when multiple machines with different SSH keys push to
|
||||||
|
the same server? This requires a proper trust/key-authority design.
|
||||||
|
|
||||||
## Go Package Structure
|
## Go Package Structure
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user