# 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
