Files
relicario/docs/superpowers/specs/2026-05-02-device-authentication-design.md
adlee-was-taken 3d3e9ac7f2 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>
2026-05-02 01:17:32 -04:00

13 KiB

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:
    {
      "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:

[
  {
    "name": "macbook-cli",
    "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
    "added_at": 1714600000,
    "added_by": "macbook-cli"
  }
]

revoked.json:

[
  {
    "name": "stolen-laptop",
    "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
    "revoked_at": 1714700000,
    "revoked_by": "macbook-cli"
  }
]

Vault Configuration

Stored encrypted in vault settings:

{
  "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

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
    {
      "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

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:
    {
      "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

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

relicario verify [commit-ish]

Checks signature against devices.json, reports device name and status.

Sync (Enhanced)

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

#[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:

relicario server-hook generate > pre-receive
chmod +x pre-receive
# Copy to Gitea hooks directory

Option C — Standalone binary:

cargo install relicario-server
# Or download prebuilt binary

Pre-Receive Hook

#!/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

# 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