docs: add device authentication design spec

Real device auth replacing the security-theater implementation:
- Signing keys (ed25519) for commit signatures
- Deploy keys managed via Gitea API
- Server-side pre-receive hook enforcement
- CLI and extension feature parity
- Instant revocation (signing + push access)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 01:17:32 -04:00
parent 71d51c0bea
commit 3d3e9ac7f2

View File

@@ -0,0 +1,414 @@
# Device Authentication Design
> **Status:** Approved
> **Date:** 2026-05-02
> **Author:** Claude + alee
## Overview
Relicario device authentication provides cryptographic proof of commit authorship and API-managed access control. Each device (CLI instance, browser extension) has its own identity consisting of:
1. **Signing key** (ed25519) — signs git commits
2. **Deploy key** (ed25519) — grants git push access via Gitea API
Device management is fully self-contained within Relicario — no manual SSH key management or server admin panels required.
## Goals
- All commits cryptographically signed by a registered device
- Revocation instantly cuts off both signing authority AND push access
- CLI and extension have full feature parity
- Server-side enforcement via pre-receive hook
- No security theater — every feature actually works
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Vault Repository │
│ .relicario/devices.json ←── public signing keys (ed25519 OpenSSH) │
│ .relicario/revoked.json ←── revoked keys + timestamps │
└─────────────────────────────────────────────────────────────────────┘
▲ ▲
│ sign commits │ verify signatures
│ manage deploy keys (Gitea API) │
│ │
┌───────────┴───────────┐ ┌─────────────┴─────────────┐
│ CLI Device │ │ Gitea Server │
│ ~/.config/relicario │ │ pre-receive hook │
│ /devices/<name>/ │ │ (relicario-server) │
│ signing.key │ └───────────────────────────┘
│ deploy.key │
└───────────────────────┘
┌───────────────────────┐
│ Extension Device │
│ chrome.storage │
│ (encrypted keys) │
│ signing in WASM │
└───────────────────────┘
```
## Key Storage
### CLI Device
```
~/.config/relicario/devices/
├── macbook-cli/
│ ├── signing.key # OpenSSH private key (ed25519) for commit signing
│ ├── signing.pub # OpenSSH public key
│ ├── deploy.key # OpenSSH private key (ed25519) for git push
│ ├── deploy.pub # OpenSSH public key
│ └── gitea_key_id # Gitea's ID for the deploy key (for revocation)
└── current # File containing active device name
```
All private keys stored with mode 0600.
### Extension Device
- Private keys stored in `chrome.storage.local` under `device_keys`
- Encrypted at rest using `HKDF(master_key, "device-storage")`
- WASM holds decrypted keys in memory only while session is active
- Structure:
```json
{
"device_name": "chrome-macos",
"signing_private_key": "<encrypted>",
"signing_public_key": "ssh-ed25519 AAAA...",
"deploy_private_key": "<encrypted>",
"deploy_public_key": "ssh-ed25519 AAAA...",
"gitea_key_id": 42
}
```
### Vault Files
**`devices.json`:**
```json
[
{
"name": "macbook-cli",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
"added_at": 1714600000,
"added_by": "macbook-cli"
}
]
```
**`revoked.json`:**
```json
[
{
"name": "stolen-laptop",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
"revoked_at": 1714700000,
"revoked_by": "macbook-cli"
}
]
```
## Vault Configuration
Stored encrypted in vault settings:
```json
{
"git_provider": "gitea",
"git_api_url": "https://git.adlee.work/api/v1",
"git_api_token": "...",
"repo_owner": "alee",
"repo_name": "relicario-vault"
}
```
Required Gitea token scopes: `repo`, `admin:repo_key`
## CLI Flows
### Device Add
```bash
relicario device add --name "macbook-cli"
```
1. Generate ed25519 signing keypair (OpenSSH format)
2. Generate ed25519 deploy keypair (OpenSSH format)
3. Call Gitea API: `POST /repos/{owner}/{repo}/keys`
```json
{
"title": "relicario-macbook-cli",
"key": "ssh-ed25519 AAAA...",
"read_only": false
}
```
4. Store keys to `~/.config/relicario/devices/macbook-cli/`
5. Write device name to `~/.config/relicario/devices/current`
6. Append public signing key to `.relicario/devices.json`
7. Configure local git repo:
```
git config user.signingkey ~/.config/relicario/devices/macbook-cli/signing.key
git config gpg.format ssh
git config commit.gpgsign true
git config core.sshCommand "ssh -i ~/.config/relicario/devices/macbook-cli/deploy.key"
```
8. Commit: `device: add macbook-cli`
9. Push
### Device Revoke
```bash
relicario device revoke stolen-laptop
```
1. Read `devices.json`, find entry for `stolen-laptop`
2. Call Gitea API: `DELETE /repos/{owner}/{repo}/keys/{key_id}`
3. Remove from `devices.json`
4. Append to `revoked.json`:
```json
{
"name": "stolen-laptop",
"public_key": "ssh-ed25519 AAAA...",
"revoked_at": 1714700000,
"revoked_by": "macbook-cli"
}
```
5. Commit: `device: revoke stolen-laptop`
6. Push immediately
### Device List
```bash
relicario device list
DEVICE ADDED STATUS
macbook-cli 2024-05-01 active (current)
chrome-macos 2024-05-02 active
stolen-laptop 2024-04-15 revoked 2024-05-01
```
### Verify Commit
```bash
relicario verify [commit-ish]
```
Checks signature against `devices.json`, reports device name and status.
### Sync (Enhanced)
```bash
relicario sync
```
1. Verify HEAD is signed by current device
2. Pull with rebase
3. Warn on unsigned or unknown-signed incoming commits
4. Push
## Extension/WASM Flows
### WASM API
```rust
#[wasm_bindgen]
pub fn register_device(session: &SessionHandle, name: &str) -> Result<JsValue, JsError>
// Generates both keypairs, stores encrypted, returns public keys only
// Returns: { signing_public_key: "ssh-ed25519...", deploy_public_key: "ssh-ed25519..." }
#[wasm_bindgen]
pub fn sign_for_git(session: &SessionHandle, data: &[u8]) -> Result<JsValue, JsError>
// Loads encrypted signing key, decrypts, signs, returns signature
// Returns: { signature: "base64..." }
#[wasm_bindgen]
pub fn get_device_info(session: &SessionHandle) -> Result<JsValue, JsError>
// Returns: { name, signing_public_key, deploy_public_key } or null
#[wasm_bindgen]
pub fn clear_device(session: &SessionHandle) -> Result<(), JsError>
// Removes device keys from storage (for re-registration)
```
**Critical constraint:** Private key bytes never cross WASM boundary to JS. Generated in WASM, encrypted in WASM, decrypted in WASM, used in WASM.
### Extension Registration Flow
1. User clicks "Register this device" in settings
2. Prompt for device name (default: "Chrome on macOS")
3. Call WASM `register_device(name)` → returns public keys
4. Service worker calls Gitea API to register deploy key
5. Service worker updates `devices.json`, commits, pushes
6. Device is now registered
### Extension Commit Signing
When extension modifies vault:
1. Service worker prepares commit
2. Calls WASM `sign_for_git(commit_data)` → returns signature
3. Creates signed commit using git SSH signature format
4. Pushes using deploy key
## Server-Side Verification
### Hook Distribution
**Option B — CLI generates:**
```bash
relicario server-hook generate > pre-receive
chmod +x pre-receive
# Copy to Gitea hooks directory
```
**Option C — Standalone binary:**
```bash
cargo install relicario-server
# Or download prebuilt binary
```
### Pre-Receive Hook
```bash
#!/bin/bash
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-commit "$commit" || exit 1
done
done
```
### Verification Logic
`relicario-server verify-commit <commit>`:
1. Extract `devices.json` and `revoked.json` from repo at commit
2. Get commit signature via `git verify-commit --raw`
3. Parse signature, extract signing public key
4. Check key against `devices.json`:
- Not found → reject "signed by unregistered device"
5. Check key against `revoked.json`:
- Found AND commit timestamp ≥ revoked_at → reject "signed by revoked device"
- Found AND commit timestamp < revoked_at → accept (historical)
6. Accept
### Gitea Installation
```bash
# Per-repo hook
cp pre-receive /path/to/gitea-data/git/repositories/alee/vault.git/hooks/pre-receive
# Or via Gitea admin UI
# Settings → Git Hooks → pre-receive → paste script
```
## Error Handling
### Device Registration
| Error | CLI | Extension |
|-------|-----|-----------|
| Gitea API unreachable | Fail: "cannot reach git server" | Toast + retry |
| API token invalid | Fail: "API token rejected" | Prompt re-enter in settings |
| Deploy key name collision | Append `-2`, `-3` or fail | Same |
### Signing
| Error | Behavior |
|-------|----------|
| No device registered | Block: "run `relicario device add`" |
| Private key not found | Prompt re-registration |
| Key decryption fails | Session expired, prompt unlock |
### Server Verification
| Error | Hook Response |
|-------|---------------|
| Unsigned commit | Reject: "all commits must be signed" |
| Unknown signing key | Reject: "signed by unregistered device" |
| Revoked key (post-revocation) | Reject: "signed by revoked device 'X'" |
### Revocation Edge Cases
| Scenario | Behavior |
|----------|----------|
| Revoke current device | Require `--confirm`, warn about access loss |
| Revoke last device | Error: "cannot revoke last device" |
| Gitea API fails during revoke | Revoke signing key, warn about manual deploy key cleanup |
## Testing Strategy
### Unit Tests (relicario-core)
- Key generation and OpenSSH format serialization
- Sign/verify round-trip
- `devices.json` / `revoked.json` serialization
### Integration Tests (relicario-cli)
- `device add` creates keys and configures git
- `device revoke` updates both JSON files
- Commits are signed after device add
- `verify` accepts/rejects appropriately
### Integration Tests (Gitea API)
- Mock Gitea API for deploy key management
- Graceful failure on API errors
### WASM Tests
- `register_device` returns only public keys
- `sign_for_git` never exposes private key
- Round-trip signing works
### E2E Tests (Server Hook)
- Unsigned commits rejected
- Valid signatures accepted
- Revoked device signatures rejected (post-revocation)
- Historical commits by later-revoked devices accepted
## Bootstrapping
**Problem:** The first device can't sign its own registration commit — there's no device yet.
**Solution:** Bootstrap exception in the pre-receive hook:
1. `relicario init` creates vault with empty `devices.json` (unsigned commit allowed)
2. First `device add` registers itself (this commit is also unsigned — no prior device)
3. Hook logic: if `devices.json` is empty in the parent commit, allow unsigned
4. All subsequent commits must be signed
**Extension bootstrap:** If connecting to an existing vault that has no devices:
1. Extension detects empty `devices.json`
2. Prompts to register as first device
3. Same unsigned-commit exception applies
**Security implication:** Anyone with push access can add the first device. This is acceptable because:
- Push access already requires git credentials
- The hook isn't installed yet anyway on a fresh repo
- Once first device is registered, all subsequent changes require signing
## Security Properties
1. **Commit authorship is cryptographically proven** — ed25519 signatures
2. **Revocation is instant and complete** — deploy key deletion via API
3. **Private keys never leave their device** — WASM constraint enforced
4. **History is append-only** — revocation doesn't invalidate past commits
5. **Server enforces, client assists** — hook is authoritative, client checks are UX
6. **Bootstrap is explicit** — first device registration requires push access, then locked down
## Future Considerations
- Hosted Relicario service with per-user isolated git backends
- Support for other git providers (GitHub, GitLab) via their deploy key APIs
- Hardware key support (YubiKey) for signing key storage