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>
415 lines
13 KiB
Markdown
415 lines
13 KiB
Markdown
# 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
|