Phase A: 8 security fixes (B2-B4, I1-I6) Phase B: 10 tasks for real device authentication - ed25519 signing keys with git SSH signing - Deploy keys managed via Gitea API - Pre-receive hook for server-side enforcement - WASM API that keeps private keys internal Total: 18 tasks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1985 lines
55 KiB
Markdown
1985 lines
55 KiB
Markdown
# 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
|