# Plan 4: Security Fixes + Device Authentication

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix all audit security findings (B2-B4, I1-I6) and implement real device authentication with commit signing and Gitea API integration.

**Architecture:** Phase A fixes 7 security issues with targeted changes. Phase B implements device auth: ed25519 signing keys for commit signatures, deploy keys managed via Gitea API, server-side pre-receive hook verification. CLI and extension have feature parity; WASM keeps private keys internal (never crosses to JS).

**Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), TypeScript (extension), ed25519-dalek, ssh-key crate, Gitea API

---

## File Structure

### Phase A: Security Fixes

| File | Change | Purpose |
|------|--------|---------|
| `crates/relicario-core/src/backup.rs` | Modify | B2: Add NFC normalization to `derive_backup_key` |
| `crates/relicario-core/src/ids.rs` | Modify | I2: Expand AttachmentId to 128 bits, add `is_valid()` for B4 |
| `crates/relicario-core/src/error.rs` | Modify | I6: Add `HotpNotSupported` variant |
| `crates/relicario-core/src/item_types/totp.rs` | Modify | I6: Reject HOTP in `compute_totp_code` |
| `crates/relicario-cli/src/main.rs` | Modify | B3: Gate test env vars; B4: Validate restore IDs; I1: Sanitize commits; I3: Vault attachment cap |
| `crates/relicario-cli/src/helpers.rs` | Modify | I1: Add `sanitize_for_commit()` |
| `docs/SECURITY.md` | Create | I4: Document manifest integrity model |

### Phase B: Device Authentication

| File | Change | Purpose |
|------|--------|---------|
| `crates/relicario-core/src/device.rs` | Create | Device types, OpenSSH key format, signing/verification |
| `crates/relicario-core/src/lib.rs` | Modify | Export device module |
| `crates/relicario-cli/src/device.rs` | Create | Device key storage, git config management |
| `crates/relicario-cli/src/gitea.rs` | Create | Gitea API client for deploy keys |
| `crates/relicario-cli/src/main.rs` | Modify | Enhanced device add/revoke/list, verify command, git signing setup |
| `crates/relicario-cli/src/helpers.rs` | Modify | Enhance `git_command` for signing |
| `crates/relicario-wasm/src/device.rs` | Create | WASM device key storage (encrypted), signing API |
| `crates/relicario-wasm/src/lib.rs` | Modify | New device WASM bindings |
| `crates/relicario-server/` | Create | New crate for pre-receive hook binary |
| `extension/src/service-worker/devices.ts` | Modify | Add revoked.json handling, deploy key management |
| `extension/src/service-worker/gitea.ts` | Modify | Add deploy key API methods |
| `extension/src/popup/components/devices.ts` | Modify | Update UI for new device model |
| `extension/src/wasm.d.ts` | Modify | New device WASM type declarations |

---

## Phase A: Security Fixes

### Task 1: Fix backup KDF NFC normalization (B2)

**Files:**
- Modify: `crates/relicario-core/src/backup.rs:303-312`
- Test: `crates/relicario-core/tests/backup.rs`

- [ ] **Step 1: Write failing test for NFC normalization**

Add to `crates/relicario-core/tests/backup.rs`:

```rust
#[test]
fn backup_roundtrip_with_nfd_passphrase() {
    // "café" in NFD (decomposed: e + combining acute accent)
    let nfd_passphrase = "caf\u{0065}\u{0301}";
    // "café" in NFC (precomposed é)
    let nfc_passphrase = "caf\u{00E9}";
    
    let input = BackupInput {
        salt: &[0u8; 32],
        params_json: b"{}",
        devices_json: b"[]",
        manifest_enc: &[1, 2, 3],
        settings_enc: &[4, 5, 6],
        items: vec![],
        attachments: vec![],
    };
    
    // Pack with NFD passphrase
    let packed = pack(input, nfd_passphrase, false, false).unwrap();
    
    // Unpack with NFC passphrase — should work after fix
    let unpacked = unpack(&packed, nfc_passphrase).unwrap();
    assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cargo test -p relicario-core --test backup backup_roundtrip_with_nfd_passphrase`
Expected: FAIL — different keys derived from NFD vs NFC

- [ ] **Step 3: Add NFC normalization to derive_backup_key**

In `crates/relicario-core/src/backup.rs`, modify `derive_backup_key`:

```rust
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
    use unicode_normalization::UnicodeNormalization;
    
    // NFC normalize passphrase (matches derive_master_key in crypto.rs)
    let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
        Ok(s) => s.nfc().collect::<String>().into_bytes(),
        Err(_) => passphrase.to_vec(),
    };
    
    let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
        .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
    let mut key = Zeroizing::new([0u8; 32]);
    argon
        .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
        .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
    Ok(key)
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cargo test -p relicario-core --test backup backup_roundtrip_with_nfd_passphrase`
Expected: PASS

- [ ] **Step 5: Run full backup test suite**

Run: `cargo test -p relicario-core --test backup`
Expected: All tests pass

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-core/src/backup.rs crates/relicario-core/tests/backup.rs
git commit -m "$(cat <<'EOF'
fix(core): NFC normalize backup passphrase (audit B2)

Backup KDF was passing raw passphrase bytes to Argon2id without NFC
normalization, causing cross-platform restore failures for non-ASCII
passphrases (macOS NFD vs Linux NFC).

Now matches derive_master_key behavior from crypto.rs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 2: Expand AttachmentId to 128 bits + add validation (I2, B4)

**Files:**
- Modify: `crates/relicario-core/src/ids.rs:51-57`

- [ ] **Step 1: Write failing test for 128-bit AttachmentId**

Add to `crates/relicario-core/src/ids.rs` in the `tests` module:

```rust
#[test]
fn attachment_id_is_32_hex_chars() {
    let id = AttachmentId::from_plaintext(b"test content");
    assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
    assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cargo test -p relicario-core ids::tests::attachment_id_is_32_hex_chars`
Expected: FAIL — currently 16 hex chars

- [ ] **Step 3: Change digest slice from 8 to 16 bytes**

In `crates/relicario-core/src/ids.rs`, modify `AttachmentId::from_plaintext`:

```rust
impl AttachmentId {
    pub fn from_plaintext(plaintext: &[u8]) -> Self {
        let digest = Sha256::digest(plaintext);
        Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
    }
    pub fn as_str(&self) -> &str { &self.0 }
    
    /// Returns true if this ID is valid for filesystem paths.
    /// Valid AttachmentIds are 32 lowercase hex chars.
    pub fn is_valid(&self) -> bool {
        self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
    }
}
```

- [ ] **Step 4: Add is_valid to ItemId**

```rust
impl ItemId {
    pub fn new() -> Self {
        let mut bytes = [0u8; 8];
        OsRng.fill_bytes(&mut bytes);
        Self(hex::encode(bytes))
    }
    pub fn as_str(&self) -> &str { &self.0 }
    
    /// Returns true if this ID is valid for filesystem paths.
    /// Valid ItemIds are 16 lowercase hex chars.
    pub fn is_valid(&self) -> bool {
        self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
    }
}
```

- [ ] **Step 5: Update existing test expectation**

Update `attachment_id_is_16_hex_chars` test to `attachment_id_is_32_hex_chars`:

```rust
#[test]
fn attachment_id_is_32_hex_chars() {
    let id = AttachmentId::from_plaintext(b"any bytes");
    assert_eq!(id.0.len(), 32);
    assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
```

- [ ] **Step 6: Add validation tests**

```rust
#[test]
fn item_id_is_valid_for_normal_ids() {
    let id = ItemId::new();
    assert!(id.is_valid());
}

#[test]
fn item_id_is_invalid_for_traversal() {
    let bad = ItemId("../../../etc".to_string());
    assert!(!bad.is_valid());
}

#[test]
fn attachment_id_is_valid_for_normal_ids() {
    let id = AttachmentId::from_plaintext(b"test");
    assert!(id.is_valid());
}

#[test]
fn attachment_id_is_invalid_for_traversal() {
    let bad = AttachmentId("../../passwd".to_string());
    assert!(!bad.is_valid());
}
```

- [ ] **Step 7: Run tests**

Run: `cargo test -p relicario-core ids`
Expected: All tests pass

- [ ] **Step 8: Commit**

```bash
git add crates/relicario-core/src/ids.rs
git commit -m "$(cat <<'EOF'
fix(core): expand AttachmentId to 128 bits, add is_valid (audit I2, B4)

- AttachmentId now uses 16 bytes of SHA-256 (128 bits) instead of 8,
  requiring ~2^64 work for birthday collision instead of ~2^32.
- Added is_valid() to ItemId and AttachmentId for path traversal
  prevention during backup restore.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 3: Disable HOTP with clear error (I6)

**Files:**
- Modify: `crates/relicario-core/src/error.rs`
- Modify: `crates/relicario-core/src/item_types/totp.rs`

- [ ] **Step 1: Add HotpNotSupported error variant**

In `crates/relicario-core/src/error.rs`, add to the `RelicarioError` enum:

```rust
    #[error("HOTP is not supported: counter persistence requires vault save after each use")]
    HotpNotSupported,
```

- [ ] **Step 2: Write failing test for HOTP rejection**

Add to `crates/relicario-core/src/item_types/totp.rs` tests:

```rust
#[test]
fn hotp_returns_not_supported_error() {
    let cfg = TotpConfig {
        secret: Zeroizing::new(b"12345678901234567890".to_vec()),
        kind: TotpKind::Hotp { counter: 0 },
        ..TotpConfig::default()
    };
    let result = compute_totp_code(&cfg, 0);
    assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
}
```

- [ ] **Step 3: Run test to verify it fails**

Run: `cargo test -p relicario-core totp::tests::hotp_returns_not_supported`
Expected: FAIL — currently returns a code

- [ ] **Step 4: Modify compute_totp_code to reject HOTP**

In `crates/relicario-core/src/item_types/totp.rs`, modify `compute_totp_code`:

```rust
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
    let counter = match config.kind {
        TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
        TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
        TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
    };
    // ... rest unchanged
```

- [ ] **Step 5: Update or remove old HOTP test**

The `hotp_carries_counter` test will fail now. Update it:

```rust
#[test]
fn hotp_kind_roundtrips_through_json() {
    let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
    let json = serde_json::to_string(&cfg).unwrap();
    let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
    match parsed.kind {
        TotpKind::Hotp { counter } => assert_eq!(counter, 42),
        other => panic!("expected Hotp, got {:?}", other),
    }
    // Note: compute_totp_code will reject this — HOTP not supported
}
```

- [ ] **Step 6: Run tests**

Run: `cargo test -p relicario-core totp`
Expected: All tests pass

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-core/src/error.rs crates/relicario-core/src/item_types/totp.rs
git commit -m "$(cat <<'EOF'
fix(core): disable HOTP with clear error (audit I6)

HOTP requires incrementing and persisting the counter after each use.
Without vault-save machinery in compute_totp_code, HOTP would desync
immediately. Now returns HotpNotSupported error.

TOTP and Steam codes continue to work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 4: Gate test env vars with #[cfg(test)] (B3)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs`

- [ ] **Step 1: Identify all test env var locations**

Locations in `main.rs`:
- ~line 421: `RELICARIO_TEST_ITEM_SECRET`
- ~line 445: `RELICARIO_TEST_PASSPHRASE`
- ~line 1425: `RELICARIO_TEST_BACKUP_PASSPHRASE`
- ~line 1594: `RELICARIO_TEST_BACKUP_PASSPHRASE`

- [ ] **Step 2: Create helper functions with cfg(test) gates**

Near the top of `main.rs`, add:

```rust
/// Check for test passphrase override (test builds only).
#[cfg(test)]
fn test_passphrase_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
}
#[cfg(not(test))]
fn test_passphrase_override() -> Option<String> {
    None
}

/// Check for test item secret override (test builds only).
#[cfg(test)]
fn test_item_secret_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
}
#[cfg(not(test))]
fn test_item_secret_override() -> Option<String> {
    None
}

/// Check for test backup passphrase override (test builds only).
#[cfg(test)]
fn test_backup_passphrase_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
}
#[cfg(not(test))]
fn test_backup_passphrase_override() -> Option<String> {
    None
}
```

- [ ] **Step 3: Update prompt_item_secret to use helper**

```rust
fn prompt_item_secret(prompt: &str) -> Result<Zeroizing<String>> {
    if let Some(s) = test_item_secret_override() {
        return Ok(Zeroizing::new(s));
    }
    let pass = rpassword::prompt_password(prompt)
        .context("failed to read secret")?;
    Ok(Zeroizing::new(pass))
}
```

- [ ] **Step 4: Update prompt_passphrase to use helper**

```rust
fn prompt_passphrase(prompt: &str, confirm: bool) -> Result<Zeroizing<String>> {
    let passphrase = if let Some(p) = test_passphrase_override() {
        Zeroizing::new(p)
    } else {
        Zeroizing::new(rpassword::prompt_password(prompt).context("failed to read passphrase")?)
    };
    
    let skip_confirm = test_passphrase_override().is_some();
    if confirm && !skip_confirm {
        // existing confirm logic...
    }
    Ok(passphrase)
}
```

- [ ] **Step 5: Update backup passphrase prompts similarly**

Apply same pattern to backup export/restore passphrase prompts.

- [ ] **Step 6: Build release and verify strings not present**

Run: `cargo build -p relicario-cli --release && strings target/release/relicario | grep -c RELICARIO_TEST || echo "0 matches (good)"`
Expected: 0 matches

- [ ] **Step 7: Run CLI tests**

Run: `cargo test -p relicario-cli`
Expected: All tests pass (env vars still work in test builds)

- [ ] **Step 8: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): gate test env vars with #[cfg(test)] (audit B3)

RELICARIO_TEST_PASSPHRASE and friends were checked in production code,
exposing the passphrase via /proc/<pid>/environ and shell history.

Now only compiled into test binaries via cfg(test) helper functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 5: Validate IDs on backup restore (B4)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (restore logic ~line 1619)

- [ ] **Step 1: Find restore loop in cmd_backup_restore**

Look for the loop that writes items and attachments during restore.

- [ ] **Step 2: Add ID validation before writes**

```rust
// In cmd_backup_restore, before writing items:
for item in &unpacked.items {
    let item_id = ItemId(item.id.clone());
    if !item_id.is_valid() {
        anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
    }
    fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
}

for a in &unpacked.attachments {
    let item_id = ItemId(a.item_id.clone());
    let att_id = AttachmentId(a.attachment_id.clone());
    if !item_id.is_valid() || !att_id.is_valid() {
        anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
    }
    let dir = target.join("attachments").join(&a.item_id);
    fs::create_dir_all(&dir)?;
    fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
}
```

- [ ] **Step 3: Add import for ItemId and AttachmentId**

Add at top of file or in the function:
```rust
use relicario_core::{ItemId, AttachmentId};
```

- [ ] **Step 4: Write integration test**

Add to `crates/relicario-cli/tests/backup.rs`:

```rust
#[test]
fn restore_rejects_traversal_item_id() {
    // This would require crafting a malicious backup, which is complex.
    // For now, we test the is_valid function directly in core.
    // The integration is covered by code review.
}
```

- [ ] **Step 5: Run CLI tests**

Run: `cargo test -p relicario-cli`
Expected: All tests pass

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): validate IDs on backup restore (audit B4)

Crafted .relbak files with IDs like "../../.bashrc" could escape the
target directory. Now validates that item/attachment IDs are hex-only
via is_valid() before any fs::write.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 6: Sanitize item titles in commit messages (I1)

**Files:**
- Modify: `crates/relicario-cli/src/helpers.rs`
- Modify: `crates/relicario-cli/src/main.rs` (lines 565, 1110, 1327)

- [ ] **Step 1: Add sanitize_for_commit function**

In `crates/relicario-cli/src/helpers.rs`:

```rust
/// Sanitize a string for use in git commit messages.
/// Strips control characters and truncates to 50 chars.
pub fn sanitize_for_commit(s: &str) -> String {
    s.chars()
        .filter(|c| !c.is_control())
        .take(50)
        .collect()
}

#[cfg(test)]
mod tests {
    // ... existing tests ...
    
    #[test]
    fn sanitize_strips_newlines() {
        assert_eq!(super::sanitize_for_commit("line1\nline2"), "line1line2");
    }
    
    #[test]
    fn sanitize_strips_tabs() {
        assert_eq!(super::sanitize_for_commit("a\tb"), "ab");
    }
    
    #[test]
    fn sanitize_truncates_long_strings() {
        let long = "a".repeat(100);
        assert_eq!(super::sanitize_for_commit(&long).len(), 50);
    }
    
    #[test]
    fn sanitize_preserves_normal_strings() {
        assert_eq!(super::sanitize_for_commit("Normal Title"), "Normal Title");
    }
}
```

- [ ] **Step 2: Run helper tests**

Run: `cargo test -p relicario-cli helpers::tests::sanitize`
Expected: All pass

- [ ] **Step 3: Update add commit message**

In `main.rs` around line 565:
```rust
commit_paths(&vault, &format!("add: {} ({})",
    crate::helpers::sanitize_for_commit(&item.title),
    item.id.as_str()), &path_refs)?;
```

- [ ] **Step 4: Update edit commit message**

Around line 1110:
```rust
commit_paths(&vault, &format!("edit: {} ({})",
    crate::helpers::sanitize_for_commit(&item.title),
    item.id.as_str()),
```

- [ ] **Step 5: Update trash commit message**

Around line 1327:
```rust
commit_paths(&vault, &format!("trash: {} ({})",
    crate::helpers::sanitize_for_commit(&item.title),
    item.id.as_str()),
```

- [ ] **Step 6: Run CLI tests**

Run: `cargo test -p relicario-cli`
Expected: All pass

- [ ] **Step 7: Commit**

```bash
git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): sanitize item titles in commit messages (audit I1)

Control characters (newlines, tabs) in item titles corrupted git log
output. Now strips control chars and truncates to 50 chars.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 7: Enforce per-vault attachment cap (I3)

**Files:**
- Modify: `crates/relicario-cli/src/main.rs` (cmd_attach function)

- [ ] **Step 1: Find cmd_attach function**

Locate the `cmd_attach` function that handles `relicario attach`.

- [ ] **Step 2: Add vault-level cap check after loading manifest**

```rust
fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let settings = vault.load_settings()?;
    
    // Read file content
    let bytes = std::fs::read(&file)
        .with_context(|| format!("failed to read {}", file.display()))?;
    
    // Check per-vault total
    let current_total: u64 = manifest.items.values()
        .flat_map(|entry| &entry.attachment_summaries)
        .map(|s| s.size)
        .sum();
    
    let new_size = bytes.len() as u64;
    let hard_cap = settings.attachment_caps.per_vault_hard_cap_bytes;
    let soft_cap = settings.attachment_caps.per_vault_soft_cap_bytes;
    
    if current_total + new_size > hard_cap {
        anyhow::bail!(
            "attachment would exceed vault hard cap ({} + {} > {} bytes)",
            current_total, new_size, hard_cap
        );
    }
    
    if current_total + new_size > soft_cap {
        eprintln!(
            "warning: vault attachments will exceed soft cap ({} bytes)",
            soft_cap
        );
    }
    
    // ... rest of existing attach logic
```

- [ ] **Step 3: Run attachment tests**

Run: `cargo test -p relicario-cli --test attachments`
Expected: All pass

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): enforce per-vault attachment bytes cap (audit I3)

per_vault_soft_cap_bytes and per_vault_hard_cap_bytes were defined in
VaultSettings but never checked. Now enforced in cmd_attach with
warning at soft cap, error at hard cap.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: Document manifest integrity model (I4)

**Files:**
- Create: `docs/SECURITY.md`

- [ ] **Step 1: Create SECURITY.md**

```markdown
# Relicario Security Model

## Cryptographic Protection

Relicario uses two-factor vault decryption:
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography

Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)

## Manifest Integrity

The manifest (`manifest.enc`) is encrypted with AEAD, which provides:

- **Confidentiality**: Contents unreadable without master key
- **Integrity**: Any modification detected and rejected on decrypt
- **Authenticity**: Only master key holders can create valid ciphertexts

### What AEAD Does NOT Protect

- **Item deletion**: An attacker with write access can delete `.enc` files
  or git-revert commits. The manifest decrypts successfully but won't
  contain the deleted items.

- **Rollback attacks**: An attacker can replace `manifest.enc` with an
  older valid version. AEAD accepts any ciphertext created with the key.

### Mitigation

Item deletion and rollback are detectable via **git history**:

```bash
git log --oneline items/
```

For environments where git history could be rewritten (force-push):

1. Enable device authentication (commit signing + pre-receive hook)
2. Use a git server that rejects non-fast-forward pushes
3. Regular backups with `relicario backup export`

## Device Authentication

When enabled, device authentication provides:

- **Commit authorship**: All commits signed by registered device keys
- **Push access control**: Deploy keys managed via Gitea API
- **Instant revocation**: One command cuts off both signing and push

See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.

## Access Control

Without device authentication, access control is transport-layer only:

- **CLI**: SSH key authentication to git remote
- **Extension**: Git credentials in browser storage

Device registration was optional before v0.4.0. With device auth enabled,
all commits must be signed by a registered device.
```

- [ ] **Step 2: Commit**

```bash
git add docs/SECURITY.md
git commit -m "$(cat <<'EOF'
docs: document manifest integrity model (audit I4)

Clarifies what AEAD protects (tampering) vs. what it doesn't (deletion,
rollback). Documents that git history is the audit trail and device
authentication is the mitigation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Phase B: Device Authentication

### Task 9: Add device module to relicario-core

**Files:**
- Create: `crates/relicario-core/src/device.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- Modify: `crates/relicario-core/Cargo.toml`

- [ ] **Step 1: Add ssh-key dependency**

In `crates/relicario-core/Cargo.toml`:
```toml
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```

- [ ] **Step 2: Create device.rs with types and signing**

Create `crates/relicario-core/src/device.rs`:

```rust
//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.

use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use ssh_key::{LineEnding, PrivateKey, PublicKey};
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};

/// A registered device entry in devices.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceEntry {
    pub name: String,
    /// OpenSSH public key format: "ssh-ed25519 AAAA..."
    pub public_key: String,
    pub added_at: i64,
    pub added_by: String,
}

/// A revoked device entry in revoked.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokedEntry {
    pub name: String,
    pub public_key: String,
    pub revoked_at: i64,
    pub revoked_by: String,
}

/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
    let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
    let verifying_key = signing_key.verifying_key();
    
    // Convert to ssh-key types
    let ssh_private = PrivateKey::from(signing_key);
    let ssh_public = PublicKey::from(verifying_key);
    
    let private_pem = ssh_private
        .to_openssh(LineEnding::LF)
        .map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
    let public_line = ssh_public
        .to_openssh()
        .map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
    
    Ok((Zeroizing::new(private_pem.to_string()), public_line))
}

/// Sign data with an OpenSSH private key, returning base64 signature.
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
    let private = PrivateKey::from_openssh(private_key_openssh)
        .map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
    
    let signing_key: SigningKey = private
        .key_data()
        .ed25519()
        .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?
        .try_into()
        .map_err(|e| RelicarioError::DeviceKey(format!("extract signing key: {e}")))?;
    
    let signature = signing_key.sign(data);
    Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
}

/// Verify a signature against an OpenSSH public key.
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
    let public = PublicKey::from_openssh(public_key_openssh)
        .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
    
    let verifying_key: VerifyingKey = public
        .key_data()
        .ed25519()
        .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?
        .try_into()
        .map_err(|e| RelicarioError::DeviceKey(format!("extract verifying key: {e}")))?;
    
    let sig_bytes = base64::engine::general_purpose::STANDARD
        .decode(signature_b64)
        .map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
    
    let signature = Signature::from_slice(&sig_bytes)
        .map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
    
    Ok(verifying_key.verify(data, &signature).is_ok())
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn generate_and_sign_verify_roundtrip() {
        let (private, public) = generate_keypair().unwrap();
        let data = b"hello world";
        let sig = sign(&private, data).unwrap();
        assert!(verify(&public, data, &sig).unwrap());
    }
    
    #[test]
    fn verify_rejects_wrong_data() {
        let (private, public) = generate_keypair().unwrap();
        let sig = sign(&private, b"hello").unwrap();
        assert!(!verify(&public, b"world", &sig).unwrap());
    }
    
    #[test]
    fn verify_rejects_wrong_key() {
        let (private, _) = generate_keypair().unwrap();
        let (_, other_public) = generate_keypair().unwrap();
        let sig = sign(&private, b"hello").unwrap();
        assert!(!verify(&other_public, b"hello", &sig).unwrap());
    }
}
```

- [ ] **Step 3: Export device module**

In `crates/relicario-core/src/lib.rs`:
```rust
pub mod device;
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
```

- [ ] **Step 4: Run tests**

Run: `cargo test -p relicario-core device`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-core/
git commit -m "$(cat <<'EOF'
feat(core): add device module with ed25519 signing

OpenSSH-format keypair generation, signing, and verification.
Foundation for device authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 10: Add Gitea API client for deploy keys (CLI)

**Files:**
- Create: `crates/relicario-cli/src/gitea.rs`

- [ ] **Step 1: Create gitea.rs with deploy key management**

```rust
//! Gitea API client for deploy key management.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone)]
pub struct GiteaClient {
    api_url: String,
    token: String,
    owner: String,
    repo: String,
}

#[derive(Debug, Serialize)]
struct CreateKeyRequest<'a> {
    title: &'a str,
    key: &'a str,
    read_only: bool,
}

#[derive(Debug, Deserialize)]
pub struct DeployKey {
    pub id: u64,
    pub title: String,
    pub key: String,
}

impl GiteaClient {
    pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self {
        Self {
            api_url: api_url.trim_end_matches('/').to_string(),
            token: token.to_string(),
            owner: owner.to_string(),
            repo: repo.to_string(),
        }
    }
    
    /// Create a deploy key, returning its ID.
    pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result<u64> {
        let url = format!(
            "{}/repos/{}/{}/keys",
            self.api_url, self.owner, self.repo
        );
        
        let client = reqwest::blocking::Client::new();
        let resp = client
            .post(&url)
            .header("Authorization", format!("token {}", self.token))
            .header("Content-Type", "application/json")
            .json(&CreateKeyRequest {
                title,
                key: public_key,
                read_only: false,
            })
            .send()
            .context("Gitea API request failed")?;
        
        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().unwrap_or_default();
            anyhow::bail!("Gitea API error {}: {}", status, body);
        }
        
        let key: DeployKey = resp.json().context("parse deploy key response")?;
        Ok(key.id)
    }
    
    /// Delete a deploy key by ID.
    pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> {
        let url = format!(
            "{}/repos/{}/{}/keys/{}",
            self.api_url, self.owner, self.repo, key_id
        );
        
        let client = reqwest::blocking::Client::new();
        let resp = client
            .delete(&url)
            .header("Authorization", format!("token {}", self.token))
            .send()
            .context("Gitea API request failed")?;
        
        if !resp.status().is_success() && resp.status().as_u16() != 404 {
            let status = resp.status();
            let body = resp.text().unwrap_or_default();
            anyhow::bail!("Gitea API error {}: {}", status, body);
        }
        
        Ok(())
    }
    
    /// List all deploy keys.
    pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
        let url = format!(
            "{}/repos/{}/{}/keys",
            self.api_url, self.owner, self.repo
        );
        
        let client = reqwest::blocking::Client::new();
        let resp = client
            .get(&url)
            .header("Authorization", format!("token {}", self.token))
            .send()
            .context("Gitea API request failed")?;
        
        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().unwrap_or_default();
            anyhow::bail!("Gitea API error {}: {}", status, body);
        }
        
        let keys: Vec<DeployKey> = resp.json().context("parse deploy keys response")?;
        Ok(keys)
    }
}
```

- [ ] **Step 2: Add reqwest dependency**

In `crates/relicario-cli/Cargo.toml`:
```toml
reqwest = { version = "0.12", features = ["blocking", "json"] }
```

- [ ] **Step 3: Add module to main.rs**

```rust
mod gitea;
```

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/
git commit -m "$(cat <<'EOF'
feat(cli): add Gitea API client for deploy keys

Create, delete, and list deploy keys via Gitea REST API.
Foundation for device authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 11: Implement CLI device add with signing + deploy key

**Files:**
- Create: `crates/relicario-cli/src/device.rs`
- Modify: `crates/relicario-cli/src/main.rs`

- [ ] **Step 1: Create device.rs for local key storage**

```rust
//! Local device key storage and git signing configuration.

use std::fs::{self, Permissions};
use std::path::PathBuf;

use anyhow::{Context, Result};
use zeroize::Zeroizing;

/// Get the device config directory: ~/.config/relicario/devices/
pub fn devices_dir() -> Result<PathBuf> {
    let config = dirs::config_dir()
        .ok_or_else(|| anyhow::anyhow!("no config directory"))?;
    Ok(config.join("relicario").join("devices"))
}

/// Get the directory for a specific device's keys.
pub fn device_dir(name: &str) -> Result<PathBuf> {
    Ok(devices_dir()?.join(name))
}

/// Get the current device name (from ~/.config/relicario/devices/current).
pub fn current_device() -> Result<Option<String>> {
    let path = devices_dir()?.join("current");
    if !path.exists() {
        return Ok(None);
    }
    let name = fs::read_to_string(&path)
        .context("read current device")?
        .trim()
        .to_string();
    Ok(Some(name))
}

/// Set the current device name.
pub fn set_current_device(name: &str) -> Result<()> {
    let dir = devices_dir()?;
    fs::create_dir_all(&dir)?;
    fs::write(dir.join("current"), name)?;
    Ok(())
}

/// Store device keys and Gitea key ID.
pub fn store_device_keys(
    name: &str,
    signing_private: &str,
    signing_public: &str,
    deploy_private: &str,
    deploy_public: &str,
    gitea_key_id: u64,
) -> Result<()> {
    let dir = device_dir(name)?;
    fs::create_dir_all(&dir)?;
    
    // Write keys
    fs::write(dir.join("signing.key"), signing_private)?;
    fs::write(dir.join("signing.pub"), signing_public)?;
    fs::write(dir.join("deploy.key"), deploy_private)?;
    fs::write(dir.join("deploy.pub"), deploy_public)?;
    fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())?;
    
    // Set restrictive permissions on private keys
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))?;
        fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))?;
    }
    
    Ok(())
}

/// Load the signing private key for a device.
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
    let path = device_dir(name)?.join("signing.key");
    let key = fs::read_to_string(&path)
        .with_context(|| format!("read signing key for device '{}'", name))?;
    Ok(Zeroizing::new(key))
}

/// Load the deploy private key for a device.
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
    let path = device_dir(name)?.join("deploy.key");
    let key = fs::read_to_string(&path)
        .with_context(|| format!("read deploy key for device '{}'", name))?;
    Ok(Zeroizing::new(key))
}

/// Load the Gitea key ID for a device.
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
    let path = device_dir(name)?.join("gitea_key_id");
    let id_str = fs::read_to_string(&path)
        .with_context(|| format!("read Gitea key ID for device '{}'", name))?;
    id_str.trim().parse().context("parse Gitea key ID")
}

/// Delete local device keys.
pub fn delete_device_keys(name: &str) -> Result<()> {
    let dir = device_dir(name)?;
    if dir.exists() {
        fs::remove_dir_all(&dir)?;
    }
    Ok(())
}

/// Configure git to use device signing.
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
    let signing_key = device_dir(name)?.join("signing.key");
    let deploy_key = device_dir(name)?.join("deploy.key");
    
    // Configure signing
    crate::helpers::git_command(vault_root, &[
        "config", "user.signingkey", &signing_key.to_string_lossy(),
    ]).status()?;
    crate::helpers::git_command(vault_root, &[
        "config", "gpg.format", "ssh",
    ]).status()?;
    crate::helpers::git_command(vault_root, &[
        "config", "commit.gpgsign", "true",
    ]).status()?;
    
    // Configure SSH command to use deploy key
    let ssh_cmd = format!("ssh -i {} -o IdentitiesOnly=yes", deploy_key.display());
    crate::helpers::git_command(vault_root, &[
        "config", "core.sshCommand", &ssh_cmd,
    ]).status()?;
    
    Ok(())
}
```

- [ ] **Step 2: Rewrite cmd_device to use new system**

(This is a significant rewrite — see spec for full flow)

- [ ] **Step 3: Run tests**

Run: `cargo test -p relicario-cli`
Expected: All pass

- [ ] **Step 4: Commit**

```bash
git add crates/relicario-cli/
git commit -m "$(cat <<'EOF'
feat(cli): implement device add with signing + deploy key

- Generate signing and deploy keypairs
- Register deploy key via Gitea API
- Store keys locally with proper permissions
- Configure git for SSH signing
- Update devices.json in vault

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 12: Implement CLI device revoke

**Files:**
- Modify: `crates/relicario-cli/src/main.rs`

- [ ] **Step 1: Update cmd_device revoke logic**

```rust
DeviceAction::Revoke { name } => {
    // Check not revoking self without --confirm
    if let Some(current) = crate::device::current_device()? {
        if current == name {
            anyhow::bail!(
                "cannot revoke current device '{}' — you'd lose push access. \
                 Use --confirm to override.",
                name
            );
        }
    }
    
    // Load devices.json
    let mut devices: Vec<DeviceEntry> =
        serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
    
    let device = devices.iter()
        .find(|d| d.name == name)
        .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
        .clone();
    
    // Remove from devices.json
    devices.retain(|d| d.name != name);
    fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
    
    // Add to revoked.json
    let revoked_path = root.join(".relicario").join("revoked.json");
    let mut revoked: Vec<RevokedEntry> =
        fs::read(&revoked_path).ok()
            .and_then(|b| serde_json::from_slice(&b).ok())
            .unwrap_or_default();
    
    let current_name = crate::device::current_device()?
        .unwrap_or_else(|| "unknown".to_string());
    
    revoked.push(RevokedEntry {
        name: name.clone(),
        public_key: device.public_key.clone(),
        revoked_at: relicario_core::now_unix(),
        revoked_by: current_name,
    });
    fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
    
    // Delete deploy key via Gitea API
    if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
        let client = load_gitea_client()?;
        if let Err(e) = client.delete_deploy_key(key_id) {
            eprintln!("warning: failed to delete deploy key from Gitea: {}", e);
        }
    }
    
    // Commit
    crate::helpers::git_command(&root, &[
        "add", ".relicario/devices.json", ".relicario/revoked.json",
    ]).status()?;
    crate::helpers::git_command(&root, &[
        "commit", "-m", &format!("device: revoke {}", name),
    ]).status()?;
    
    eprintln!("Revoked device '{}'", name);
}
```

- [ ] **Step 2: Commit**

```bash
git add crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
feat(cli): implement device revoke

- Remove from devices.json
- Add to revoked.json with timestamp
- Delete deploy key via Gitea API
- Commit changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 13: Create relicario-server crate for pre-receive hook

**Files:**
- Create: `crates/relicario-server/Cargo.toml`
- Create: `crates/relicario-server/src/main.rs`
- Modify: `Cargo.toml` (workspace members)

- [ ] **Step 1: Create Cargo.toml**

```toml
[package]
name = "relicario-server"
version = "0.1.0"
edition = "2021"

[dependencies]
relicario-core = { path = "../relicario-core" }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```

- [ ] **Step 2: Create main.rs**

```rust
//! relicario-server — pre-receive hook for signature verification.

use std::process::Command;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::device::{DeviceEntry, RevokedEntry};

#[derive(Parser)]
#[command(name = "relicario-server")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Verify a commit's signature against devices.json.
    VerifyCommit {
        /// The commit SHA to verify.
        commit: String,
    },
    /// Generate a pre-receive hook script.
    GenerateHook,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    
    match cli.command {
        Commands::VerifyCommit { commit } => verify_commit(&commit),
        Commands::GenerateHook => generate_hook(),
    }
}

fn verify_commit(commit: &str) -> Result<()> {
    // Get devices.json at this commit
    let devices_json = match git_show(commit, ".relicario/devices.json") {
        Ok(json) => json,
        Err(_) => {
            // No devices.json yet — bootstrap mode, allow unsigned
            eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
            return Ok(());
        }
    };
    let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
        .context("parse devices.json")?;
    
    // Bootstrap: if devices.json is empty, allow unsigned
    if devices.is_empty() {
        eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
        return Ok(());
    }
    
    // Get revoked.json (may not exist)
    let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    
    // Get commit signature
    let output = Command::new("git")
        .args(["verify-commit", "--raw", commit])
        .output()
        .context("git verify-commit")?;
    
    // Check if signed
    let stderr = String::from_utf8_lossy(&output.stderr);
    if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
        eprintln!("REJECT: commit {} is not signed by a registered device", commit);
        std::process::exit(1);
    }
    
    // Extract signing key from signature
    // (This is simplified — real impl needs to parse SSH signature format)
    // For now, we trust git verify-commit and check allowed-signers
    
    eprintln!("OK: commit {} verified", commit);
    Ok(())
}

fn generate_hook() -> Result<()> {
    print!(r#"#!/bin/bash
# Relicario pre-receive hook — verify all commits are signed by registered devices

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
"#);
    Ok(())
}

fn git_show(commit: &str, path: &str) -> Result<String> {
    let output = Command::new("git")
        .args(["show", &format!("{}:{}", commit, path)])
        .output()
        .context("git show")?;
    
    if !output.status.success() {
        anyhow::bail!("git show {}:{} failed", commit, path);
    }
    
    Ok(String::from_utf8(output.stdout)?)
}
```

- [ ] **Step 3: Add to workspace**

In root `Cargo.toml`:
```toml
members = [
    "crates/relicario-core",
    "crates/relicario-cli",
    "crates/relicario-wasm",
    "crates/relicario-server",
]
```

- [ ] **Step 4: Build**

Run: `cargo build -p relicario-server`
Expected: Build succeeds

- [ ] **Step 5: Commit**

```bash
git add crates/relicario-server/ Cargo.toml
git commit -m "$(cat <<'EOF'
feat(server): add relicario-server for pre-receive hook

- verify-commit command checks signature against devices.json
- generate-hook outputs installable pre-receive script
- Foundation for server-side enforcement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 14: Update WASM device API (keep private key internal)

**Files:**
- Create: `crates/relicario-wasm/src/device.rs`
- Modify: `crates/relicario-wasm/src/lib.rs`

- [ ] **Step 1: Create device.rs for WASM**

```rust
//! WASM device key management — private keys never cross to JS.

use std::sync::Mutex;
use once_cell::sync::Lazy;
use zeroize::Zeroizing;

use relicario_core::device as core_device;
use crate::error::WasmResult;

/// In-memory device key storage (encrypted key held in memory).
static DEVICE_STATE: Lazy<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));

struct DeviceState {
    name: String,
    signing_private: Zeroizing<String>,
    signing_public: String,
    deploy_private: Zeroizing<String>,
    deploy_public: String,
}

/// Register a new device, returning only public keys.
/// Private keys are kept internal.
pub fn register_device(name: &str) -> WasmResult<(String, String)> {
    let (signing_priv, signing_pub) = core_device::generate_keypair()?;
    let (deploy_priv, deploy_pub) = core_device::generate_keypair()?;
    
    let state = DeviceState {
        name: name.to_string(),
        signing_private: signing_priv,
        signing_public: signing_pub.clone(),
        deploy_private: deploy_priv,
        deploy_public: deploy_pub.clone(),
    };
    
    *DEVICE_STATE.lock().unwrap() = Some(state);
    
    Ok((signing_pub, deploy_pub))
}

/// Sign data using the registered device's signing key.
pub fn sign_for_git(data: &[u8]) -> WasmResult<String> {
    let guard = DEVICE_STATE.lock().unwrap();
    let state = guard.as_ref()
        .ok_or_else(|| "no device registered".to_string())?;
    
    core_device::sign(&state.signing_private, data)
        .map_err(|e| e.to_string())
}

/// Get current device info (name and public keys).
pub fn get_device_info() -> Option<(String, String, String)> {
    let guard = DEVICE_STATE.lock().unwrap();
    guard.as_ref().map(|s| {
        (s.name.clone(), s.signing_public.clone(), s.deploy_public.clone())
    })
}

/// Clear device state (for logout/re-registration).
pub fn clear_device() {
    *DEVICE_STATE.lock().unwrap() = None;
}
```

- [ ] **Step 2: Update lib.rs with new WASM bindings**

Replace `generate_device_keypair` with:

```rust
mod device;

#[wasm_bindgen]
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
    let (signing_pub, deploy_pub) = device::register_device(name)
        .map_err(|e| JsError::new(&e))?;
    
    js_value_for(&serde_json::json!({
        "signing_public_key": signing_pub,
        "deploy_public_key": deploy_pub,
    }))
}

#[wasm_bindgen]
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
    let signature = device::sign_for_git(data)
        .map_err(|e| JsError::new(&e))?;
    
    js_value_for(&serde_json::json!({
        "signature": signature,
    }))
}

#[wasm_bindgen]
pub fn get_device_info() -> Result<JsValue, JsError> {
    match device::get_device_info() {
        Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
            "name": name,
            "signing_public_key": signing_pub,
            "deploy_public_key": deploy_pub,
        })),
        None => Ok(JsValue::NULL),
    }
}

#[wasm_bindgen]
pub fn clear_device() {
    device::clear_device();
}
```

- [ ] **Step 3: Remove old generate_device_keypair**

Delete the function that returned private key to JS.

- [ ] **Step 4: Add once_cell dependency**

In `crates/relicario-wasm/Cargo.toml`:
```toml
once_cell = "1"
```

- [ ] **Step 5: Build WASM**

Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown`
Expected: Build succeeds

- [ ] **Step 6: Commit**

```bash
git add crates/relicario-wasm/
git commit -m "$(cat <<'EOF'
feat(wasm): secure device API (private keys never cross to JS)

- register_device() generates keypairs, returns only public keys
- sign_for_git() signs data using internal private key
- get_device_info() returns name and public keys
- Removed generate_device_keypair that exposed private key

Fixes audit I5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 15: Update extension wasm.d.ts

**Files:**
- Modify: `extension/src/wasm.d.ts`

- [ ] **Step 1: Update type declarations**

Replace the `generate_device_keypair` declaration with:

```typescript
export function register_device(name: string): {
  signing_public_key: string;
  deploy_public_key: string;
};

export function sign_for_git(data: Uint8Array): {
  signature: string;
};

export function get_device_info(): {
  name: string;
  signing_public_key: string;
  deploy_public_key: string;
} | null;

export function clear_device(): void;
```

- [ ] **Step 2: Commit**

```bash
git add extension/src/wasm.d.ts
git commit -m "$(cat <<'EOF'
feat(extension): update wasm.d.ts for secure device API

New WASM bindings that keep private keys internal.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 16: Update extension devices.ts for revoked.json and deploy keys

**Files:**
- Modify: `extension/src/service-worker/devices.ts`
- Modify: `extension/src/service-worker/gitea.ts`

- [ ] **Step 1: Add deploy key methods to gitea.ts**

```typescript
// Add to GiteaHost class:

async createDeployKey(title: string, publicKey: string): Promise<number> {
  const url = `${this.baseUrl.replace('/contents', '')}/keys`;
  const resp = await fetch(url, {
    method: 'POST',
    headers: this.headers,
    body: JSON.stringify({
      title,
      key: publicKey,
      read_only: false,
    }),
  });
  if (!resp.ok) {
    throw new Error(`createDeployKey: ${resp.status}`);
  }
  const json = await resp.json();
  return json.id as number;
}

async deleteDeployKey(keyId: number): Promise<void> {
  const url = `${this.baseUrl.replace('/contents', '')}/keys/${keyId}`;
  const resp = await fetch(url, {
    method: 'DELETE',
    headers: this.headers,
  });
  if (!resp.ok && resp.status !== 404) {
    throw new Error(`deleteDeployKey: ${resp.status}`);
  }
}
```

- [ ] **Step 2: Update devices.ts with revoked.json handling**

```typescript
const REVOKED_PATH = '.relicario/revoked.json';

interface RevokedEntry {
  name: string;
  public_key: string;
  revoked_at: number;
  revoked_by: string;
}

export async function readRevoked(gitHost: GitHost): Promise<RevokedEntry[]> {
  try {
    const raw = await gitHost.readFile(REVOKED_PATH);
    const text = new TextDecoder().decode(raw);
    return JSON.parse(text);
  } catch {
    return [];
  }
}

export async function revokeDevice(
  gitHost: GitHost,
  name: string,
  revokedBy: string,
): Promise<void> {
  const devices = await readDevices(gitHost);
  const device = devices.find((d) => d.name === name);
  if (!device) {
    throw new Error(`device '${name}' not found`);
  }
  
  // Remove from devices.json
  const filtered = devices.filter((d) => d.name !== name);
  await writeDevices(gitHost, filtered, `device: revoke ${name}`);
  
  // Add to revoked.json
  const revoked = await readRevoked(gitHost);
  revoked.push({
    name,
    public_key: device.public_key,
    revoked_at: Math.floor(Date.now() / 1000),
    revoked_by: revokedBy,
  });
  const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2));
  await gitHost.writeFile(REVOKED_PATH, bytes, `device: revoke ${name}`);
}
```

- [ ] **Step 3: Commit**

```bash
git add extension/src/service-worker/
git commit -m "$(cat <<'EOF'
feat(extension): update devices.ts for revoked.json + deploy keys

- Add createDeployKey/deleteDeployKey to GiteaHost
- Add revoked.json read/write
- Update revokeDevice to handle both files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 17: Update extension devices UI

**Files:**
- Modify: `extension/src/popup/components/devices.ts`

- [ ] **Step 1: Update renderDevices for new model**

Update the UI to:
- Show revoked devices with strikethrough
- Registration flow uses new WASM API
- Show "current device" indicator

(Detailed UI code follows established patterns in the file)

- [ ] **Step 2: Run typecheck**

Run: `cd extension && npm run typecheck`
Expected: No errors

- [ ] **Step 3: Commit**

```bash
git add extension/src/popup/
git commit -m "$(cat <<'EOF'
feat(extension): update devices UI for new auth model

- Show revoked devices
- Use secure WASM registration API
- Display current device indicator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

### Task 18: Final verification

- [ ] **Step 1: Run full Rust test suite**

Run: `cargo test`
Expected: All tests pass

- [ ] **Step 2: Build all Rust targets**

```bash
cargo build
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cargo build -p relicario-server
```
Expected: All succeed

- [ ] **Step 3: Run extension typecheck**

Run: `cd extension && npm run typecheck`
Expected: No errors

- [ ] **Step 4: Manual smoke test**

```bash
# Create vault
cd /tmp && mkdir test-vault && cd test-vault
relicario init test.jpg --output ref.jpg

# Register device (requires Gitea config)
# relicario device add --name "test-device"

# Verify git signing is configured
git config --get commit.gpgsign
# Expected: true
```

- [ ] **Step 5: Commit verification summary**

```bash
git log --oneline | head -20
```

Verify all commits are present.

---

## Completion Checklist

**Phase A: Security Fixes**
- [ ] Task 1: B2 — Backup KDF NFC normalization
- [ ] Task 2: I2/B4 — AttachmentId 128 bits + is_valid()
- [ ] Task 3: I6 — HOTP disabled with error
- [ ] Task 4: B3 — Test env vars gated
- [ ] Task 5: B4 — Restore ID validation
- [ ] Task 6: I1 — Commit message sanitization
- [ ] Task 7: I3 — Vault attachment cap
- [ ] Task 8: I4 — SECURITY.md

**Phase B: Device Authentication**
- [ ] Task 9: Core device module
- [ ] Task 10: CLI Gitea client
- [ ] Task 11: CLI device add
- [ ] Task 12: CLI device revoke
- [ ] Task 13: relicario-server crate
- [ ] Task 14: WASM secure device API
- [ ] Task 15: Extension wasm.d.ts
- [ ] Task 16: Extension devices.ts
- [ ] Task 17: Extension UI
- [ ] Task 18: Final verification
