From 3d3e9ac7f2734ca9c1e3019bf9633df95a35f3a8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 01:17:32 -0400 Subject: [PATCH] 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 --- ...2026-05-02-device-authentication-design.md | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-device-authentication-design.md diff --git a/docs/superpowers/specs/2026-05-02-device-authentication-design.md b/docs/superpowers/specs/2026-05-02-device-authentication-design.md new file mode 100644 index 0000000..a53f2c1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-device-authentication-design.md @@ -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// │ │ (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": "", + "signing_public_key": "ssh-ed25519 AAAA...", + "deploy_private_key": "", + "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 +// 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 +// Loads encrypted signing key, decrypts, signs, returns signature +// Returns: { signature: "base64..." } + +#[wasm_bindgen] +pub fn get_device_info(session: &SessionHandle) -> Result +// 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 `: + +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