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>
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:
- Signing key (ed25519) — signs git commits
- 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.localunderdevice_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"
- Generate ed25519 signing keypair (OpenSSH format)
- Generate ed25519 deploy keypair (OpenSSH format)
- Call Gitea API:
POST /repos/{owner}/{repo}/keys{ "title": "relicario-macbook-cli", "key": "ssh-ed25519 AAAA...", "read_only": false } - Store keys to
~/.config/relicario/devices/macbook-cli/ - Write device name to
~/.config/relicario/devices/current - Append public signing key to
.relicario/devices.json - 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" - Commit:
device: add macbook-cli - Push
Device Revoke
relicario device revoke stolen-laptop
- Read
devices.json, find entry forstolen-laptop - Call Gitea API:
DELETE /repos/{owner}/{repo}/keys/{key_id} - Remove from
devices.json - Append to
revoked.json:{ "name": "stolen-laptop", "public_key": "ssh-ed25519 AAAA...", "revoked_at": 1714700000, "revoked_by": "macbook-cli" } - Commit:
device: revoke stolen-laptop - 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
- Verify HEAD is signed by current device
- Pull with rebase
- Warn on unsigned or unknown-signed incoming commits
- 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
- User clicks "Register this device" in settings
- Prompt for device name (default: "Chrome on macOS")
- Call WASM
register_device(name)→ returns public keys - Service worker calls Gitea API to register deploy key
- Service worker updates
devices.json, commits, pushes - Device is now registered
Extension Commit Signing
When extension modifies vault:
- Service worker prepares commit
- Calls WASM
sign_for_git(commit_data)→ returns signature - Creates signed commit using git SSH signature format
- 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>:
- Extract
devices.jsonandrevoked.jsonfrom repo at commit - Get commit signature via
git verify-commit --raw - Parse signature, extract signing public key
- Check key against
devices.json:- Not found → reject "signed by unregistered device"
- Check key against
revoked.json:- Found AND commit timestamp ≥ revoked_at → reject "signed by revoked device"
- Found AND commit timestamp < revoked_at → accept (historical)
- 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.jsonserialization
Integration Tests (relicario-cli)
device addcreates keys and configures gitdevice revokeupdates both JSON files- Commits are signed after device add
verifyaccepts/rejects appropriately
Integration Tests (Gitea API)
- Mock Gitea API for deploy key management
- Graceful failure on API errors
WASM Tests
register_devicereturns only public keyssign_for_gitnever 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:
relicario initcreates vault with emptydevices.json(unsigned commit allowed)- First
device addregisters itself (this commit is also unsigned — no prior device) - Hook logic: if
devices.jsonis empty in the parent commit, allow unsigned - All subsequent commits must be signed
Extension bootstrap: If connecting to an existing vault that has no devices:
- Extension detects empty
devices.json - Prompts to register as first device
- 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
- Commit authorship is cryptographically proven — ed25519 signatures
- Revocation is instant and complete — deploy key deletion via API
- Private keys never leave their device — WASM constraint enforced
- History is append-only — revocation doesn't invalidate past commits
- Server enforces, client assists — hook is authoritative, client checks are UX
- 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