# 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