feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror
- Add `org_manifest_decrypt` + `org_manifest_encrypt` to relicario-wasm/src/lib.rs,
placed after `org_unwrap_key`; mirror the existing manifest_decrypt/manifest_encrypt
pattern exactly (need_key guard, session::with, js_value_for). Core import path:
relicario_core::decrypt_org_manifest / relicario_core::encrypt_org_manifest (crate root).
- Add round-trip test `org_manifest_round_trip_via_handle` in org_tests: encrypt via
WASM wrapper, decrypt via core directly (avoids serde_wasm_bindgen off-wasm).
Asserts collection/title/tags/schema_version survive the round-trip; asserts nonce
uniqueness. TDD: RED (compile error) → GREEN confirmed.
- Declare org_manifest_decrypt/org_manifest_encrypt in extension/src/wasm.d.ts.
- Add faithful/lean OrgManifestEntry + OrgManifest TS interfaces to
extension/src/shared/types.ts: {id, type, title, tags[], modified, trashed_at?,
collection} — no personal-only fields; collection required. Mirrors org.rs:184-202.
- Plan-doc: rewrite Task 2 body (re-scope rationale + checked steps), fix File
Structure + Architecture lines, amend Hand-off contract (org_list_items ->
OrgManifestEntry[]; org items remain personal Item).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
@@ -602,12 +602,73 @@ pub fn org_unwrap_key(
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
/// Decrypt an org manifest blob (manifest.enc in the org repo) with an org
|
||||
/// session handle. The org manifest is a dedicated OrgManifest type (NOT the
|
||||
/// personal Manifest); core's decrypt_org_manifest deserializes it.
|
||||
#[wasm_bindgen]
|
||||
pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||
need_key(handle)?;
|
||||
let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
js_value_for(&out)
|
||||
}
|
||||
|
||||
/// Encrypt an OrgManifest (JSON) with an org session handle, for the write track
|
||||
/// (Dev-C never touches WASM — this is exposed here so they don't have to).
|
||||
#[wasm_bindgen]
|
||||
pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let m: relicario_core::OrgManifest = serde_json::from_str(manifest_json)
|
||||
.map_err(|e| JsError::new(&format!("org manifest json: {e}")))?;
|
||||
session::with(handle.0, |k| relicario_core::encrypt_org_manifest(&m, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod org_tests {
|
||||
use super::*;
|
||||
use relicario_core::org::wrap_org_key;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[test]
|
||||
fn org_manifest_round_trip_via_handle() {
|
||||
use relicario_core::{OrgManifest, OrgManifestEntry, ItemType, ItemId, decrypt_org_manifest};
|
||||
session::clear();
|
||||
let key_bytes = [0xABu8; 32];
|
||||
let h = session::insert(Zeroizing::new(key_bytes), Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new(key_bytes);
|
||||
|
||||
let mut manifest = OrgManifest::new();
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::SecureNote,
|
||||
title: "Org Test Item".into(),
|
||||
tags: vec!["tag1".into()],
|
||||
modified: 1_234_567_890,
|
||||
trashed_at: None,
|
||||
collection: "prod".into(),
|
||||
});
|
||||
|
||||
let manifest_json = serde_json::to_string(&manifest).unwrap();
|
||||
let encrypted = org_manifest_encrypt(&handle, &manifest_json).unwrap();
|
||||
assert!(!encrypted.is_empty());
|
||||
|
||||
// Decrypt via core directly (avoids js-sys/serde_wasm_bindgen on native).
|
||||
let parsed: OrgManifest = decrypt_org_manifest(&encrypted, &key).unwrap();
|
||||
assert_eq!(parsed.entries.len(), 1);
|
||||
assert_eq!(parsed.entries[0].collection, "prod");
|
||||
assert_eq!(parsed.entries[0].title, "Org Test Item");
|
||||
assert_eq!(parsed.entries[0].tags, vec!["tag1".to_string()]);
|
||||
assert_eq!(parsed.schema_version, 1);
|
||||
|
||||
// Random nonces: two encryptions of the same plaintext must differ.
|
||||
let encrypted2 = org_manifest_encrypt(&handle, &manifest_json).unwrap();
|
||||
assert_ne!(encrypted, encrypted2, "nonces must differ");
|
||||
}
|
||||
|
||||
/// Returns (public_openssh, private_openssh) using the real core keypair generator.
|
||||
///
|
||||
/// Device private keys are produced by `relicario_core::device::generate_keypair()`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI.
|
||||
|
||||
**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM.
|
||||
**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`). Org ITEMS share the personal `.enc` format and reuse `item_decrypt`/`item_encrypt` on the org handle directly. The org MANIFEST is a dedicated `OrgManifest` type (NOT the personal `Manifest`) — it uses `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2, mirroring `manifest_decrypt`/`manifest_encrypt` in pattern but wrapping core's dedicated org manifest AEAD). New WASM functions: `org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM.
|
||||
|
||||
**Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom.
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
## File Structure
|
||||
|
||||
- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.)
|
||||
- `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option<String>` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.)
|
||||
- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). Org items reuse `item_decrypt`/`item_encrypt` on the returned handle. Org manifest uses the dedicated `org_manifest_decrypt`/`org_manifest_encrypt` (NOT `manifest_decrypt`/`manifest_encrypt` — the org manifest is a different type `OrgManifest`).
|
||||
- ~~`crates/relicario-core/src/manifest.rs`~~ — NOT modified. The org manifest is a dedicated `OrgManifest` type, not the personal `Manifest`. Core already ships its AEAD wrappers. No change to `ManifestEntry` was needed or made.
|
||||
- `extension/src/wasm.d.ts` — declare `org_unwrap_key`.
|
||||
- `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry.
|
||||
- `extension/src/service-worker/org-config.ts` *(new)* — `orgConfigs` read/write over `chrome.storage.local`.
|
||||
@@ -120,55 +120,42 @@ git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Org manifest `collection` field round-trips
|
||||
### Task 2: Org manifest over WASM + faithful OrgManifest/OrgManifestEntry TS mirror
|
||||
|
||||
> **Re-scoped after investigation.** The original Task 2 ("add collection to the personal
|
||||
> ManifestEntry") was based on a wrong assumption and was rejected. Investigation confirmed
|
||||
> that the org manifest is a DEDICATED type (`OrgManifest`/`OrgManifestEntry`), NOT the
|
||||
> personal `Manifest`. Core already ships `encrypt_org_manifest`/`decrypt_org_manifest`
|
||||
> AEAD wrappers (`crates/relicario-core/src/vault.rs:56,62`, re-exported at crate root).
|
||||
> Adding `collection` to the personal `ManifestEntry` would be wrong and was not done.
|
||||
> The PM ratified this re-scope (Option A). This task exposes the existing core wrappers
|
||||
> over WASM and mirrors the org manifest types into TS.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/relicario-core/src/manifest.rs`
|
||||
- Modify: `extension/src/shared/types.ts`
|
||||
- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module)
|
||||
- Modify: `crates/relicario-wasm/src/lib.rs` — add `org_manifest_decrypt` + `org_manifest_encrypt` after `org_unwrap_key`; add round-trip test in `org_tests`.
|
||||
- Modify: `extension/src/wasm.d.ts` — declare both new functions.
|
||||
- Modify: `extension/src/shared/types.ts` — add `OrgManifestEntry` + `OrgManifest` interfaces (faithful/lean; no personal-only fields).
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ManifestEntry.collection: Option<String>` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`.
|
||||
- Produces: `org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>` — mirrors `manifest_decrypt`; org items remain personal Items.
|
||||
- Produces: `org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>` — mirrors `manifest_encrypt`.
|
||||
- Produces: `OrgManifestEntry` TS interface: `{id, type, title, tags[], modified, trashed_at?, collection}`. `collection` is REQUIRED. No personal-only fields (no attachment_summaries/icon_hint/favorite/group).
|
||||
- Produces: `OrgManifest` TS interface: `{schema_version, entries: OrgManifestEntry[]}`.
|
||||
- Core import path confirmed: `relicario_core::decrypt_org_manifest` / `relicario_core::encrypt_org_manifest` / `relicario_core::OrgManifest` — all re-exported at crate root (`lib.rs:81-83`, `lib.rs:99`).
|
||||
|
||||
- [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed.
|
||||
- [x] **Step 1 (TDD RED):** Added test `org_manifest_round_trip_via_handle` in `org_tests` before implementing the fns. Confirmed compile error: `cannot find function 'org_manifest_encrypt' in this scope`.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
- [x] **Step 2 (Implement):** Added `org_manifest_decrypt` + `org_manifest_encrypt` in `lib.rs` right after `org_unwrap_key`, mirroring the existing `manifest_decrypt`/`manifest_encrypt` pattern exactly. Same `need_key(handle)?` guard, same `session::with`, same `js_value_for(&out)`. Swapped type + core fn.
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn manifest_entry_round_trips_collection_slug() {
|
||||
let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#;
|
||||
let entry: ManifestEntry = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(entry.collection.as_deref(), Some("prod-infra"));
|
||||
let back = serde_json::to_string(&entry).unwrap();
|
||||
assert!(back.contains("prod-infra"));
|
||||
}
|
||||
```
|
||||
- [x] **Step 3 (TDD GREEN):** `cargo test -p relicario-wasm org_manifest` → 1 passed.
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails**
|
||||
- [x] **Step 4 (TS types):** Added `OrgManifestEntry` + `OrgManifest` to `extension/src/shared/types.ts` (faithful/lean). Added `org_manifest_decrypt`/`org_manifest_encrypt` declarations to `extension/src/wasm.d.ts`.
|
||||
|
||||
Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug`
|
||||
Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only).
|
||||
|
||||
- [ ] **Step 4: Add the field if absent**
|
||||
|
||||
```rust
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub collection: Option<String>,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-core manifest`
|
||||
Expected: PASS, no other manifest test regressed.
|
||||
|
||||
- [ ] **Step 6: Mirror in TS + commit**
|
||||
|
||||
Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`.
|
||||
- [x] **Step 5 (gates):** All five gates green (see commit).
|
||||
|
||||
```bash
|
||||
git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts
|
||||
git commit -m "feat(core): ManifestEntry carries optional collection slug"
|
||||
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts extension/src/shared/types.ts
|
||||
git commit -m "feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -460,8 +447,8 @@ Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages
|
||||
|
||||
- `org_list_configs` → `{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }`
|
||||
- `org_switch { context: 'personal' | <orgId> }` → `{ ok, data: { context, offline: boolean } }`
|
||||
- `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`)
|
||||
- `org_get_item { id }` → `{ ok, data: Item }`
|
||||
- `org_list_items` → `{ ok, data: OrgManifestEntry[] }` (already grant-filtered; `collection` required — entries are the lean org shape, NOT personal ManifestEntry)
|
||||
- `org_get_item { id }` → `{ ok, data: Item }` (org items are personal `Item`s encrypted with the org key; `item_decrypt` is unchanged)
|
||||
- `org_list_collections` → `{ ok, data: Collection[] }` where `Collection = { slug, display_name }`
|
||||
|
||||
The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model.
|
||||
|
||||
@@ -206,6 +206,25 @@ export interface ManifestEntry {
|
||||
attachment_summaries: AttachmentSummary[];
|
||||
}
|
||||
|
||||
// --- Org manifest ---
|
||||
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
|
||||
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.
|
||||
|
||||
export interface OrgManifestEntry {
|
||||
id: ItemId;
|
||||
type: ItemType; // Rust r#type → JSON key "type"
|
||||
title: string;
|
||||
tags: string[];
|
||||
modified: number;
|
||||
trashed_at?: number;
|
||||
collection: string;
|
||||
}
|
||||
|
||||
export interface OrgManifest {
|
||||
schema_version: number;
|
||||
entries: OrgManifestEntry[];
|
||||
}
|
||||
|
||||
// --- Vault settings ---
|
||||
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
|
||||
// β₂ tightens retention + generator_defaults; γ owns attachment_caps.
|
||||
|
||||
2
extension/src/wasm.d.ts
vendored
2
extension/src/wasm.d.ts
vendored
@@ -88,6 +88,8 @@ declare module 'relicario-wasm' {
|
||||
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
|
||||
|
||||
export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle;
|
||||
export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
||||
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||
|
||||
Reference in New Issue
Block a user