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:
@@ -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
|
||||
Reference in New Issue
Block a user