41 Commits

Author SHA1 Message Date
adlee-was-taken
2524270524 feat: add environment-aware WASM loading for Chrome/Firefox 2026-04-12 13:14:46 -04:00
adlee-was-taken
b71ebcc418 feat: add Firefox manifest and webpack config 2026-04-12 13:14:38 -04:00
adlee-was-taken
051c98dece docs: add Firefox extension port implementation plan
3 tasks: Firefox manifest + webpack config, environment-aware
WASM loading, and build integration with manual testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:11:39 -04:00
adlee-was-taken
39f04a0b97 docs: add Firefox extension port design spec
Shared TypeScript source with separate manifests and webpack configs.
Firefox uses background scripts (not service workers) so WASM loading
uses dynamic import instead of initSync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:01:57 -04:00
adlee-was-taken
ff19faff03 feat: add settings view with capture toggle and blacklist management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:25:25 -04:00
adlee-was-taken
baf6416805 feat: add credential capture with bar/toast prompts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:24:04 -04:00
adlee-was-taken
a56114650a feat: add settings, blacklist, and credential check handlers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:22:54 -04:00
adlee-was-taken
1916fa0f81 feat: add settings and credential capture message types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:22:24 -04:00
adlee-was-taken
68f2908156 docs: add credential capture implementation plan
5 tasks: types/messages, service worker handlers, capture content
script with bar/toast prompts, settings popup view, and integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:20:28 -04:00
adlee-was-taken
cdbd648079 docs: add credential capture design spec
Experimental feature for auto-detecting login form submissions and
prompting to save/update credentials. Configurable bar or toast
prompt style, off by default, with per-site blacklist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:17:20 -04:00
adlee-was-taken
c50285c4a5 refactor: replace popup setup wizard with link to setup.html
The popup is too constrained for multi-step setup (file pickers
close it, fields duplicate the init wizard). Now it just shows
a single button that opens the full-page setup wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:55:07 -04:00
adlee-was-taken
4c26b4c534 fix: remove file picker from popup setup wizard
Chrome closes popups when file pickers steal focus. Instead, check
chrome.storage.local for an existing image (pushed by init wizard),
and redirect to the full-page setup.html if no image is found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:52:35 -04:00
adlee-was-taken
0551efe69e fix: avoid full re-render on image upload in setup wizard
Calling setState() after FileReader.onload triggered a full popup
re-render which could crash or close the popup with large images.
Update DOM elements in place instead, and add error handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:44:32 -04:00
adlee-was-taken
336e90fc84 fix: use static import + initSync for WASM in service worker
Chrome MV3 service workers do not support dynamic import().
Switch to static import of the wasm-pack JS glue and use
initSync() with fetch() to load the WASM binary at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:37:44 -04:00
adlee-was-taken
8236a18433 feat: add setup wizard to webpack build and manifest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:15 -04:00
adlee-was-taken
9a53b264f2 feat: add vault initialization wizard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:12 -04:00
adlee-was-taken
5397d385e6 feat: add setup wizard HTML page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:09 -04:00
adlee-was-taken
26e68b133c feat: add embed_image_secret type declaration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:07 -04:00
adlee-was-taken
a1c9d567b1 feat: add embed_image_secret to WASM crate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:58:04 -04:00
adlee-was-taken
0c800bcd4f docs: add vault initialization wizard implementation plan
6 tasks: WASM embed function, setup HTML, wizard TypeScript,
webpack/manifest updates, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:52:51 -04:00
adlee-was-taken
b48ff0a05c docs: add vault initialization wizard design spec
Browser-based 4-step wizard for creating idfoto vaults without the
CLI. Uses WASM for crypto, pushes vault files via git API, downloads
reference image, and optionally configures the Chrome extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:46:37 -04:00
adlee-was-taken
8e63ccc23b fix: enable getrandom js feature for WASM compilation
The getrandom crate (transitive dep via rand/argon2) requires the
"js" feature flag to compile for wasm32-unknown-unknown targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:30:26 -04:00
adlee-was-taken
8093649757 fix: vault paths, TOTP caching, and keyboard nav on filtered list
- Fix .idfoto/ prefix for salt and params.json in vault.ts
- Cache TOTP secrets by entry ID to avoid re-fetching every second
- Fix keyboard navigation to use filtered entries, not unfiltered
- Add window.close() on Escape from entry list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:48:48 -04:00
adlee-was-taken
029784b67a feat: add placeholder extension icons
Minimal 16x16, 48x48, and 128x128 blue PNG icons generated programmatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:30 -04:00
adlee-was-taken
78ffeb4b8d feat: add content script with form detection and autofill
Login form detector using password field + username heuristics,
native value setter fill for React/Vue compatibility, inline "id" icon
injection with autofill candidate picker, and MutationObserver for SPA support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:27 -04:00
adlee-was-taken
b4febbbe45 feat: add popup state machine and all components
View router (setup/locked/list/detail/add/edit), unlock screen with
passphrase input, entry list with search/group tabs/keyboard nav,
entry detail with TOTP countdown and copy shortcuts, add/edit form
with password generation, and 3-step setup wizard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:23 -04:00
adlee-was-taken
caf360c978 feat: add terminal dark theme for popup
Monospace font stack, #0d1117 background, blue accents, TOTP green,
entry list with keyboard selection, confirm overlay, wizard progress bar,
and custom 4px scrollbar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:17 -04:00
adlee-was-taken
ff62970917 feat: add service worker with WASM init and message router
Main entry point that loads WASM via dynamic import, manages vault state
(master key, manifest, git host), and handles all message types from
popup and content scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:12 -04:00
adlee-was-taken
ea9dee00e1 feat: add vault operations module
Bridges WASM crypto with git host API for encrypt/decrypt of entries
and manifest, plus search, group filtering, and URL-based lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:08 -04:00
adlee-was-taken
7cf7960aff feat: add git API layer with Gitea and GitHub implementations
GitHost interface for reading/writing vault files via REST API.
Gitea and GitHub implementations handle base64 content encoding,
SHA-based updates, and directory listing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:42:02 -04:00
adlee-was-taken
71f7bf9797 feat: add shared types and message definitions
Entry, Manifest, VaultConfig types mirroring the Rust data model, plus
a discriminated-union Request type for all popup/content-to-service-worker messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:41:58 -04:00
adlee-was-taken
6866250f78 feat: add extension scaffolding
Manifest, package.json, tsconfig, webpack config, popup HTML shell,
WASM type declarations, and .gitignore entries for the Chrome MV3 extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:41:54 -04:00
adlee-was-taken
98c20b613c feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:30:51 -04:00
adlee-was-taken
eae8fd4a24 fix: preserve group field in manifest during cmd_edit
The ManifestEntry was being written with group: None instead of
preserving the entry's existing group value during edits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:27:43 -04:00
adlee-was-taken
7baec1cd67 feat: add group field to Entry and ManifestEntry
Add optional group: Option<String> to both Entry and ManifestEntry for
logical organization (e.g. "work", "personal"). Backwards-compatible via
skip_serializing_if so existing vaults deserialize with group: None.
Includes three new tests verifying round-trip and legacy deserialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:25:18 -04:00
adlee-was-taken
c7aab28484 docs: fix zig-zag position numbering and luminance rationale in imgsecret
Corrected zig-zag scan positions from 4-15 to 6-17 (verified against
standard JPEG zig-zag ordering). Fixed inverted HVS luminance reasoning
to correctly explain that luminance is used because it isn't spatially
subsampled by JPEG, not because of visual sensitivity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:23:16 -04:00
adlee-was-taken
847051216d docs: add comprehensive doc comments to all Rust source files
Document every public function, struct, field, constant, and non-trivial
private function across idfoto-core and idfoto-cli. Module-level docs
explain each module's role in the architecture. Comments explain the "why"
(crypto choices, algorithm design, data model rationale) not just the "what".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:01:48 -04:00
adlee-was-taken
0d374f3faf chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:19:48 -04:00
adlee-was-taken
822547f349 docs: add Task 0 for heavy Rust code documentation
Adds a pre-implementation task to thoroughly document all existing
Rust code in idfoto-core and idfoto-cli with doc comments explaining
the crypto pipeline, steganography algorithm, and vault data model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:15:33 -04:00
adlee-was-taken
01d5fd5d0d docs: add WASM + Chrome MV3 extension implementation plan
11 tasks covering core data model changes, WASM crate with TOTP,
extension scaffolding, git API layer, service worker, popup UI
with terminal aesthetic, content script autofill, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:14:03 -04:00
adlee-was-taken
596daf320a docs: add WASM + Chrome MV3 extension design spec
Plan 2 design covering idfoto-wasm crate, Chrome extension with
terminal-aesthetic popup, conservative autofill, Gitea/GitHub API
integration, and TOTP code generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:31 -04:00
53 changed files with 11976 additions and 41 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
target/ target/
.superpowers/ .superpowers/
.worktrees/
extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/

302
Cargo.lock generated
View File

@@ -106,6 +106,17 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -142,6 +153,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.0"
@@ -154,6 +171,22 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -318,6 +351,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -446,6 +485,12 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -456,6 +501,30 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -483,8 +552,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -510,6 +581,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "idfoto-cli" name = "idfoto-cli"
version = "0.1.0" version = "0.1.0"
@@ -542,6 +622,21 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "idfoto-wasm"
version = "0.1.0"
dependencies = [
"data-encoding",
"getrandom",
"hmac",
"idfoto-core",
"js-sys",
"serde_json",
"sha1",
"wasm-bindgen",
"wasm-bindgen-test",
]
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.10" version = "0.25.10"
@@ -579,12 +674,30 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.16" version = "0.1.16"
@@ -621,6 +734,16 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -641,6 +764,15 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -648,6 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -723,12 +856,24 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
version = "1.70.2" version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@@ -781,6 +926,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkcs8" name = "pkcs8"
version = "0.10.2" version = "0.10.2"
@@ -936,6 +1087,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -991,6 +1157,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1002,6 +1179,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signature" name = "signature"
version = "2.2.0" version = "2.2.0"
@@ -1017,6 +1200,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
@@ -1144,12 +1333,116 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472"
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -1172,6 +1465,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View File

@@ -3,4 +3,5 @@ resolver = "2"
members = [ members = [
"crates/idfoto-core", "crates/idfoto-core",
"crates/idfoto-cli", "crates/idfoto-cli",
"crates/idfoto-wasm",
] ]

View File

@@ -1,3 +1,42 @@
//! idfoto CLI -- the platform layer for the idfoto password manager.
//!
//! This binary provides the filesystem, git, and terminal I/O that
//! [`idfoto_core`] intentionally excludes. It is the "glue" between the
//! platform-agnostic core library and the user's local environment.
//!
//! ## Vault layout on disk
//!
//! ```text
//! <vault_dir>/
//! .idfoto/
//! salt # 32-byte random salt for Argon2id KDF
//! params.json # KDF tuning parameters (m, t, p)
//! devices.json # registered device public keys
//! entries/
//! <id>.enc # individual encrypted entries
//! manifest.enc # encrypted entry index (name, url, username per entry)
//! .gitignore # excludes reference.jpg from version control
//! reference.jpg # the reference image with embedded secret (gitignored)
//! ```
//!
//! ## Unlock flow
//!
//! Every command that accesses vault data follows this sequence:
//!
//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt).
//! 2. Prompt for the passphrase (read from stderr, not echoed).
//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography.
//! 4. Read the vault salt and KDF params from `.idfoto/`.
//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`.
//! 6. Use the master key to decrypt the manifest and/or individual entries.
//!
//! ## Git integration
//!
//! The CLI shells out to the `git` binary for all version control operations.
//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the
//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke)
//! creates a git commit, preserving an audit log of all vault changes.
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use idfoto_core::{ use idfoto_core::{
@@ -14,6 +53,7 @@ use std::process::Command;
// ─── CLI structure ────────────────────────────────────────────────────────── // ─── CLI structure ──────────────────────────────────────────────────────────
/// Top-level CLI argument parser.
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "idfoto", name = "idfoto",
@@ -25,70 +65,105 @@ struct Cli {
command: Commands, command: Commands,
} }
/// All available CLI subcommands.
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Initialize a new idfoto vault /// Initialize a new idfoto vault in the current directory.
/// Creates the directory structure, generates a random image secret,
/// embeds it in the carrier image, and sets up git.
Init { Init {
/// Path to the carrier JPEG image to embed the secret into.
#[arg(long)] #[arg(long)]
image: PathBuf, image: PathBuf,
/// Output path for the reference image (with embedded secret).
#[arg(long, default_value = "reference.jpg")] #[arg(long, default_value = "reference.jpg")]
output: PathBuf, output: PathBuf,
}, },
/// Add a new password entry /// Add a new password entry to the vault.
/// Prompts interactively for name, URL, username, password, notes, and TOTP.
Add, Add,
/// Get a password entry by name /// Get a password entry by name (fuzzy search).
/// Decrypts and displays the full entry, and copies the password to clipboard
/// with a 30-second auto-clear.
Get { name: String }, Get { name: String },
/// List all entries /// List all entries in the vault (names, URLs, usernames only -- no passwords).
List, List,
/// Edit an existing entry /// Edit an existing entry by name (fuzzy search).
/// Shows current values and lets you selectively update fields.
Edit { name: String }, Edit { name: String },
/// Remove an entry /// Remove an entry from the vault by name (fuzzy search).
/// Prompts for confirmation before deleting.
Rm { name: String }, Rm { name: String },
/// Sync vault with git remote /// Sync the vault with the git remote (pull --rebase, then push).
Sync, Sync,
/// Generate a random password /// Generate a random password and print it to stdout.
Generate { Generate {
/// Length of the generated password in characters.
#[arg(short, long, default_value = "20")] #[arg(short, long, default_value = "20")]
length: usize, length: usize,
}, },
/// Manage devices /// Manage device keys (add, list, revoke).
/// Device ed25519 keys are independent of the vault KDF -- revoking a device
/// does not require changing the passphrase or reference image.
Device { Device {
#[command(subcommand)] #[command(subcommand)]
action: DeviceCommands, action: DeviceCommands,
}, },
} }
/// Subcommands for device key management.
#[derive(Subcommand)] #[derive(Subcommand)]
enum DeviceCommands { enum DeviceCommands {
/// Add a new device /// Register a new device by generating an ed25519 keypair.
/// The private key is saved to the user's config directory;
/// the public key is added to the vault's devices.json.
Add { Add {
/// Human-readable name for this device (e.g., "macbook", "phone").
#[arg(long)] #[arg(long)]
name: String, name: String,
}, },
/// List registered devices /// List all registered devices and their public keys.
List, List,
/// Revoke a device /// Revoke a device by removing its public key from devices.json.
/// This does NOT rotate the vault key -- the device can no longer
/// authenticate, but the vault encryption is unchanged.
Revoke { name: String }, Revoke { name: String },
} }
// ─── Device entry ─────────────────────────────────────────────────────────── // ─── Device entry ───────────────────────────────────────────────────────────
/// A registered device, stored in `.idfoto/devices.json`.
///
/// Each device has an ed25519 keypair. The private key lives on the device
/// itself (in the user's config directory); only the public key is stored
/// in the vault. This separation means revoking a device is a metadata-only
/// operation that does not affect the vault's encryption key.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceEntry { struct DeviceEntry {
/// Human-readable device name (e.g., "macbook-pro", "pixel-7").
name: String, name: String,
/// Hex-encoded ed25519 public key (64 hex chars = 32 bytes).
public_key: String, // hex-encoded public_key: String, // hex-encoded
} }
// ─── Helper functions ─────────────────────────────────────────────────────── // ─── Helper functions ───────────────────────────────────────────────────────
/// Returns the vault root directory (the current working directory).
/// The vault is always rooted at the directory where `idfoto` is invoked.
fn vault_dir() -> PathBuf { fn vault_dir() -> PathBuf {
std::env::current_dir().expect("failed to get current directory") std::env::current_dir().expect("failed to get current directory")
} }
/// Returns the path to the `.idfoto/` configuration directory within the vault.
fn idfoto_dir() -> PathBuf { fn idfoto_dir() -> PathBuf {
vault_dir().join(".idfoto") vault_dir().join(".idfoto")
} }
/// Read the 32-byte vault salt from `.idfoto/salt`.
///
/// The salt is generated once during `init` and is unique per vault. It is
/// not secret (stored in plaintext) -- its purpose is to prevent precomputed
/// rainbow table attacks against the Argon2id KDF.
fn read_salt() -> Result<[u8; 32]> { fn read_salt() -> Result<[u8; 32]> {
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?; let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
let mut salt = [0u8; 32]; let mut salt = [0u8; 32];
@@ -99,6 +174,7 @@ fn read_salt() -> Result<[u8; 32]> {
Ok(salt) Ok(salt)
} }
/// Read the KDF parameters from `.idfoto/params.json`.
fn read_params() -> Result<KdfParams> { fn read_params() -> Result<KdfParams> {
let data = fs::read_to_string(idfoto_dir().join("params.json")) let data = fs::read_to_string(idfoto_dir().join("params.json"))
.context("failed to read params.json")?; .context("failed to read params.json")?;
@@ -106,6 +182,10 @@ fn read_params() -> Result<KdfParams> {
Ok(params) Ok(params)
} }
/// Locate the reference image path.
///
/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting
/// and testing). If not set, prompts the user interactively.
fn get_image_path() -> Result<PathBuf> { fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("IDFOTO_IMAGE") { if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
return Ok(PathBuf::from(path)); return Ok(PathBuf::from(path));
@@ -114,6 +194,13 @@ fn get_image_path() -> Result<PathBuf> {
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
} }
/// Perform the two-factor unlock sequence and return the derived master key.
///
/// This is the core authentication flow used by every vault-access command:
/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal).
/// 2. Read and decode the reference JPEG, extracting the steganographic secret.
/// 3. Load the vault salt and KDF params.
/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt).
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> { fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?; let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
@@ -130,18 +217,25 @@ fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
Ok(master_key) Ok(master_key)
} }
/// Decrypt and return the vault manifest.
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> { fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?; let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?; let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
Ok(manifest) Ok(manifest)
} }
/// Encrypt and write the vault manifest to disk.
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> { fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?; let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?; fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
Ok(()) Ok(())
} }
/// Stage all changes and create a git commit with the given message.
///
/// Every vault mutation is committed to preserve a full audit log in git history.
/// The CLI shells out to the `git` binary rather than using a Rust git library
/// to keep dependencies minimal.
fn git_commit(message: &str) -> Result<()> { fn git_commit(message: &str) -> Result<()> {
let status = Command::new("git") let status = Command::new("git")
.args(["add", "-A"]) .args(["add", "-A"])
@@ -162,6 +256,10 @@ fn git_commit(message: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Return the current time as a Unix timestamp string.
///
/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid
/// pulling in chrono or time crate dependencies.
fn now_iso8601() -> String { fn now_iso8601() -> String {
let duration = std::time::SystemTime::now() let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -169,6 +267,7 @@ fn now_iso8601() -> String {
format!("{}", duration.as_secs()) format!("{}", duration.as_secs())
} }
/// Prompt the user for input via stderr (so stdout remains clean for piping).
fn prompt(message: &str) -> Result<String> { fn prompt(message: &str) -> Result<String> {
eprint!("{}: ", message); eprint!("{}: ", message);
io::stderr().flush()?; io::stderr().flush()?;
@@ -177,6 +276,7 @@ fn prompt(message: &str) -> Result<String> {
Ok(line.trim().to_string()) Ok(line.trim().to_string())
} }
/// Prompt for an optional field. Returns `None` if the user enters an empty string.
fn prompt_optional(message: &str) -> Result<Option<String>> { fn prompt_optional(message: &str) -> Result<Option<String>> {
let value = prompt(message)?; let value = prompt(message)?;
if value.is_empty() { if value.is_empty() {
@@ -186,6 +286,8 @@ fn prompt_optional(message: &str) -> Result<Option<String>> {
} }
} }
/// Prompt for a field with a default value shown in brackets.
/// If the user presses Enter without typing, the current value is kept.
fn prompt_with_default(field: &str, current: &str) -> Result<String> { fn prompt_with_default(field: &str, current: &str) -> Result<String> {
eprint!("{} [{}]: ", field, current); eprint!("{} [{}]: ", field, current);
io::stderr().flush()?; io::stderr().flush()?;
@@ -199,6 +301,10 @@ fn prompt_with_default(field: &str, current: &str) -> Result<String> {
} }
} }
/// Generate a random password of the given length using a mixed character set.
///
/// The charset includes lowercase, uppercase, digits, and common symbols.
/// Each character is selected uniformly at random via the OS CSPRNG.
fn generate_password(length: usize) -> String { fn generate_password(length: usize) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
let mut rng = OsRng; let mut rng = OsRng;
@@ -212,6 +318,19 @@ fn generate_password(length: usize) -> String {
// ─── Command implementations ──────────────────────────────────────────────── // ─── Command implementations ────────────────────────────────────────────────
/// Initialize a new idfoto vault in the current directory.
///
/// Full sequence:
/// 1. Read the carrier JPEG provided by the user.
/// 2. Generate a random 32-byte image secret.
/// 3. Embed the secret into the carrier via DCT steganography.
/// 4. Save the resulting reference JPEG (this is the user's second factor).
/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation).
/// 6. Generate a random 32-byte salt.
/// 7. Derive the master key from passphrase + image_secret + salt.
/// 8. Create the vault directory structure (.idfoto/, entries/).
/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest.
/// 10. Initialize git and create the first commit.
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
// 1. Read carrier JPEG // 1. Read carrier JPEG
let carrier = fs::read(&image).context("failed to read carrier image")?; let carrier = fs::read(&image).context("failed to read carrier image")?;
@@ -274,7 +393,8 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
fs::write(vault_dir().join("manifest.enc"), manifest_enc) fs::write(vault_dir().join("manifest.enc"), manifest_enc)
.context("failed to write manifest.enc")?; .context("failed to write manifest.enc")?;
// 11. Create .gitignore // 11. Create .gitignore (exclude reference image from version control --
// it contains the steganographic secret and must be kept offline)
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n") fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
.context("failed to write .gitignore")?; .context("failed to write .gitignore")?;
@@ -292,11 +412,16 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
Ok(()) Ok(())
} }
/// Generate a random password and print it to stdout.
fn cmd_generate(length: usize) -> Result<()> { fn cmd_generate(length: usize) -> Result<()> {
println!("{}", generate_password(length)); println!("{}", generate_password(length));
Ok(()) Ok(())
} }
/// Add a new entry to the vault.
///
/// Prompts for all fields, encrypts the entry, writes it to `entries/<id>.enc`,
/// updates the manifest, and commits the change to git.
fn cmd_add() -> Result<()> { fn cmd_add() -> Result<()> {
let image_path = get_image_path()?; let image_path = get_image_path()?;
let master_key = unlock(&image_path)?; let master_key = unlock(&image_path)?;
@@ -332,6 +457,7 @@ fn cmd_add() -> Result<()> {
password, password,
notes, notes,
totp_secret, totp_secret,
group: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now.clone(), updated_at: now.clone(),
}; };
@@ -351,6 +477,7 @@ fn cmd_add() -> Result<()> {
name: name.clone(), name: name.clone(),
url, url,
username, username,
group: None,
updated_at: now, updated_at: now,
}, },
); );
@@ -362,6 +489,10 @@ fn cmd_add() -> Result<()> {
Ok(()) Ok(())
} }
/// Search the manifest for entries matching a query and let the user select one.
///
/// If exactly one entry matches, it is returned immediately. If multiple match,
/// the user is shown a numbered list and prompted to choose.
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> { fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
let results = manifest.search(query); let results = manifest.search(query);
if results.is_empty() { if results.is_empty() {
@@ -394,6 +525,11 @@ fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, Manife
Ok((id.clone(), entry.clone())) Ok((id.clone(), entry.clone()))
} }
/// Retrieve and display a vault entry, and copy its password to the clipboard.
///
/// The password is auto-cleared from the clipboard after 30 seconds to limit
/// exposure. The clipboard clear is best-effort (a background thread checks
/// whether the clipboard still contains the password before clearing).
fn cmd_get(query: String) -> Result<()> { fn cmd_get(query: String) -> Result<()> {
let image_path = get_image_path()?; let image_path = get_image_path()?;
let master_key = unlock(&image_path)?; let master_key = unlock(&image_path)?;
@@ -422,7 +558,10 @@ fn cmd_get(query: String) -> Result<()> {
println!("TOTP: {}", totp); println!("TOTP: {}", totp);
} }
// Copy password to clipboard with 30s TTL // Copy password to clipboard with 30s TTL.
// Uses arboard for cross-platform clipboard access.
// The clear is done in a background thread: after 30 seconds, if the
// clipboard still contains this password, it is replaced with an empty string.
match arboard::Clipboard::new() { match arboard::Clipboard::new() {
Ok(mut clipboard) => { Ok(mut clipboard) => {
if clipboard.set_text(&entry.password).is_ok() { if clipboard.set_text(&entry.password).is_ok() {
@@ -448,6 +587,10 @@ fn cmd_get(query: String) -> Result<()> {
Ok(()) Ok(())
} }
/// List all vault entries in alphabetical order.
///
/// Only shows non-sensitive metadata (name, URL, username) from the manifest.
/// Individual entry files are not decrypted.
fn cmd_list() -> Result<()> { fn cmd_list() -> Result<()> {
let image_path = get_image_path()?; let image_path = get_image_path()?;
let master_key = unlock(&image_path)?; let master_key = unlock(&image_path)?;
@@ -477,6 +620,8 @@ fn cmd_list() -> Result<()> {
Ok(()) Ok(())
} }
/// Edit an existing entry by searching for it, showing current values, and
/// prompting for new values. Unchanged fields keep their current value.
fn cmd_edit(query: String) -> Result<()> { fn cmd_edit(query: String) -> Result<()> {
let image_path = get_image_path()?; let image_path = get_image_path()?;
let master_key = unlock(&image_path)?; let master_key = unlock(&image_path)?;
@@ -517,6 +662,7 @@ fn cmd_edit(query: String) -> Result<()> {
password, password,
notes, notes,
totp_secret, totp_secret,
group: entry.group,
created_at: entry.created_at, created_at: entry.created_at,
updated_at: now.clone(), updated_at: now.clone(),
}; };
@@ -535,6 +681,7 @@ fn cmd_edit(query: String) -> Result<()> {
name: name.clone(), name: name.clone(),
url, url,
username, username,
group: updated_entry.group,
updated_at: now, updated_at: now,
}, },
); );
@@ -546,6 +693,10 @@ fn cmd_edit(query: String) -> Result<()> {
Ok(()) Ok(())
} }
/// Remove an entry from the vault after confirmation.
///
/// Deletes the encrypted entry file, removes the entry from the manifest,
/// and commits the change to git.
fn cmd_rm(query: String) -> Result<()> { fn cmd_rm(query: String) -> Result<()> {
let image_path = get_image_path()?; let image_path = get_image_path()?;
let master_key = unlock(&image_path)?; let master_key = unlock(&image_path)?;
@@ -576,6 +727,11 @@ fn cmd_rm(query: String) -> Result<()> {
Ok(()) Ok(())
} }
/// Sync the vault with the git remote.
///
/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead
/// of merge to keep the commit history linear, which is important for the
/// audit log use case.
fn cmd_sync() -> Result<()> { fn cmd_sync() -> Result<()> {
eprintln!("Pulling..."); eprintln!("Pulling...");
let status = Command::new("git") let status = Command::new("git")
@@ -601,6 +757,7 @@ fn cmd_sync() -> Result<()> {
// ─── Device management ────────────────────────────────────────────────────── // ─── Device management ──────────────────────────────────────────────────────
/// Read the device registry from `.idfoto/devices.json`.
fn read_devices() -> Result<Vec<DeviceEntry>> { fn read_devices() -> Result<Vec<DeviceEntry>> {
let path = idfoto_dir().join("devices.json"); let path = idfoto_dir().join("devices.json");
let data = fs::read_to_string(&path).context("failed to read devices.json")?; let data = fs::read_to_string(&path).context("failed to read devices.json")?;
@@ -608,30 +765,39 @@ fn read_devices() -> Result<Vec<DeviceEntry>> {
Ok(devices) Ok(devices)
} }
/// Write the device registry to `.idfoto/devices.json`.
fn write_devices(devices: &[DeviceEntry]) -> Result<()> { fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
let data = serde_json::to_string_pretty(devices)?; let data = serde_json::to_string_pretty(devices)?;
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?; fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
Ok(()) Ok(())
} }
/// Register a new device by generating an ed25519 keypair.
///
/// The private key is saved to `~/.config/idfoto/<name>.key` with
/// restrictive permissions (0600 on Unix). The public key is added to
/// the vault's devices.json and committed to git.
///
/// Device keys are independent of the vault encryption key -- revoking a
/// device does not require rotating the passphrase or reference image.
fn cmd_device_add(name: String) -> Result<()> { fn cmd_device_add(name: String) -> Result<()> {
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
let mut devices = read_devices()?; let mut devices = read_devices()?;
// Check for duplicate // Check for duplicate device names
if devices.iter().any(|d| d.name == name) { if devices.iter().any(|d| d.name == name) {
bail!("device '{}' already exists", name); bail!("device '{}' already exists", name);
} }
// Generate ed25519 keypair // Generate ed25519 keypair using the OS CSPRNG
let signing_key = SigningKey::generate(&mut OsRng); let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key(); let verifying_key = signing_key.verifying_key();
let private_key_hex = hex::encode(signing_key.to_bytes()); let private_key_hex = hex::encode(signing_key.to_bytes());
let public_key_hex = hex::encode(verifying_key.to_bytes()); let public_key_hex = hex::encode(verifying_key.to_bytes());
// Save private key // Save private key to the user's config directory (NOT in the vault)
let config_dir = dirs::config_dir() let config_dir = dirs::config_dir()
.context("failed to find config directory")? .context("failed to find config directory")?
.join("idfoto"); .join("idfoto");
@@ -646,7 +812,7 @@ fn cmd_device_add(name: String) -> Result<()> {
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
} }
// Add to devices.json // Add public key to the vault's device registry
devices.push(DeviceEntry { devices.push(DeviceEntry {
name: name.clone(), name: name.clone(),
public_key: public_key_hex, public_key: public_key_hex,
@@ -660,6 +826,7 @@ fn cmd_device_add(name: String) -> Result<()> {
Ok(()) Ok(())
} }
/// List all registered devices with their public keys.
fn cmd_device_list() -> Result<()> { fn cmd_device_list() -> Result<()> {
let devices = read_devices()?; let devices = read_devices()?;
@@ -677,6 +844,13 @@ fn cmd_device_list() -> Result<()> {
Ok(()) Ok(())
} }
/// Revoke a device by removing it from the device registry.
///
/// This is a metadata-only operation: the device's public key is removed from
/// devices.json, but the vault encryption key is NOT rotated. The revoked
/// device can no longer authenticate via its ed25519 key, but if it had
/// previously derived the master key (via passphrase + image), that key
/// remains valid until the user changes their passphrase or reference image.
fn cmd_device_revoke(name: String) -> Result<()> { fn cmd_device_revoke(name: String) -> Result<()> {
let mut devices = read_devices()?; let mut devices = read_devices()?;
let initial_len = devices.len(); let initial_len = devices.len();
@@ -695,6 +869,7 @@ fn cmd_device_revoke(name: String) -> Result<()> {
// ─── Main ─────────────────────────────────────────────────────────────────── // ─── Main ───────────────────────────────────────────────────────────────────
/// Entry point: parse CLI arguments and dispatch to the appropriate command handler.
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();

View File

@@ -1,3 +1,48 @@
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
//!
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
//!
//! ## Why XChaCha20-Poly1305 over AES-GCM
//!
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
//! nonce, birthday-bound collisions become probable around 2^48 messages under
//! the same key -- a real concern for a long-lived vault.
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
//! to cache-timing side channels.
//!
//! ## Binary ciphertext format
//!
//! Every encrypted blob produced by [`encrypt`] has this layout:
//!
//! ```text
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
//! ```
//!
//! - **Version byte** (`0x01`): allows future format changes without ambiguity.
//! Decryption rejects any version it does not recognize.
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
//! nonce management.
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
//! appended by the cipher implementation; we do not separate it.
//!
//! ## KDF pipeline
//!
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
//! password input to Argon2id:
//!
//! ```text
//! password = passphrase_bytes || image_secret (32 bytes)
//! master_key = Argon2id(password, salt, params) -> 32 bytes
//! ```
//!
//! Both factors contribute to the derived key -- compromising one without the
//! other is insufficient. The salt is vault-specific and stored in `.idfoto/salt`.
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{ use chacha20poly1305::{
aead::{Aead, KeyInit}, aead::{Aead, KeyInit},
@@ -8,14 +53,35 @@ use serde::{Deserialize, Serialize};
use crate::error::{IdfotoError, Result}; use crate::error::{IdfotoError, Result};
/// Current binary format version. Increment this if the ciphertext layout changes.
const VERSION_BYTE: u8 = 0x01; const VERSION_BYTE: u8 = 0x01;
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
const NONCE_LEN: usize = 24; const NONCE_LEN: usize = 24;
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
/// Used only for minimum-length validation during decryption.
const TAG_LEN: usize = 16; const TAG_LEN: usize = 16;
/// Total header size: version byte + nonce. The ciphertext (including tag)
/// follows immediately after the header.
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
/// A fresh random nonce is generated for each call via the OS CSPRNG.
///
/// # Errors
///
/// Returns [`IdfotoError::Encrypt`] if the underlying AEAD operation fails
/// (extremely unlikely in practice).
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> { pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into()); let cipher = XChaCha20Poly1305::new(key.into());
// Generate a fresh random 24-byte nonce for every encryption.
// With 192 bits of randomness, nonce reuse probability is negligible
// even across billions of encryptions under the same key.
let mut nonce_bytes = [0u8; NONCE_LEN]; let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes); let nonce = XNonce::from(nonce_bytes);
@@ -33,7 +99,22 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
Ok(output) Ok(output)
} }
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
///
/// Validates the version byte and minimum blob length before attempting
/// authenticated decryption. If the key is wrong or the data has been
/// tampered with, the Poly1305 tag verification fails and [`IdfotoError::Decrypt`]
/// is returned -- with no information about which bytes were wrong (preventing
/// padding oracle / chosen-ciphertext attacks).
///
/// # Errors
///
/// - [`IdfotoError::Format`] if the data is too short or has an unknown version byte.
/// - [`IdfotoError::Decrypt`] if the AEAD tag verification fails (wrong key or
/// tampered data).
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> { pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
// A zero-length plaintext produces exactly 41 bytes of output.
if data.len() < HEADER_LEN + TAG_LEN { if data.len() < HEADER_LEN + TAG_LEN {
return Err(IdfotoError::Format( return Err(IdfotoError::Format(
"data too short to be valid ciphertext".into(), "data too short to be valid ciphertext".into(),
@@ -59,13 +140,36 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
Ok(plaintext) Ok(plaintext)
} }
/// Tunable parameters for the Argon2id key derivation function.
///
/// These are stored in the vault's `.idfoto/params.json` so that every client
/// derives the same master key from the same inputs. Making them configurable
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
/// params (m=64MiB, t=3, p=4).
///
/// The parameters follow Argon2id naming conventions:
/// - `argon2_m`: memory cost in KiB
/// - `argon2_t`: time cost (number of iterations)
/// - `argon2_p`: parallelism degree (number of lanes)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams { pub struct KdfParams {
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
/// brute-force attacks expensive. Tests use 256 KiB for speed.
pub argon2_m: u32, pub argon2_m: u32,
/// Time cost (iteration count). Default is 3. Higher values increase CPU
/// time linearly. Combined with high memory cost, this makes each key
/// derivation take ~1 second on modern hardware.
pub argon2_t: u32, pub argon2_t: u32,
/// Parallelism degree. Default is 4. Sets the number of independent lanes
/// in the Argon2id memory-hard computation.
pub argon2_p: u32, pub argon2_p: u32,
} }
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
///
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
/// making brute-force attacks impractical while keeping interactive unlock fast
/// enough for daily use.
impl Default for KdfParams { impl Default for KdfParams {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -76,6 +180,28 @@ impl Default for KdfParams {
} }
} }
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
///
/// The two factors (passphrase + image_secret) are concatenated into a single
/// password input to Argon2id. This means both factors contribute entropy to
/// the derived key -- compromising one factor alone is insufficient.
///
/// # Arguments
///
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
/// [`crate::imgsecret::extract`].
/// - `salt`: a 32-byte vault-specific salt (stored in `.idfoto/salt`).
/// - `params`: the Argon2id tuning parameters (stored in `.idfoto/params.json`).
///
/// # Returns
///
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
///
/// # Errors
///
/// Returns [`IdfotoError::Kdf`] if the Argon2id parameters are invalid (e.g.,
/// memory cost below the library's minimum).
pub fn derive_master_key( pub fn derive_master_key(
passphrase: &[u8], passphrase: &[u8],
image_secret: &[u8; 32], image_secret: &[u8; 32],
@@ -92,7 +218,10 @@ pub fn derive_master_key(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Concatenate passphrase + image_secret as the password input // Concatenate passphrase + image_secret as the password input.
// This ensures both factors contribute to the derived key: knowing only
// the passphrase (without the reference image) or only the image secret
// (without the passphrase) is insufficient to derive the correct master key.
let mut password = Vec::with_capacity(passphrase.len() + 32); let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase); password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret); password.extend_from_slice(image_secret);

View File

@@ -1,8 +1,49 @@
//! Vault data model: entries, manifest entries, and the manifest index.
//!
//! The vault stores credentials in two tiers:
//!
//! 1. **Individual entries** (`entries/<id>.enc`): each file contains a single
//! [`Entry`] encrypted with the master key. Only decrypted when the user
//! needs to read or edit a specific credential.
//!
//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a
//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries.
//! This lets the CLI list and search entries by decrypting only one file,
//! rather than decrypting every entry in the vault.
//!
//! ## Entry IDs
//!
//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy,
//! ~4 billion possible values). This is sufficient for family-scale vaults while
//! keeping filenames short and filesystem-friendly.
//!
//! ## Serialization strategy
//!
//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields
//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact
//! -- omitting null fields reduces ciphertext size and avoids leaking structural
//! information about which optional fields a credential uses.
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
/// A single password entry (stored encrypted in entries/<id>.enc). /// A full credential entry stored encrypted in `entries/<id>.enc`.
///
/// Contains all sensitive data for a single credential. Each entry is encrypted
/// independently, so accessing one entry does not require decrypting others.
///
/// ## Fields
///
/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required.
/// - `url`: the login URL. Optional; used for autofill matching in the browser extension.
/// - `username`: the account username or email. Optional.
/// - `password`: the credential password. Required (this is the core secret).
/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional.
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification.
/// - `group`: optional group label for organizing entries (e.g. "work", "personal").
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry { pub struct Entry {
pub name: String, pub name: String,
@@ -15,29 +56,56 @@ pub struct Entry {
pub notes: Option<String>, pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub totp_secret: Option<String>, pub totp_secret: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
/// Summary info about an entry (stored in the manifest). /// Summary metadata for a single entry, stored in the manifest.
///
/// This is a lightweight projection of [`Entry`] that contains only the
/// non-sensitive fields needed for listing and searching. The password,
/// notes, and TOTP secret are intentionally excluded so that listing
/// entries requires decrypting only the manifest, not every individual entry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry { pub struct ManifestEntry {
/// Human-readable label for display and search matching.
pub name: String, pub name: String,
/// Login URL for search matching and browser extension autofill.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>, pub url: Option<String>,
/// Account username for display in entry listings.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>, pub username: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Timestamp of last modification, used for sorting and display.
pub updated_at: String, pub updated_at: String,
} }
/// The vault manifest — maps entry IDs to their metadata. /// The vault manifest -- an encrypted index mapping entry IDs to their metadata.
///
/// The manifest serves two purposes:
///
/// 1. **Efficient listing**: decrypting the single manifest file is enough to show
/// all entry names, URLs, and usernames without touching individual entry files.
/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive
/// substring matching against entry names and URLs.
///
/// The `version` field allows future schema migrations if the manifest format evolves.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest { pub struct Manifest {
/// Map from entry ID (8-char hex string) to entry metadata.
pub entries: HashMap<String, ManifestEntry>, pub entries: HashMap<String, ManifestEntry>,
/// Schema version. Currently always `1`.
pub version: u32, pub version: u32,
} }
impl Manifest { impl Manifest {
/// Create a new empty manifest with version 1.
pub fn new() -> Self { pub fn new() -> Self {
Manifest { Manifest {
entries: HashMap::new(), entries: HashMap::new(),
@@ -45,14 +113,23 @@ impl Manifest {
} }
} }
/// Insert or update an entry in the manifest.
///
/// If an entry with the same ID already exists, it is overwritten.
/// This is used both for `add` (new entry) and `edit` (update existing).
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
self.entries.insert(id, entry); self.entries.insert(id, entry);
} }
/// Remove an entry from the manifest by ID, returning its metadata if it existed.
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> { pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
self.entries.remove(id) self.entries.remove(id)
} }
/// Search entries by case-insensitive substring match against name and URL.
///
/// Returns a vector of `(id, entry)` pairs for all matching entries. An entry
/// matches if the query appears in its name or URL (case-insensitive).
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
let q = query.to_lowercase(); let q = query.to_lowercase();
self.entries self.entries
@@ -75,6 +152,10 @@ impl Default for Manifest {
} }
/// Generate a random 8-character hex string to use as an entry ID. /// Generate a random 8-character hex string to use as an entry ID.
///
/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`.
/// This gives ~4 billion possible values, which is more than sufficient for
/// a family-scale vault (typically < 1000 entries).
pub fn generate_entry_id() -> String { pub fn generate_entry_id() -> String {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let bytes: [u8; 4] = rng.gen(); let bytes: [u8; 4] = rng.gen();
@@ -94,6 +175,7 @@ mod tests {
password: "s3cr3t".to_string(), password: "s3cr3t".to_string(),
notes: None, notes: None,
totp_secret: None, totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}; };
@@ -115,6 +197,7 @@ mod tests {
name: "GitHub".to_string(), name: "GitHub".to_string(),
url: Some("https://github.com".to_string()), url: Some("https://github.com".to_string()),
username: Some("alice".to_string()), username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}; };
manifest.add_entry("abc12345".to_string(), me); manifest.add_entry("abc12345".to_string(), me);
@@ -136,6 +219,7 @@ mod tests {
name: "Gmail".to_string(), name: "Gmail".to_string(),
url: Some("https://mail.google.com".to_string()), url: Some("https://mail.google.com".to_string()),
username: Some("user@gmail.com".to_string()), username: Some("user@gmail.com".to_string()),
group: None,
updated_at: "2024-06-01T00:00:00Z".to_string(), updated_at: "2024-06-01T00:00:00Z".to_string(),
}, },
); );
@@ -164,6 +248,7 @@ mod tests {
name: "GitHub Account".to_string(), name: "GitHub Account".to_string(),
url: Some("https://github.com".to_string()), url: Some("https://github.com".to_string()),
username: None, username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}, },
); );
@@ -173,6 +258,7 @@ mod tests {
name: "Work Email".to_string(), name: "Work Email".to_string(),
url: Some("https://mail.example.com".to_string()), url: Some("https://mail.example.com".to_string()),
username: None, username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}, },
); );
@@ -191,4 +277,59 @@ mod tests {
let results = manifest.search("nonexistent"); let results = manifest.search("nonexistent");
assert_eq!(results.len(), 0); assert_eq!(results.len(), 0);
} }
#[test]
fn entry_deserializes_without_group_field() {
// JSON from an older vault that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"password": "hunter2",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field");
assert_eq!(entry.name, "OldEntry");
assert_eq!(entry.group, None);
}
#[test]
fn manifest_entry_deserializes_without_group_field() {
// JSON from an older manifest that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let me: ManifestEntry = serde_json::from_str(json)
.expect("should deserialize ManifestEntry without group field");
assert_eq!(me.name, "OldEntry");
assert_eq!(me.group, None);
}
#[test]
fn entry_with_group_round_trips() {
let entry = Entry {
name: "Work Laptop".to_string(),
url: None,
username: Some("alice@corp.example".to_string()),
password: "p@ssw0rd".to_string(),
notes: None,
totp_secret: None,
group: Some("work".to_string()),
created_at: "2024-03-15T00:00:00Z".to_string(),
updated_at: "2024-03-15T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
// The group field should be present in the JSON output
assert!(json.contains("\"group\""), "serialized JSON should contain group field");
assert!(json.contains("\"work\""), "serialized JSON should contain group value");
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, "Work Laptop");
assert_eq!(decoded.group, Some("work".to_string()));
}
} }

View File

@@ -1,25 +1,59 @@
//! Unified error type for the idfoto-core crate.
//!
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
//! for `std::result::Result<T, IdfotoError>`. Using a single error enum keeps the
//! public API surface predictable and makes error handling in callers (CLI, WASM
//! bindings, mobile FFI) straightforward.
use thiserror::Error; use thiserror::Error;
/// All errors that can originate from idfoto-core operations.
///
/// Variants are ordered roughly by the pipeline stage where they occur:
/// KDF -> encryption -> decryption -> format parsing -> entry lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum IdfotoError { pub enum IdfotoError {
/// The Argon2id key derivation failed. This typically means invalid KDF
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
#[error("key derivation failed: {0}")] #[error("key derivation failed: {0}")]
Kdf(String), Kdf(String),
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
/// -- the only realistic cause is an internal library error, since the cipher
/// accepts arbitrary-length plaintext.
#[error("encryption failed: {0}")] #[error("encryption failed: {0}")]
Encrypt(String), Encrypt(String),
/// Authenticated decryption failed. This means either the wrong master key
/// was used (wrong passphrase or wrong reference image) or the ciphertext
/// was tampered with / corrupted in transit or at rest. The error message is
/// intentionally vague to avoid leaking information about which factor was
/// wrong (passphrase vs. image).
#[error("decryption failed: wrong key or corrupted data")] #[error("decryption failed: wrong key or corrupted data")]
Decrypt, Decrypt,
/// The binary ciphertext blob does not match the expected format (e.g.,
/// too short to contain the version byte + nonce + tag, or an unrecognized
/// version byte). This usually indicates file corruption or a version
/// mismatch between the writer and reader.
#[error("invalid vault format: {0}")] #[error("invalid vault format: {0}")]
Format(String), Format(String),
/// A vault entry was looked up by ID but does not exist in the manifest.
/// The string payload is the missing entry ID.
#[error("entry not found: {0}")] #[error("entry not found: {0}")]
EntryNotFound(String), EntryNotFound(String),
/// A general error from the image steganography subsystem (imgsecret).
/// Covers issues like failing to decode the carrier JPEG or failing to
/// encode the output JPEG after modification.
#[error("imgsecret: {0}")] #[error("imgsecret: {0}")]
ImgSecret(String), ImgSecret(String),
/// The carrier image is too small to hold the embedded secret with
/// sufficient redundancy. The embed region (central 70% of the image)
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")] #[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
ImageTooSmall { ImageTooSmall {
min_width: u32, min_width: u32,
@@ -28,14 +62,25 @@ pub enum IdfotoError {
actual_height: u32, actual_height: u32,
}, },
/// Secret extraction from a JPEG failed. This can mean:
/// - The image never had a secret embedded in it.
/// - The image was recompressed below Q85, destroying the QIM watermarks.
/// - The image was cropped beyond the 15% crumple zone.
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
#[error("extraction failed: no valid secret found in image")] #[error("extraction failed: no valid secret found in image")]
ExtractionFailed, ExtractionFailed,
/// JSON serialization or deserialization of an entry or manifest failed.
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
/// An error related to device ed25519 key operations. Device keys are
/// separate from the vault KDF -- revoking a device does not require
/// rotating the passphrase or reference image.
#[error("device key error: {0}")] #[error("device key error: {0}")]
DeviceKey(String), DeviceKey(String),
} }
/// Crate-wide result alias, reducing boilerplate in function signatures.
pub type Result<T> = std::result::Result<T, IdfotoError>; pub type Result<T> = std::result::Result<T, IdfotoError>;

View File

@@ -1,7 +1,43 @@
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping. //! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
//! //!
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance //! This is the novel component of idfoto. It hides a 32-byte secret inside a
//! channel using Quantization Index Modulation (QIM) with majority voting. //! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
//! copies for robustness.
//!
//! ## High-level algorithm
//!
//! ### Embedding (`embed`)
//!
//! 1. Decode the carrier JPEG and extract the luminance (Y) channel.
//! 2. Compute the "embed region" -- the central 70% of the image (15% margin
//! on each side acts as a crumple zone for mild cropping).
//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced
//! blocks for embedding.
//! 4. For each copy of the secret (5-50 copies depending on image size):
//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block):
//! - Apply the 2D DCT to the 8x8 block.
//! - Embed bits into 12 mid-frequency DCT coefficients using QIM.
//! - Apply the inverse DCT to write the modified block back.
//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding.
//!
//! ### Extraction (`extract`)
//!
//! 1. Decode the JPEG and extract the Y channel.
//! 2. Try the canonical extraction (assuming the image is uncropped).
//! 3. If that fails, try crop-recovery: search for plausible original dimensions
//! and pixel offsets, reconstructing the block grid accordingly.
//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM.
//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence.
//!
//! ## Robustness
//!
//! The combination of QIM with a high quantization step (50.0), mid-frequency
//! coefficient placement, and majority voting across many copies makes the
//! watermark survive:
//! - JPEG recompression down to quality ~85
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
//! - Color space conversions (embedding is in luminance only)
use crate::error::{IdfotoError, Result}; use crate::error::{IdfotoError, Result};
use image::codecs::jpeg::JpegEncoder; use image::codecs::jpeg::JpegEncoder;
@@ -12,43 +48,97 @@ use std::io::Cursor;
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize
/// interference with the JPEG codec's own quantization.
const BLOCK_SIZE: usize = 8; const BLOCK_SIZE: usize = 8;
/// QIM quantization step. Higher values make the watermark more robust to
/// recompression but introduce more visible artifacts. A value of 50.0 is
/// higher than the typical academic value of 25 -- this is intentional because
/// we need to survive JPEG recompression at Q85 and below, which applies
/// aggressive quantization to mid-frequency coefficients. The trade-off is
/// acceptable because the reference image is a personal photo, not a
/// publication-quality image.
const QUANT_STEP: f64 = 50.0; const QUANT_STEP: f64 = 50.0;
/// Minimum image dimension (width or height) in pixels. Images smaller than
/// this cannot hold enough 8x8 blocks for reliable embedding.
const MIN_DIMENSION: u32 = 100; const MIN_DIMENSION: u32 = 100;
/// Number of secret bits to embed: 256 bits = 32 bytes.
const SECRET_BITS: usize = 256; const SECRET_BITS: usize = 256;
/// Minimum number of redundant copies of the secret. More copies improve
/// extraction reliability via majority voting, but require more blocks.
const MIN_COPIES: usize = 5; const MIN_COPIES: usize = 5;
/// Number of mid-frequency DCT positions used per block. Each block carries
/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`.
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len() const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
/// ceil(256 / 12) = 22 blocks per copy.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
/// Mid-frequency DCT positions (zig-zag positions 415) /// Mid-frequency DCT coefficient positions for embedding, specified as
/// (row, col) indices into the 8x8 DCT coefficient matrix.
///
/// These correspond to zig-zag scan positions 6 through 17 -- the "sweet spot"
/// between low-frequency coefficients (which carry visible image structure and
/// are heavily quantized by JPEG) and high-frequency coefficients (which carry
/// noise/detail and are aggressively zeroed by JPEG compression).
///
/// Mid-frequency coefficients survive JPEG recompression better than high-frequency
/// ones, while causing less visible distortion than modifying low-frequency ones.
///
/// The zig-zag ordering is the standard JPEG scan order:
/// ```text
/// Zig-zag positions 6-9: (0,3) (1,2) (2,1) (3,0)
/// Zig-zag positions 10-13: (4,0) (3,1) (2,2) (1,3)
/// Zig-zag positions 14-17: (0,4) (0,5) (1,4) (2,3)
/// ```
const EMBED_POSITIONS: [(usize, usize); 12] = [ const EMBED_POSITIONS: [(usize, usize); 12] = [
(0, 3), (0, 3),
(1, 2), (1, 2),
(2, 1), (2, 1),
(3, 0), // zig-zag 4-7 (3, 0), // zig-zag 6-9
(0, 4), (0, 4),
(1, 3), (1, 3),
(2, 2), (2, 2),
(3, 1), // zig-zag 8-11 (3, 1), // zig-zag 10-13
(4, 0), (4, 0),
(0, 5), (0, 5),
(1, 4), (1, 4),
(2, 3), // zig-zag 12-15 (2, 3), // zig-zag 14-17
]; ];
// ─── YChannel ──────────────────────────────────────────────────────────────── // ─── YChannel ────────────────────────────────────────────────────────────────
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
///
/// We embed exclusively in the luminance channel because:
/// - Luminance is not spatially subsampled by JPEG (unlike chrominance which
/// is typically 4:2:0), so the full DCT block grid is available for embedding.
/// - JPEG's chrominance subsampling would destroy embedded data by halving
/// the spatial resolution before DCT, misaligning our block positions.
/// - Working with a single channel keeps the DCT operations simple and fast.
struct YChannel { struct YChannel {
/// Row-major luminance values. `data[y * width + x]` gives the luminance
/// at pixel (x, y). Values are in the range [0, 255] after extraction
/// from RGB, but may temporarily go slightly outside this range during
/// DCT manipulation.
data: Vec<f64>, data: Vec<f64>,
width: usize, width: usize,
height: usize, height: usize,
} }
impl YChannel { impl YChannel {
/// Get the luminance value at pixel (x, y).
fn get(&self, x: usize, y: usize) -> f64 { fn get(&self, x: usize, y: usize) -> f64 {
self.data[y * self.width + x] self.data[y * self.width + x]
} }
/// Set the luminance value at pixel (x, y).
fn set(&mut self, x: usize, y: usize, val: f64) { fn set(&mut self, x: usize, y: usize, val: f64) {
self.data[y * self.width + x] = val; self.data[y * self.width + x] = val;
} }
@@ -56,19 +146,36 @@ impl YChannel {
// ─── EmbedRegion ───────────────────────────────────────────────────────────── // ─── EmbedRegion ─────────────────────────────────────────────────────────────
/// Defines the central region of the image where embedding occurs.
///
/// The embed region is the central 70% of the image -- a 15% margin is excluded
/// on each side. This margin acts as a "crumple zone": if the image is mildly
/// cropped (e.g., a social media platform trims edges), the embedded data in the
/// center remains intact. The 15% margin is sufficient to tolerate up to ~10%
/// cropping from any single edge.
struct EmbedRegion { struct EmbedRegion {
/// Pixel offset from the left edge to the start of the embed region.
x_offset: usize, x_offset: usize,
/// Pixel offset from the top edge to the start of the embed region.
y_offset: usize, y_offset: usize,
/// Width of the embed region in pixels.
#[allow(dead_code)] #[allow(dead_code)]
region_width: usize, region_width: usize,
/// Height of the embed region in pixels.
#[allow(dead_code)] #[allow(dead_code)]
region_height: usize, region_height: usize,
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
blocks_x: usize, blocks_x: usize,
/// Number of complete 8x8 blocks that fit vertically in the embed region.
blocks_y: usize, blocks_y: usize,
} }
// ─── Helper functions ──────────────────────────────────────────────────────── // ─── Helper functions ────────────────────────────────────────────────────────
/// Decode a JPEG from raw bytes and extract the luminance (Y) channel.
///
/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula:
/// `Y = 0.299*R + 0.587*G + 0.114*B`
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> { fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes)) let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format() .with_guessed_format()
@@ -82,6 +189,7 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
for y in 0..height { for y in 0..height {
for x in 0..width { for x in 0..width {
let p = rgb.get_pixel(x as u32, y as u32); let p = rgb.get_pixel(x as u32, y as u32);
// ITU-R BT.601 luma coefficients
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
data.push(luma); data.push(luma);
} }
@@ -93,10 +201,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
}) })
} }
/// Compute the embed region for a YChannel (convenience wrapper).
fn central_region(y: &YChannel) -> EmbedRegion { fn central_region(y: &YChannel) -> EmbedRegion {
compute_region(y.width, y.height) compute_region(y.width, y.height)
} }
/// Compute the central embed region for given image dimensions.
///
/// The region excludes a 15% margin on each side, leaving the central 70%.
/// The margin acts as a crumple zone for crop tolerance.
fn compute_region(width: usize, height: usize) -> EmbedRegion { fn compute_region(width: usize, height: usize) -> EmbedRegion {
let margin_x = (width as f64 * 0.15) as usize; let margin_x = (width as f64 * 0.15) as usize;
let margin_y = (height as f64 * 0.15) as usize; let margin_y = (height as f64 * 0.15) as usize;
@@ -116,6 +229,11 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion {
} }
} }
/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates.
///
/// Returns `None` if the block would extend beyond the image boundaries
/// (used during crop-recovery extraction where some blocks may have been
/// cropped away).
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
if px + 8 > y.width || py + 8 > y.height { if px + 8 > y.width || py + 8 > y.height {
return None; return None;
@@ -129,12 +247,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
Some(block) Some(block)
} }
/// Read an 8x8 block from the Y channel using block coordinates relative to
/// the embed region.
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] { fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
let start_x = region.x_offset + bx * BLOCK_SIZE; let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE;
read_block_abs(y, start_x, start_y).unwrap() read_block_abs(y, start_x, start_y).unwrap()
} }
/// Write an 8x8 block back to the Y channel using block coordinates relative
/// to the embed region.
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
let start_x = region.x_offset + bx * BLOCK_SIZE; let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE;
@@ -146,7 +268,22 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
} }
// ─── DCT ───────────────────────────────────────────────────────────────────── // ─── DCT ─────────────────────────────────────────────────────────────────────
//
// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel
// values) into a frequency-domain representation (coefficients). JPEG compression
// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed
// data where JPEG's own quantization is least destructive.
//
// We implement the DCT from scratch (rather than depending on a library) to keep
// the crate dependency-light and WASM-friendly. The 8x8 size is small enough
// that the naive O(N^2) computation is fast.
/// 1D Type-II DCT of an 8-element signal.
///
/// Applies the orthonormal DCT-II:
/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16)
///
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
fn dct1d(input: &[f64; 8]) -> [f64; 8] { fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for k in 0..8 { for k in 0..8 {
@@ -164,6 +301,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
output output
} }
/// 1D Type-III DCT (inverse DCT) of an 8-element signal.
///
/// Reconstructs the spatial-domain signal from DCT coefficients:
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
fn idct1d(input: &[f64; 8]) -> [f64; 8] { fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for i in 0..8 { for i in 0..8 {
@@ -181,11 +322,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] {
output output
} }
/// 2D DCT of an 8x8 block, computed as separable 1D DCTs.
///
/// First applies the 1D DCT to each row, then to each column of the result.
/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3)
/// instead of O(N^4) for the naive 2D formulation).
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
// Step 1: DCT along rows
let mut temp = [[0.0f64; 8]; 8]; let mut temp = [[0.0f64; 8]; 8];
for row in 0..8 { for row in 0..8 {
temp[row] = dct1d(&block[row]); temp[row] = dct1d(&block[row]);
} }
// Step 2: DCT along columns
let mut result = [[0.0f64; 8]; 8]; let mut result = [[0.0f64; 8]; 8];
for col in 0..8 { for col in 0..8 {
let mut column = [0.0f64; 8]; let mut column = [0.0f64; 8];
@@ -200,7 +348,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
result result
} }
/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs.
///
/// Reverses the 2D DCT: first applies IDCT along columns, then along rows.
/// (The order is reversed compared to the forward transform.)
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
// Step 1: IDCT along columns
let mut temp = [[0.0f64; 8]; 8]; let mut temp = [[0.0f64; 8]; 8];
for col in 0..8 { for col in 0..8 {
let mut column = [0.0f64; 8]; let mut column = [0.0f64; 8];
@@ -212,6 +365,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
temp[row][col] = transformed[row]; temp[row][col] = transformed[row];
} }
} }
// Step 2: IDCT along rows
let mut result = [[0.0f64; 8]; 8]; let mut result = [[0.0f64; 8]; 8];
for row in 0..8 { for row in 0..8 {
result[row] = idct1d(&temp[row]); result[row] = idct1d(&temp[row]);
@@ -220,7 +374,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
} }
// ─── QIM ───────────────────────────────────────────────────────────────────── // ─── QIM ─────────────────────────────────────────────────────────────────────
//
// Quantization Index Modulation (QIM) is the core technique for encoding bits
// into DCT coefficients. It works by quantizing each coefficient to one of two
// interleaved grids, where the grid selection encodes the bit value.
//
// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...)
// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...)
//
// Extraction simply measures which grid the coefficient is closest to.
//
// QIM is preferred over spread-spectrum or LSB methods because it is:
// - Robust to recompression (the quantization step is larger than JPEG's own)
// - Simple to implement and analyze
// - Deterministic (no pseudo-random spreading sequence to synchronize)
/// Embed a single bit into a DCT coefficient using QIM.
///
/// Quantizes the coefficient to the nearest point on the grid selected by `bit`:
/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...)
/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...)
///
/// The returned value is the modified coefficient.
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
let offset = if bit == 1 { q / 2.0 } else { 0.0 }; let offset = if bit == 1 { q / 2.0 } else { 0.0 };
let shifted = coef - offset; let shifted = coef - offset;
@@ -228,8 +403,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
quantized + offset quantized + offset
} }
/// Extract a single bit from a DCT coefficient using QIM.
///
/// Computes the distance from the coefficient to each grid (bit-0 grid and
/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum
/// likelihood) decoder for QIM under additive noise.
fn qim_extract(coef: f64, q: f64) -> u8 { fn qim_extract(coef: f64, q: f64) -> u8 {
// Distance to the nearest bit-0 grid point
let d0 = (coef - (coef / q).round() * q).abs(); let d0 = (coef - (coef / q).round() * q).abs();
// Distance to the nearest bit-1 grid point (offset by q/2)
let offset = q / 2.0; let offset = q / 2.0;
let shifted = coef - offset; let shifted = coef - offset;
let d1 = (shifted - (shifted / q).round() * q).abs(); let d1 = (shifted - (shifted / q).round() * q).abs();
@@ -238,6 +420,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
// ─── Bit conversion ────────────────────────────────────────────────────────── // ─── Bit conversion ──────────────────────────────────────────────────────────
/// Convert a byte slice to a vector of individual bits (MSB first).
///
/// Each byte is expanded to 8 bits, with bit 7 (MSB) first.
/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]`
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> { fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
let mut bits = Vec::with_capacity(bytes.len() * 8); let mut bits = Vec::with_capacity(bytes.len() * 8);
for &byte in bytes { for &byte in bytes {
@@ -248,6 +434,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
bits bits
} }
/// Convert a vector of individual bits (MSB first) back to bytes.
///
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> { fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
for chunk in bits.chunks(8) { for chunk in bits.chunks(8) {
@@ -263,7 +452,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
// ─── Block selection ───────────────────────────────────────────────────────── // ─── Block selection ─────────────────────────────────────────────────────────
/// Compute the absolute pixel positions of embed blocks for a given image size. /// Compute the absolute pixel positions of embed blocks for a given image size.
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks. ///
/// This function deterministically maps image dimensions to a list of block
/// positions. Both the embedder and extractor call this function with the same
/// dimensions to agree on where blocks are. During crop recovery, the extractor
/// tries different assumed original dimensions to find the correct grid.
///
/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates.
/// Returns an empty vec if the image is too small to embed.
///
/// Blocks are selected with even spacing (stride) across the embed region to
/// spread the watermark uniformly, making it more resilient to localized damage.
/// The number of copies is capped at 50 to avoid diminishing returns.
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> { fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
let region = compute_region(img_width, img_height); let region = compute_region(img_width, img_height);
let total_blocks = region.blocks_x * region.blocks_y; let total_blocks = region.blocks_x * region.blocks_y;
@@ -273,6 +473,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let target_count = num_copies * BLOCKS_PER_COPY; let target_count = num_copies * BLOCKS_PER_COPY;
// Stride ensures blocks are evenly distributed across the embed region
let stride = (total_blocks / target_count).max(1); let stride = (total_blocks / target_count).max(1);
let mut positions = Vec::with_capacity(target_count); let mut positions = Vec::with_capacity(target_count);
let mut idx = 0; let mut idx = 0;
@@ -287,11 +488,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
positions positions
} }
/// Select embed blocks using block-coordinate indices relative to the embed region.
///
/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices
/// rather than absolute pixel positions. Used during embedding where block
/// coordinates are more convenient for the read_block/write_block API.
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> { fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
let total_blocks = region.blocks_x * region.blocks_y; let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks == 0 || target_count == 0 { if total_blocks == 0 || target_count == 0 {
return Vec::new(); return Vec::new();
} }
// Even stride distributes blocks uniformly across the region
let stride = (total_blocks / target_count).max(1); let stride = (total_blocks / target_count).max(1);
let mut blocks = Vec::with_capacity(target_count); let mut blocks = Vec::with_capacity(target_count);
let mut idx = 0; let mut idx = 0;
@@ -306,6 +513,17 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
// ─── Reconstruct JPEG ──────────────────────────────────────────────────────── // ─── Reconstruct JPEG ────────────────────────────────────────────────────────
/// Reconstruct a JPEG image after modifying its luminance channel.
///
/// This function takes the original JPEG (for its Cb/Cr chrominance data) and
/// the modified Y channel, then:
///
/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values.
/// 2. For each pixel, combines the modified Y with the original Cb/Cr.
/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula.
/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark).
///
/// Only the luminance changes; chrominance is preserved from the original.
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> { fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg)) let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format() .with_guessed_format()
@@ -325,12 +543,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
let g = orig[1] as f64; let g = orig[1] as f64;
let b = orig[2] as f64; let b = orig[2] as f64;
// Extract Cb and Cr from the original pixel (we only modify Y)
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b; let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0; let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0; let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
// Use the modified Y value from our watermarked luminance channel
let y_new = y_modified.get(px as usize, py as usize); let y_new = y_modified.get(px as usize, py as usize);
// Convert YCbCr -> RGB using ITU-R BT.601 inverse
let r_new = y_new + 1.402 * (cr - 128.0); let r_new = y_new + 1.402 * (cr - 128.0);
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0); let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
let b_new = y_new + 1.772 * (cb - 128.0); let b_new = y_new + 1.772 * (cb - 128.0);
@@ -357,7 +578,28 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
// ─── Public API ────────────────────────────────────────────────────────────── // ─── Public API ──────────────────────────────────────────────────────────────
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes. /// Embed a 256-bit secret into a carrier JPEG image.
///
/// Returns the modified JPEG bytes with the secret hidden in the luminance
/// channel's mid-frequency DCT coefficients.
///
/// ## Pipeline
///
/// 1. Decode the carrier and extract the Y (luminance) channel.
/// 2. Validate that the image is large enough (>= 100x100 pixels, and enough
/// blocks in the central region for at least 5 redundant copies).
/// 3. Compute how many copies fit (up to 50) and select evenly-spaced blocks.
/// 4. For each copy, iterate through the 22 blocks that hold 256 bits:
/// - Forward DCT the 8x8 block.
/// - Embed 12 bits per block into the mid-frequency coefficients via QIM.
/// - Inverse DCT to write the modified spatial-domain values back.
/// 5. Reconstruct the JPEG with the modified Y channel and original Cb/Cr.
///
/// # Errors
///
/// - [`IdfotoError::ImageTooSmall`] if the image is below minimum dimensions
/// or does not have enough blocks for reliable embedding.
/// - [`IdfotoError::ImgSecret`] if the image cannot be decoded or re-encoded.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> { pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let mut y = extract_y_channel(carrier_jpeg)?; let mut y = extract_y_channel(carrier_jpeg)?;
@@ -382,12 +624,15 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
}); });
} }
// Cap at 50 copies -- beyond that, additional redundancy has diminishing
// returns and the image modification becomes more visible.
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let bits = bytes_to_bits(secret); let bits = bytes_to_bits(secret);
let blocks_needed = num_copies * BLOCKS_PER_COPY; let blocks_needed = num_copies * BLOCKS_PER_COPY;
let embed_blocks = select_embed_blocks(&region, blocks_needed); let embed_blocks = select_embed_blocks(&region, blocks_needed);
// Embed each copy of the secret into its assigned blocks
for copy in 0..num_copies { for copy in 0..num_copies {
for block_idx in 0..BLOCKS_PER_COPY { for block_idx in 0..BLOCKS_PER_COPY {
let global_idx = copy * BLOCKS_PER_COPY + block_idx; let global_idx = copy * BLOCKS_PER_COPY + block_idx;
@@ -398,6 +643,8 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let mut block = read_block(&y, bx, by, &region); let mut block = read_block(&y, bx, by, &region);
let mut dct = dct2_8x8(&block); let mut dct = dct2_8x8(&block);
// Embed up to 12 bits (BITS_PER_BLOCK) in this block's
// mid-frequency DCT coefficients
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx; let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS { if bit_idx >= SECRET_BITS {
@@ -414,14 +661,31 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
reconstruct_jpeg(carrier_jpeg, &y) reconstruct_jpeg(carrier_jpeg, &y)
} }
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. /// Extract a 256-bit secret from a (possibly re-encoded or mildly cropped) JPEG.
///
/// Delegates to [`extract_with_crop_recovery`] which first tries canonical
/// extraction (assuming the image has its original dimensions), then falls back
/// to searching for plausible original dimensions if the image was cropped.
///
/// # Errors
///
/// - [`IdfotoError::ExtractionFailed`] if no valid secret could be recovered
/// (image was never watermarked, or was too heavily recompressed/cropped).
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
extract_with_crop_recovery(jpeg_bytes) extract_with_crop_recovery(jpeg_bytes)
} }
/// Try to extract using a specific assumed original image size and pixel offset. /// Attempt to extract the secret assuming specific original image dimensions
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies). /// and a pixel offset (for crop recovery).
/// `dx`/`dy` shift all block positions when reading from the actual image. ///
/// The block grid is computed based on `orig_w`/`orig_h` (the assumed original
/// dimensions), and then each block position is shifted by `dx`/`dy` when
/// reading from the actual (possibly cropped) image.
///
/// Uses majority voting across all copies: for each of the 256 bit positions,
/// the extracted bit from every copy votes, and the majority wins. A minimum
/// confidence threshold of 60% is required -- below that, the extraction is
/// considered unreliable and fails.
fn try_extract_with_layout( fn try_extract_with_layout(
y: &YChannel, y: &YChannel,
orig_w: usize, orig_w: usize,
@@ -438,6 +702,7 @@ fn try_extract_with_layout(
let total_blocks = region.blocks_x * region.blocks_y; let total_blocks = region.blocks_x * region.blocks_y;
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
// Accumulate votes for each bit position across all copies
let mut votes_one = vec![0usize; SECRET_BITS]; let mut votes_one = vec![0usize; SECRET_BITS];
let mut votes_total = vec![0usize; SECRET_BITS]; let mut votes_total = vec![0usize; SECRET_BITS];
@@ -447,6 +712,8 @@ fn try_extract_with_layout(
if global_idx >= positions.len() { if global_idx >= positions.len() {
break; break;
} }
// Apply crop offset to find the actual block position in the
// (possibly cropped) image
let (orig_px, orig_py) = positions[global_idx]; let (orig_px, orig_py) = positions[global_idx];
let actual_px = orig_px as isize + dx; let actual_px = orig_px as isize + dx;
let actual_py = orig_py as isize + dy; let actual_py = orig_py as isize + dy;
@@ -462,6 +729,7 @@ fn try_extract_with_layout(
}; };
let dct = dct2_8x8(&block); let dct = dct2_8x8(&block);
// Extract bits from mid-frequency coefficients and tally votes
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx; let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS { if bit_idx >= SECRET_BITS {
@@ -476,7 +744,9 @@ fn try_extract_with_layout(
} }
} }
// Majority vote with confidence check // Majority vote with confidence check: each bit must have >= 60% agreement
// across copies. Below that threshold, the watermark is considered too
// degraded for reliable extraction.
let mut result_bits = vec![0u8; SECRET_BITS]; let mut result_bits = vec![0u8; SECRET_BITS];
for i in 0..SECRET_BITS { for i in 0..SECRET_BITS {
if votes_total[i] == 0 { if votes_total[i] == 0 {
@@ -498,6 +768,19 @@ fn try_extract_with_layout(
Ok(secret) Ok(secret)
} }
/// Extract with automatic crop recovery.
///
/// Tries extraction in order of decreasing likelihood:
///
/// 1. **Uncropped**: assume the image has its original dimensions (most common case).
/// 2. **Width-only crop (8-pixel aligned)**: try original widths from current up to
/// +20%, stepping by 8 pixels (JPEG block alignment). Assumes right-side crop
/// (left edge unchanged, dx=0).
/// 3. **Height-only crop (8-pixel aligned)**: same strategy for vertical crops.
/// 4. **Width crop (non-aligned)**: finer 1-pixel step for non-block-aligned crops.
///
/// The search space is limited to 20% expansion in each dimension, which covers
/// the 15% crumple zone plus some margin for measurement error.
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let y = extract_y_channel(jpeg_bytes)?; let y = extract_y_channel(jpeg_bytes)?;
@@ -505,7 +788,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
return Err(IdfotoError::ExtractionFailed); return Err(IdfotoError::ExtractionFailed);
} }
// Try assuming the image is uncropped (original size = current size) // Try 1: assume the image is uncropped (original size = current size)
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) { if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
return Ok(secret); return Ok(secret);
} }
@@ -522,7 +805,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let max_orig_w = (y.width as f64 * 1.20) as usize; let max_orig_w = (y.width as f64 * 1.20) as usize;
let max_orig_h = (y.height as f64 * 1.20) as usize; let max_orig_h = (y.height as f64 * 1.20) as usize;
// Try width-only crops first (most common: crop from one side) // Try 2: width-only crops, block-aligned steps (most common crop scenario)
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) { for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
// Right-side crop: dx = 0 (left edge unchanged) // Right-side crop: dx = 0 (left edge unchanged)
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) { if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
@@ -530,17 +813,17 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
} }
} }
// Try height-only crops // Try 3: height-only crops, block-aligned steps
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) { for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) { if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
return Ok(secret); return Ok(secret);
} }
} }
// Try width crops with finer step (non-8-aligned crops) // Try 4: width crops with finer step (non-8-aligned crops are rarer but possible)
for orig_w in (y.width..=max_orig_w).step_by(1) { for orig_w in (y.width..=max_orig_w).step_by(1) {
if orig_w % BLOCK_SIZE == 0 { if orig_w % BLOCK_SIZE == 0 {
continue; // already tried continue; // already tried in step 2
} }
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) { if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
return Ok(secret); return Ok(secret);

View File

@@ -1,3 +1,37 @@
//! # idfoto-core
//!
//! Platform-agnostic core library for the idfoto password manager.
//!
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
//! without any conditional compilation or platform shims.
//!
//! ## Modules
//!
//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate.
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
//! index that lets you list/search without decrypting every entry).
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
//! before encrypting, and deserialize after decrypting.
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
//! 256-bit secret in a JPEG image. This is the novel component that provides the
//! second authentication factor.
//!
//! ## Crypto pipeline
//!
//! ```text
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
//! -> master_key (32 bytes)
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
//! -> encrypted entry/manifest
//! ```
pub mod error; pub mod error;
pub use error::{IdfotoError, Result}; pub use error::{IdfotoError, Result};

View File

@@ -1,23 +1,72 @@
//! Typed encryption/decryption wrappers for vault entries and manifests.
//!
//! This module bridges the gap between the raw bytes-in/bytes-out layer in
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function
//! follows the same pattern:
//!
//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON
//! bytes with [`crate::crypto::encrypt`].
//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then
//! deserialize the resulting JSON bytes back into the typed struct.
//!
//! ## Why a single master key
//!
//! All entries and the manifest are encrypted under the same `master_key`. This is
//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults
//! (typically < 1000 entries). The security properties are equivalent: an attacker
//! who compromises the master key can decrypt everything regardless of whether
//! subkeys exist, and the vault's threat model already assumes the master key is
//! the single point of trust (protected by the two-factor KDF).
use crate::crypto; use crate::crypto;
use crate::entry::{Entry, Manifest}; use crate::entry::{Entry, Manifest};
use crate::error::Result; use crate::error::Result;
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `entries/<id>.enc` by the CLI.
///
/// # Errors
///
/// - [`crate::IdfotoError::Json`] if JSON serialization fails (should not happen
/// with well-formed Entry structs).
/// - [`crate::IdfotoError::Encrypt`] if the underlying AEAD operation fails.
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> { pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
let json = serde_json::to_vec(entry)?; let json = serde_json::to_vec(entry)?;
crypto::encrypt(master_key, &json) crypto::encrypt(master_key, &json)
} }
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
///
/// # Errors
///
/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is
/// tampered.
/// - [`crate::IdfotoError::Format`] if the ciphertext blob has an invalid header.
/// - [`crate::IdfotoError::Json`] if the decrypted JSON is malformed.
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> { pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
let json = crypto::decrypt(master_key, data)?; let json = crypto::decrypt(master_key, data)?;
let entry: Entry = serde_json::from_slice(&json)?; let entry: Entry = serde_json::from_slice(&json)?;
Ok(entry) Ok(entry)
} }
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `manifest.enc` by the CLI.
///
/// # Errors
///
/// Same as [`encrypt_entry`].
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> { pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?; let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json) crypto::encrypt(master_key, &json)
} }
/// Decrypt a manifest blob and deserialize it back into a [`Manifest`].
///
/// # Errors
///
/// Same as [`decrypt_entry`].
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> { pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?; let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?; let manifest: Manifest = serde_json::from_slice(&json)?;
@@ -45,6 +94,7 @@ mod tests {
password: "secret123".to_string(), password: "secret123".to_string(),
notes: None, notes: None,
totp_secret: None, totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
} }
@@ -73,6 +123,7 @@ mod tests {
name: "GitHub".to_string(), name: "GitHub".to_string(),
url: Some("https://github.com".to_string()), url: Some("https://github.com".to_string()),
username: Some("alice".to_string()), username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}, },
); );

View File

@@ -59,6 +59,7 @@ fn full_vault_workflow() {
password: "supersecret123!".to_string(), password: "supersecret123!".to_string(),
notes: Some("my main account".to_string()), notes: Some("my main account".to_string()),
totp_secret: None, totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}; };
@@ -102,6 +103,7 @@ fn full_vault_workflow() {
name: "GitHub".to_string(), name: "GitHub".to_string(),
url: Some("https://github.com".to_string()), url: Some("https://github.com".to_string()),
username: Some("alice".to_string()), username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(),
}, },
); );

View File

@@ -0,0 +1,22 @@
[package]
name = "idfoto-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for idfoto password manager"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
idfoto-core = { path = "../idfoto-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }

View File

@@ -0,0 +1,364 @@
//! WASM bindings for the idfoto password manager.
//!
//! This crate wraps [`idfoto_core`] for use in a Chrome MV3 browser extension via
//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from
//! JavaScript after loading the compiled `.wasm` module.
//!
//! All crypto operations run entirely in the browser -- the extension never sends
//! secrets to any server. The TOTP function lets the extension generate live 6-digit
//! authenticator codes without a separate authenticator app.
//!
//! ## Design notes
//!
//! - Functions accept and return `Vec<u8>`, `&[u8]`, and `String` -- wasm-bindgen
//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings
//! for JSON).
//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS.
//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because
//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is
//! sufficient for these non-security-critical operations (password character selection
//! and identifier generation).
use wasm_bindgen::prelude::*;
use idfoto_core::crypto::{self, KdfParams};
use idfoto_core::entry::Entry;
use idfoto_core::vault;
use idfoto_core::imgsecret;
use hmac::{Hmac, Mac};
use sha1::Sha1;
/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters.
///
/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`,
/// and `argon2_p` (matching [`KdfParams`]). Example:
///
/// ```json
/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4}
/// ```
///
/// Returns a 32-byte `Uint8Array` in JavaScript.
#[wasm_bindgen]
pub fn derive_master_key(
passphrase: &str,
image_secret: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<Vec<u8>, JsValue> {
let params: KdfParams =
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let image_secret: &[u8; 32] = image_secret
.try_into()
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
let salt: &[u8; 32] = salt
.try_into()
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, &params)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(key.to_vec())
}
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the ciphertext as a `Uint8Array` in the format:
/// `version(1) || nonce(24) || ciphertext+tag`.
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext.
///
/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data
/// has been tampered with.
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Extract the 32-byte steganographic secret from a JPEG image.
///
/// Returns a 32-byte `Uint8Array` containing the embedded secret.
/// Throws if the image is not a valid JPEG or the secret cannot be recovered.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret =
imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(secret.to_vec())
}
/// Embed a 256-bit secret into a carrier JPEG image.
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
idfoto_core::imgsecret::embed(carrier_jpeg, &secret)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Encrypt an [`Entry`] (given as a JSON string) under the master key.
///
/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the
/// ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry: Entry =
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt an entry ciphertext blob and return the entry as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry =
vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Encrypt a [`Manifest`] (given as a JSON string) under the master key.
///
/// Returns the ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest: idfoto_core::entry::Manifest =
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest = vault::decrypt_manifest(key, ciphertext)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Generate a 6-digit TOTP code per RFC 6238.
///
/// # Arguments
///
/// - `secret_base32`: the shared secret encoded in base32 (with or without padding).
/// - `timestamp_secs`: the current Unix timestamp in seconds.
///
/// # Algorithm
///
/// 1. Decode the base32 secret.
/// 2. Compute the time step: `T = timestamp_secs / 30`.
/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`.
/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an
/// offset determined by the last nibble.
/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits.
///
/// Returns a 6-character string like `"287082"`.
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
generate_totp_inner(secret_base32, timestamp_secs)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Inner TOTP implementation that returns a standard Result for testability
/// (avoids depending on JsValue in native tests).
fn generate_totp_inner(
secret_base32: &str,
timestamp_secs: u64,
) -> std::result::Result<String, String> {
// Normalize: strip whitespace, uppercase, remove padding for lenient decode,
// then re-pad to a multiple of 8 for strict base32.
let cleaned: String = secret_base32
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_uppercase()
.trim_end_matches('=')
.to_string();
// Re-pad to a multiple of 8 characters (base32 requirement).
let padded = {
let remainder = cleaned.len() % 8;
if remainder == 0 {
cleaned
} else {
let pad_count = 8 - remainder;
format!("{}{}", cleaned, "=".repeat(pad_count))
}
};
let secret = data_encoding::BASE32
.decode(padded.as_bytes())
.map_err(|e| format!("invalid base32 secret: {}", e))?;
// Time step: T = floor(timestamp / 30)
let time_step = timestamp_secs / 30;
// HMAC-SHA1(secret, time_step as big-endian u64)
type HmacSha1 = Hmac<Sha1>;
let mut mac =
HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?;
mac.update(&time_step.to_be_bytes());
let result = mac.finalize().into_bytes();
// Dynamic truncation per RFC 4226 section 5.4
let offset = (result[19] & 0x0F) as usize;
let code = ((result[offset] as u32 & 0x7F) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
// 6-digit code, zero-padded
Ok(format!("{:06}", code % 1_000_000))
}
/// Generate a random password of the given length.
///
/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure,
/// but sufficient for password character selection). The character set includes
/// uppercase, lowercase, digits, and common symbols.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
(0..length)
.map(|_| {
let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize;
CHARSET[idx % CHARSET.len()] as char
})
.collect()
}
/// Generate a random 8-character hex string for use as an entry ID.
///
/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not
/// security-sensitive -- they are just opaque identifiers.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_entry_id() -> String {
(0..4)
.map(|_| {
let byte = (js_sys::Math::random() * 256.0) as u8;
format!("{:02x}", byte)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_rfc6238_test_vector() {
// secret = "12345678901234567890" ASCII, time = 59, expected = "287082"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 59).unwrap();
assert_eq!(result, "287082");
}
#[test]
fn totp_rfc6238_test_vector_2() {
// time = 1111111109, expected = "081804"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1111111109).unwrap();
assert_eq!(result, "081804");
}
#[test]
fn totp_rfc6238_test_vector_3() {
// time = 1234567890, expected = "005924"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1234567890).unwrap();
assert_eq!(result, "005924");
}
#[test]
fn totp_invalid_base32_fails() {
let result = generate_totp_inner("not-valid-base32!!!", 1000);
assert!(result.is_err());
}
#[test]
fn derive_key_via_wasm_wrapper() {
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
let key =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key.len(), 32);
let key2 =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key, key2);
}
#[test]
fn encrypt_decrypt_via_wasm_wrapper() {
let key = [0xABu8; 32];
let ciphertext = encrypt(b"hello wasm", &key).unwrap();
let decrypted = decrypt(&ciphertext, &key).unwrap();
assert_eq!(decrypted, b"hello wasm");
}
#[test]
fn embed_then_extract_round_trip() {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(400, 300, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut jpeg_buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap();
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
let extracted = extract_image_secret(&stego).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn encrypt_entry_decrypt_entry_round_trip() {
let key = [0xABu8; 32];
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
let result = decrypt_entry(&ciphertext, &key).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["name"], "Test");
assert_eq!(parsed["password"], "secret");
}
}

View File

@@ -0,0 +1,845 @@
# idfoto Credential Capture Implementation Plan
> **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:** Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist.
**Architecture:** Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default.
**Tech Stack:** TypeScript, Chrome extension APIs, DOM injection
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-credential-capture-design.md`
---
## File Structure
### New files
```
extension/src/content/capture.ts # Form submission detection + prompt injection
extension/src/popup/components/settings.ts # Settings view
```
### Modified files
```
extension/src/shared/types.ts # Add IdfotoSettings interface
extension/src/shared/messages.ts # Add new message types
extension/src/service-worker/index.ts # Handle new messages
extension/src/content/detector.ts # Import and init capture
extension/src/popup/popup.ts # Add 'settings' view
extension/src/popup/components/unlock.ts # Wire settings button to settings view
```
---
## Task 1: Add Types and Message Definitions
**Files:**
- Modify: `extension/src/shared/types.ts`
- Modify: `extension/src/shared/messages.ts`
- [ ] **Step 1: Add IdfotoSettings to types.ts**
Add at the end of `extension/src/shared/types.ts`:
```typescript
export interface IdfotoSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: IdfotoSettings = {
captureEnabled: false,
captureStyle: 'bar',
};
```
- [ ] **Step 2: Add new message types to messages.ts**
Add these to the `Request` union in `extension/src/shared/messages.ts`:
```typescript
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').IdfotoSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
```
- [ ] **Step 3: Commit**
```bash
git add extension/src/shared/types.ts extension/src/shared/messages.ts
git commit -m "feat: add settings and credential capture message types"
```
---
## Task 2: Service Worker Message Handlers
**Files:**
- Modify: `extension/src/service-worker/index.ts`
- [ ] **Step 1: Add settings and blacklist storage helpers**
Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers:
```typescript
import type { IdfotoSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
async function loadSettings(): Promise<IdfotoSettings> {
const data = await chrome.storage.local.get(['settings']);
if (!data.settings) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...data.settings };
}
async function saveSettings(settings: IdfotoSettings): Promise<void> {
await chrome.storage.local.set({ settings });
}
async function loadBlacklist(): Promise<string[]> {
const data = await chrome.storage.local.get(['captureBlacklist']);
return data.captureBlacklist ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
```
- [ ] **Step 2: Add message handlers**
Add these cases to the `switch` statement in the message handler, before the `default` case:
```typescript
case 'get_settings': {
const settings = await loadSettings();
return { ok: true, data: settings };
}
case 'update_settings': {
const current = await loadSettings();
const updated = { ...current, ...req.settings };
await saveSettings(updated);
return { ok: true };
}
case 'get_blacklist': {
const list = await loadBlacklist();
return { ok: true, data: { blacklist: list } };
}
case 'remove_blacklist': {
const list = await loadBlacklist();
await saveBlacklist(list.filter(h => h !== req.hostname));
return { ok: true };
}
case 'blacklist_site': {
const list = await loadBlacklist();
if (!list.includes(req.hostname)) {
list.push(req.hostname);
await saveBlacklist(list);
}
return { ok: true };
}
case 'check_credential': {
// If vault is locked, skip
if (!masterKey || !gitHost || !manifest) {
return { ok: true, data: { action: 'skip' } };
}
// Check settings
const settings = await loadSettings();
if (!settings.captureEnabled) {
return { ok: true, data: { action: 'skip' } };
}
// Check blacklist
let hostname: string;
try {
hostname = new URL(req.url).hostname;
} catch {
return { ok: true, data: { action: 'skip' } };
}
const blacklist = await loadBlacklist();
if (blacklist.includes(hostname)) {
return { ok: true, data: { action: 'skip' } };
}
// Find matching entries by hostname
const matches = vault.findByUrl(manifest, req.url);
if (matches.length === 0) {
return { ok: true, data: { action: 'save' } };
}
// Check if any match has the same username
for (const [id, entry] of matches) {
if (entry.username === req.username) {
// Same username — check if password changed
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id);
if (fullEntry.password === req.password) {
// Exact match, already saved
return { ok: true, data: { action: 'skip' } };
} else {
// Password changed
return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } };
}
}
}
// Different username on same site — new account
return { ok: true, data: { action: 'save' } };
}
```
- [ ] **Step 3: Verify build**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/service-worker/index.ts
git commit -m "feat: add settings, blacklist, and credential check handlers"
```
---
## Task 3: Credential Capture Content Script
**Files:**
- Create: `extension/src/content/capture.ts`
- Modify: `extension/src/content/detector.ts`
- [ ] **Step 1: Create capture.ts**
Create `extension/src/content/capture.ts`:
```typescript
/// Credential capture — detects login form submissions and prompts
/// the user to save or update credentials in the vault.
///
/// This module hooks form submit events and submit button clicks,
/// captures username + password, asks the service worker whether to
/// save/update/skip, and injects a prompt (bar or toast) into the page.
// --- Types ---
interface CaptureResult {
action: 'save' | 'update' | 'skip';
entryId?: string;
entryName?: string;
}
type PromptStyle = 'bar' | 'toast';
// Track forms we've already hooked to avoid duplicates.
const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();
// --- Form Submission Detection ---
/// Find the username field associated with a password field.
/// Same priority as detector.ts but inlined to avoid circular deps.
function findUsername(pwField: HTMLInputElement): string {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// autocomplete="username"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value;
}
// autocomplete="email"
for (const input of inputs) {
if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value;
}
// type="email"
for (const input of inputs) {
if (input !== pwField && input.type === 'email' && input.value) return input.value;
}
// name/id pattern
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
}
// Nearest preceding visible text input
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
}
return '';
}
/// Capture credentials from a form that contains a password field.
function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null {
const pwField = form.querySelector<HTMLInputElement>('input[type="password"]');
if (!pwField || !pwField.value) return null;
const username = findUsername(pwField);
return { username, password: pwField.value };
}
/// Handle a form submission — capture credentials and check with service worker.
async function handleSubmission(form: HTMLFormElement): Promise<void> {
const creds = captureFromForm(form);
if (!creds || !creds.password) return;
const url = window.location.href;
try {
const response = await chrome.runtime.sendMessage({
type: 'check_credential',
url,
username: creds.username,
password: creds.password,
});
if (!response?.ok) return;
const result = response.data as CaptureResult;
if (result.action === 'skip') return;
// Get the prompt style from settings
const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' });
const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar';
showPrompt(style, result, url, creds.username, creds.password);
} catch {
// Extension not available or vault locked — silently skip
}
}
/// Hook form submit events and submit button clicks.
export function hookForms(): void {
const forms = document.querySelectorAll<HTMLFormElement>('form');
for (const form of forms) {
// Only hook forms that contain a password field.
if (!form.querySelector('input[type="password"]')) continue;
if (hookedForms.has(form)) continue;
hookedForms.add(form);
// Hook form submit event.
form.addEventListener('submit', () => {
handleSubmission(form);
});
// Hook submit button clicks (some sites don't use form submit).
const submitBtns = form.querySelectorAll<HTMLElement>(
'button[type="submit"], input[type="submit"], button:not([type])'
);
for (const btn of submitBtns) {
if (hookedButtons.has(btn)) continue;
hookedButtons.add(btn);
btn.addEventListener('click', () => {
handleSubmission(form);
});
}
}
}
// --- Prompt UI ---
/// Remove any existing idfoto prompt from the page.
function removePrompt(): void {
document.getElementById('idfoto-capture-prompt')?.remove();
}
/// Show a save/update prompt.
function showPrompt(
style: PromptStyle,
result: CaptureResult,
url: string,
username: string,
password: string,
): void {
removePrompt();
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
hostname = url;
}
const isUpdate = result.action === 'update';
const actionLabel = isUpdate ? 'Update' : 'Save';
const message = isUpdate
? `Update password for ${hostname}?`
: `Save login for ${hostname}?`;
const container = document.createElement('div');
container.id = 'idfoto-capture-prompt';
// Common styles
const baseStyles = `
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
color: #c9d1d9;
background: #161b22;
border: 1px solid #30363d;
z-index: 2147483647;
box-sizing: border-box;
`;
if (style === 'bar') {
container.style.cssText = `
${baseStyles}
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-top: none;
border-left: none;
border-right: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transform: translateY(-100%);
transition: transform 0.2s ease-out;
`;
// Slide in after a frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
container.style.transform = 'translateY(0)';
});
});
} else {
container.style.cssText = `
${baseStyles}
position: fixed;
bottom: 16px;
right: 16px;
padding: 12px 16px;
border-radius: 4px;
min-width: 260px;
max-width: 340px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.2s ease-out;
`;
requestAnimationFrame(() => {
container.style.opacity = '1';
});
// Auto-dismiss after 15 seconds.
setTimeout(() => {
if (container.isConnected) {
container.style.opacity = '0';
setTimeout(removePrompt, 200);
}
}, 15000);
}
// Brand label
const brand = document.createElement('span');
brand.textContent = 'idfoto';
brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;';
// Message text
const msg = document.createElement('span');
msg.style.cssText = 'flex: 1;';
if (style === 'bar') {
msg.textContent = `${message} ${username ? `(${username})` : ''}`;
} else {
msg.innerHTML = `${escapeHtml(message)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
}
// Buttons
const btnStyle = `
font-family: inherit;
font-size: 11px;
padding: 4px 12px;
border-radius: 2px;
cursor: pointer;
border: none;
`;
const saveBtn = document.createElement('button');
saveBtn.textContent = actionLabel;
saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`;
const neverBtn = document.createElement('button');
neverBtn.textContent = 'Never';
neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`;
// Event handlers
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.textContent = '...';
try {
if (isUpdate && result.entryId) {
// Fetch existing entry, update password
const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId });
if (getResp?.ok) {
const existing = (getResp.data as { entry: Record<string, unknown> }).entry;
await chrome.runtime.sendMessage({
type: 'update_entry',
id: result.entryId,
entry: { ...existing, password },
});
}
} else {
await chrome.runtime.sendMessage({
type: 'add_entry',
entry: {
name: hostname,
url,
username: username || undefined,
password,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
});
}
// Brief confirmation
msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved';
saveBtn.remove();
neverBtn.remove();
setTimeout(removePrompt, 1500);
} catch {
saveBtn.textContent = 'Error';
setTimeout(removePrompt, 2000);
}
});
neverBtn.addEventListener('click', async () => {
await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname });
removePrompt();
});
closeBtn.addEventListener('click', removePrompt);
// Assemble
if (style === 'bar') {
container.appendChild(brand);
container.appendChild(msg);
container.appendChild(saveBtn);
container.appendChild(neverBtn);
container.appendChild(closeBtn);
} else {
const header = document.createElement('div');
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;';
header.appendChild(brand);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'margin-bottom:10px;';
body.appendChild(msg);
const actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
actions.appendChild(neverBtn);
actions.appendChild(saveBtn);
container.appendChild(header);
container.appendChild(body);
container.appendChild(actions);
}
document.body.appendChild(container);
}
function escapeHtml(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
```
- [ ] **Step 2: Import and init capture in detector.ts**
Add to the top of `extension/src/content/detector.ts`, after the existing imports:
```typescript
import { hookForms } from './capture';
```
Add `hookForms()` calls in two places:
After the `scan()` call near the bottom (line ~91):
```typescript
// Initial scan.
scan();
hookForms();
```
Inside the MutationObserver callback (line ~95):
```typescript
const observer = new MutationObserver(() => {
scan();
hookForms();
});
```
- [ ] **Step 3: Build and verify**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/content/capture.ts extension/src/content/detector.ts
git commit -m "feat: add credential capture with bar/toast prompts"
```
---
## Task 4: Settings View in Popup
**Files:**
- Create: `extension/src/popup/components/settings.ts`
- Modify: `extension/src/popup/popup.ts`
- Modify: `extension/src/popup/components/unlock.ts`
- [ ] **Step 1: Create settings.ts**
Create `extension/src/popup/components/settings.ts`:
```typescript
/// Settings view — configure credential capture and manage blacklist.
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { IdfotoSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
// Load current settings and blacklist in parallel.
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
]);
const settings: IdfotoSettings = settingsResp.ok
? settingsResp.data as IdfotoSettings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="margin-bottom:16px;">
<span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
</div>
<div class="brand" style="margin-bottom:16px;">settings</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
<input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
</div>
</div>
<div class="form-group" style="margin-bottom:16px;">
<div class="label">PROMPT STYLE</div>
<div style="display:flex;gap:8px;margin-top:6px;">
<button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
<button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
</div>
</div>
${blacklist.length > 0 ? `
<div class="form-group">
<div class="label">BLACKLISTED SITES</div>
<div style="margin-top:6px;">
${blacklist.map(h => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
<span class="secondary">${escapeHtml(h)}</span>
<span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
// --- Event listeners ---
document.getElementById('back-btn')?.addEventListener('click', () => {
navigate('locked');
});
document.getElementById('capture-toggle')?.addEventListener('change', async (e) => {
const enabled = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
});
document.querySelectorAll('[data-style]').forEach(btn => {
btn.addEventListener('click', async () => {
const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast';
await sendMessage({ type: 'update_settings', settings: { captureStyle: style } });
// Re-render to update active state.
renderSettings(app);
});
});
document.querySelectorAll('[data-remove-host]').forEach(btn => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.removeHost!;
await sendMessage({ type: 'remove_blacklist', hostname });
// Re-render to remove from list.
renderSettings(app);
});
});
}
```
- [ ] **Step 2: Add 'settings' view to popup.ts**
In `extension/src/popup/popup.ts`:
Add the import at the top with the other component imports:
```typescript
import { renderSettings } from './components/settings';
```
Update the `View` type:
```typescript
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
```
Add the case to the `render()` switch:
```typescript
case 'settings':
renderSettings(app);
break;
```
- [ ] **Step 3: Wire settings button in unlock.ts**
In `extension/src/popup/components/unlock.ts`, change the settings button handler (line ~55):
From:
```typescript
settingsBtn?.addEventListener('click', () => navigate('setup'));
```
To:
```typescript
settingsBtn?.addEventListener('click', () => navigate('settings'));
```
- [ ] **Step 4: Build and verify**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors.
- [ ] **Step 5: Commit**
```bash
git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts
git commit -m "feat: add settings view with capture toggle and blacklist management"
```
---
## Task 5: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Full build**
```bash
cd extension && bun run build
```
Expected: Compiles with no errors (warnings about WASM size are fine).
- [ ] **Step 2: Reload extension in Chrome**
Open `chrome://extensions/`, reload the unpacked extension.
- [ ] **Step 3: Test settings view**
1. Open popup → click "settings" from unlock screen
2. Verify toggle for "auto-detect logins" (should be off by default)
3. Toggle it on
4. Verify bar/toast style selector works
5. Go back to unlock screen
- [ ] **Step 4: Test credential capture (bar mode)**
1. Enable capture in settings, set style to "bar"
2. Unlock the vault
3. Navigate to a login page (e.g. GitHub)
4. Enter credentials and submit the form
5. Verify: notification bar slides down from top with "Save login for github.com?"
6. Click "Save" — verify entry appears in vault
7. Submit same credentials again — verify no prompt (already saved)
8. Change password and submit — verify "Update password?" prompt
- [ ] **Step 5: Test credential capture (toast mode)**
1. Change style to "toast" in settings
2. Submit a login form on a new site
3. Verify: floating toast appears in bottom-right
4. Verify: auto-dismisses after ~15 seconds if ignored
- [ ] **Step 6: Test blacklist**
1. Click "Never" on a capture prompt
2. Submit login on same site again — verify no prompt
3. Open settings — verify site appears in blacklist
4. Remove site from blacklist — verify it's gone
- [ ] **Step 7: Fix any issues found**
- [ ] **Step 8: Final commit**
```bash
git add -A
git commit -m "feat: complete credential capture feature"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Add types and message definitions | None |
| 2 | Service worker message handlers | Task 1 |
| 3 | Capture content script + detector integration | Task 1 |
| 4 | Settings view in popup | Task 1 |
| 5 | Build and manual test | All |
Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.

View File

@@ -0,0 +1,323 @@
# idfoto Firefox Extension Port Implementation Plan
> **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:** Port the Chrome extension to Firefox with shared TypeScript source, a Firefox-specific manifest, and a separate webpack build target.
**Architecture:** All TypeScript source is shared. The only code change is an environment check in `index.ts` for WASM loading (service worker vs background script). A second webpack config produces `dist-firefox/` with the Firefox manifest.
**Tech Stack:** TypeScript, webpack, Firefox WebExtensions MV3
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-firefox-extension-design.md`
---
## File Structure
### New files
```
extension/manifest.firefox.json # Firefox-specific manifest
extension/webpack.firefox.config.js # Webpack config for Firefox build
```
### Modified files
```
extension/src/service-worker/index.ts # Environment-aware WASM loading
extension/package.json # Add Firefox build scripts
.gitignore # Add extension/dist-firefox/
```
---
## Task 1: Firefox Manifest and Webpack Config
**Files:**
- Create: `extension/manifest.firefox.json`
- Create: `extension/webpack.firefox.config.js`
- Modify: `extension/package.json`
- Modify: `.gitignore`
- [ ] **Step 1: Create Firefox manifest**
Create `extension/manifest.firefox.json`:
```json
{
"manifest_version": 3,
"name": "idfoto",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"strict_min_version": "128.0"
}
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["service-worker.js"]
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [
{
"resources": [
"setup.html",
"setup.js",
"styles.css",
"idfoto_wasm_bg.wasm",
"idfoto_wasm.js"
]
}
]
}
```
- [ ] **Step 2: Create Firefox webpack config**
Create `extension/webpack.firefox.config.js`:
```javascript
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
output: {
path: path.resolve(__dirname, 'dist-firefox'),
filename: '[name].js',
clean: true,
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'manifest.firefox.json', to: 'manifest.json' },
{ from: 'src/popup/index.html', to: 'popup.html' },
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
],
}),
],
experiments: { asyncWebAssembly: true },
};
```
- [ ] **Step 3: Add Firefox build scripts to package.json**
In `extension/package.json`, update the `scripts` section:
```json
{
"scripts": {
"build": "webpack --mode production",
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
}
}
```
- [ ] **Step 4: Add `dist-firefox/` to `.gitignore`**
Append to the root `.gitignore`:
```
extension/dist-firefox/
```
- [ ] **Step 5: Commit**
```bash
git add extension/manifest.firefox.json extension/webpack.firefox.config.js extension/package.json .gitignore
git commit -m "feat: add Firefox manifest and webpack config"
```
---
## Task 2: Environment-Aware WASM Loading
**Files:**
- Modify: `extension/src/service-worker/index.ts`
- [ ] **Step 1: Update the WASM init function**
In `extension/src/service-worker/index.ts`, replace the current `initWasm` function and its surrounding comments (lines 23-49) with:
```typescript
// --- WASM initialization ---
// Chrome MV3 uses service workers which do NOT support dynamic import().
// Firefox MV3 uses background scripts which DO support dynamic import().
// We detect the environment at runtime and use the appropriate loading strategy.
//
// The JS glue is imported statically so webpack bundles it. Both initSync
// (Chrome) and the default export (Firefox) are available.
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/idfoto_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/idfoto_wasm.js';
type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined'
&& self instanceof ServiceWorkerGlobalScope;
if (isServiceWorker) {
// Chrome: fetch WASM binary and instantiate synchronously
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script — dynamic init works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
await initDefault(wasmUrl);
}
vault.setWasm(wasmBindings);
wasm = wasmBindings;
wasmReady = true;
return wasm;
}
```
- [ ] **Step 2: Update the module doc comment**
Change the doc comment at the top of the file (line 1) from:
```typescript
/// Service worker entry point for the idfoto Chrome extension.
```
To:
```typescript
/// Background script entry point for the idfoto browser extension.
///
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
/// as a persistent background script. WASM loading adapts automatically.
```
- [ ] **Step 3: Build both targets**
```bash
cd extension && bun run build && bun run build:firefox
```
Expected: Both builds succeed with 0 errors.
- [ ] **Step 4: Commit**
```bash
git add extension/src/service-worker/index.ts
git commit -m "feat: add environment-aware WASM loading for Chrome/Firefox"
```
---
## Task 3: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Verify Chrome build still works**
```bash
cd extension && bun run build
```
Expected: `dist/` output, 0 errors. Reload in Chrome — unlock, list, autofill all work.
- [ ] **Step 2: Build Firefox**
```bash
bun run build:firefox
```
Expected: `dist-firefox/` output with `manifest.json` (Firefox version), all JS bundles, WASM files, icons.
- [ ] **Step 3: Verify Firefox manifest**
```bash
cat dist-firefox/manifest.json | grep -E "gecko|background"
```
Expected: `browser_specific_settings.gecko.id` present, `background.scripts` (not `service_worker`).
- [ ] **Step 4: Load in Firefox**
1. Open Firefox
2. Navigate to `about:debugging#/runtime/this-firefox`
3. Click "Load Temporary Add-on..."
4. Select `extension/dist-firefox/manifest.json`
5. Extension icon appears in toolbar
- [ ] **Step 5: Test basic flow**
1. Click extension icon — popup opens, shows setup prompt (or unlock if already configured)
2. Open `setup.html` via the setup button — full-page wizard loads
3. Configure vault (or verify it's already configured from Chrome)
4. Unlock with passphrase — entry list appears
5. Navigate entries, check TOTP countdown works
6. Visit a login page — field icon appears
7. Test autofill
8. If credential capture is enabled, test save prompt appears on form submit
- [ ] **Step 6: Fix any Firefox-specific issues**
- [ ] **Step 7: Final commit**
```bash
git add -A
git commit -m "feat: complete Firefox extension port"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Firefox manifest + webpack config + scripts | None |
| 2 | Environment-aware WASM loading | None |
| 3 | Build and manual test | Tasks 1, 2 |
Tasks 1 and 2 are independent and can run in parallel.

View File

@@ -0,0 +1,955 @@
# idfoto Vault Initialization Wizard Implementation Plan
> **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:** Build a browser-based wizard that creates a new idfoto vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.
**Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM.
**Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs
**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md`
---
## File Structure
### Rust (modified)
```
crates/idfoto-wasm/src/lib.rs # Add embed_image_secret function
```
### Extension (new)
```
extension/
├── setup.html # Standalone wizard page
└── src/
└── setup/
└── setup.ts # 4-step wizard logic
```
### Extension (modified)
```
extension/webpack.config.js # Add 'setup' entry point + copy setup.html
extension/manifest.json # Add web_accessible_resources for setup.html
```
---
## Task 1: Add `embed_image_secret` to WASM Crate
**Files:**
- Modify: `crates/idfoto-wasm/src/lib.rs`
- [ ] **Step 1: Write the test**
Add to the `#[cfg(test)] mod tests` block in `crates/idfoto-wasm/src/lib.rs`:
```rust
#[test]
fn embed_then_extract_round_trip() {
// Create a synthetic test JPEG (same approach as idfoto-core tests)
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(400, 300, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut jpeg_buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
encoder
.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8)
.unwrap();
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
let extracted = extract_image_secret(&stego).unwrap();
assert_eq!(extracted, secret);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p idfoto-wasm embed_then_extract`
Expected: FAIL — `embed_image_secret` not defined.
- [ ] **Step 3: Add `image` dev-dependency to Cargo.toml**
Add to `crates/idfoto-wasm/Cargo.toml` under `[dev-dependencies]`:
```toml
[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
```
- [ ] **Step 4: Implement the function**
Add to `crates/idfoto-wasm/src/lib.rs`, after the `extract_image_secret` function:
```rust
/// Embed a 256-bit secret into a carrier JPEG image.
///
/// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the
/// modified JPEG with the secret embedded via DCT steganography.
///
/// The returned JPEG should be saved as the "reference image" — the user
/// needs it alongside their passphrase to unlock the vault.
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
idfoto_core::imgsecret::embed(carrier_jpeg, &secret)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cargo test -p idfoto-wasm embed_then_extract`
Expected: PASS
- [ ] **Step 6: Rebuild WASM**
Run: `wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm`
Expected: Builds successfully.
- [ ] **Step 7: Commit**
```bash
git add crates/idfoto-wasm/src/lib.rs crates/idfoto-wasm/Cargo.toml
git commit -m "feat: add embed_image_secret to WASM crate"
```
---
## Task 2: Add WASM Type Declaration for New Function
**Files:**
- Modify: `extension/src/wasm.d.ts`
- [ ] **Step 1: Add the type declaration**
Add to `extension/src/wasm.d.ts` alongside the existing declarations:
```typescript
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
```
- [ ] **Step 2: Commit**
```bash
git add extension/src/wasm.d.ts
git commit -m "feat: add embed_image_secret type declaration"
```
---
## Task 3: Create Setup Page HTML
**Files:**
- Create: `extension/setup.html`
- [ ] **Step 1: Create the HTML file**
Create `extension/setup.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>idfoto — vault setup</title>
<link rel="stylesheet" href="styles.css">
<style>
/* Override popup constraints for full-page layout */
body {
width: auto;
min-height: 100vh;
max-height: none;
overflow-y: auto;
display: flex;
justify-content: center;
padding: 40px 20px;
}
#app {
max-width: 560px;
width: 100%;
}
.step-instructions {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 16px;
margin: 12px 0;
font-size: 12px;
line-height: 1.8;
}
.step-instructions ol {
padding-left: 20px;
}
.step-instructions li {
margin-bottom: 4px;
}
.step-instructions code {
background: #0d1117;
padding: 1px 4px;
border-radius: 2px;
color: #58a6ff;
}
.image-preview {
max-width: 200px;
max-height: 150px;
border: 1px solid #30363d;
border-radius: 2px;
margin-top: 8px;
}
.strength-bar {
height: 3px;
background: #21262d;
margin-top: 4px;
border-radius: 2px;
}
.strength-bar-fill {
height: 3px;
border-radius: 2px;
transition: width 0.3s, background 0.3s;
}
.success-box {
background: #0d1117;
border: 1px solid #3fb950;
border-radius: 4px;
padding: 16px;
margin: 12px 0;
}
.config-blob {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 12px;
font-size: 11px;
word-break: break-all;
margin-top: 8px;
cursor: pointer;
}
.config-blob:hover {
border-color: #58a6ff;
}
.test-result {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 11px;
}
.test-ok { color: #3fb950; }
.test-fail { color: #f85149; }
</style>
</head>
<body>
<div id="app"></div>
<script src="setup.js"></script>
</body>
</html>
```
- [ ] **Step 2: Commit**
```bash
git add extension/setup.html
git commit -m "feat: add setup wizard HTML page"
```
---
## Task 4: Create Setup Wizard TypeScript
**Files:**
- Create: `extension/src/setup/setup.ts`
This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module.
- [ ] **Step 1: Create the setup wizard**
Create `extension/src/setup/setup.ts`:
```typescript
/// Standalone vault initialization wizard.
///
/// 4-step flow:
/// 1. Choose git host (Gitea/GitHub) with setup instructions
/// 2. Configure connection (URL, repo, token) with test button
/// 3. Create vault (carrier image, passphrase, generate + push)
/// 4. Finish (download reference image, push config to extension)
import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types';
// --- State ---
interface WizardState {
step: number;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
connectionTested: boolean;
carrierImageBytes: Uint8Array | null;
carrierImageName: string;
passphrase: string;
passphraseConfirm: string;
referenceImageBytes: Uint8Array | null;
creating: boolean;
error: string;
extensionDetected: boolean;
configPushed: boolean;
}
let state: WizardState = {
step: 1,
hostType: 'gitea',
hostUrl: '',
repoPath: '',
apiToken: '',
connectionTested: false,
carrierImageBytes: null,
carrierImageName: '',
passphrase: '',
passphraseConfirm: '',
referenceImageBytes: null,
creating: false,
error: '',
extensionDetected: false,
configPushed: false,
};
// --- WASM ---
type WasmModule = typeof import('idfoto-wasm');
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const mod = await import(/* webpackIgnore: true */ '../idfoto_wasm.js');
await mod.default();
wasm = mod;
return mod;
}
// --- Helpers ---
function escapeHtml(s: string): string {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function passwordStrength(pw: string): { label: string; color: string; pct: number } {
if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 };
let score = 0;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 1) return { label: 'weak', color: '#f85149', pct: 30 };
if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 };
return { label: 'strong', color: '#3fb950', pct: 100 };
}
// --- Render ---
function render(): void {
const app = document.getElementById('app')!;
const stepNames = ['git host', 'connection', 'create vault', 'done'];
let html = `
<div class="brand" style="font-size:18px;margin-bottom:4px">idfoto setup</div>
<div class="wizard-step">step ${state.step} of 4 — ${stepNames[state.step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width:${(state.step / 4) * 100}%"></div></div>
`;
if (state.error) {
html += `<div class="error" style="margin-bottom:12px">${escapeHtml(state.error)}</div>`;
}
switch (state.step) {
case 1: html += renderStep1(); break;
case 2: html += renderStep2(); break;
case 3: html += renderStep3(); break;
case 4: html += renderStep4(); break;
}
app.innerHTML = html;
attachListeners();
}
// --- Step 1: Choose Git Host ---
function renderStep1(): string {
return `
<div class="form-group" style="margin-top:16px">
<div class="label">HOST TYPE</div>
<div class="host-toggle" style="display:flex;gap:8px;margin-top:4px">
<button class="group-tab ${state.hostType === 'gitea' ? 'active' : ''}" data-action="host" data-host="gitea">gitea</button>
<button class="group-tab ${state.hostType === 'github' ? 'active' : ''}" data-action="host" data-host="github">github</button>
</div>
</div>
<div class="step-instructions">
${state.hostType === 'gitea' ? `
<div class="label" style="margin-bottom:8px">GITEA SETUP</div>
<ol>
<li>Log in to your Gitea instance</li>
<li>Click <code>+</code> → <code>New Repository</code></li>
<li>Name it (e.g. <code>idfoto-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
<li>Go to <code>Settings</code> → <code>Applications</code> → <code>Manage Access Tokens</code></li>
<li>Generate a new token with <code>repo</code> scope (read/write)</li>
<li>Copy the token — you'll need it in the next step</li>
</ol>
` : `
<div class="label" style="margin-bottom:8px">GITHUB SETUP</div>
<ol>
<li>Go to <strong>github.com</strong> → <code>New Repository</code></li>
<li>Name it (e.g. <code>idfoto-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
<li>Go to <code>Settings</code> → <code>Developer Settings</code> → <code>Personal Access Tokens</code> → <code>Fine-grained tokens</code></li>
<li>Click <code>Generate new token</code></li>
<li>Select <strong>only</strong> the vault repository under "Repository access"</li>
<li>Under Permissions → Repository → <code>Contents</code>: set to <strong>Read and write</strong></li>
<li>Generate and copy the token</li>
</ol>
`}
</div>
<div class="actions">
<button class="btn btn-primary" data-action="next">Next →</button>
</div>
`;
}
// --- Step 2: Configure Connection ---
function renderStep2(): string {
const defaultUrl = state.hostType === 'github' ? 'https://github.com' : '';
return `
<div class="form-group" style="margin-top:16px">
<div class="label">HOST URL</div>
<input type="text" id="host-url" value="${escapeHtml(state.hostUrl || defaultUrl)}" placeholder="${state.hostType === 'gitea' ? 'https://git.example.com' : 'https://github.com'}">
</div>
<div class="form-group">
<div class="label">REPO PATH</div>
<input type="text" id="repo-path" value="${escapeHtml(state.repoPath)}" placeholder="owner/repo-name">
</div>
<div class="form-group">
<div class="label">API TOKEN</div>
<input type="password" id="api-token" value="${escapeHtml(state.apiToken)}" placeholder="Paste your token">
</div>
<div id="test-result"></div>
<div class="actions">
<button class="btn" data-action="back">← Back</button>
<button class="btn" data-action="test-connection">Test Connection</button>
<button class="btn btn-primary" data-action="next" ${!state.connectionTested ? 'disabled' : ''}>Next →</button>
</div>
`;
}
// --- Step 3: Create Vault ---
function renderStep3(): string {
const strength = state.passphrase ? passwordStrength(state.passphrase) : null;
const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm;
return `
<div class="form-group" style="margin-top:16px">
<div class="label">CARRIER IMAGE</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Pick any JPEG photo. A phone photo works great — at least 400x300 pixels.
</p>
<input type="file" id="carrier-image" accept="image/jpeg" style="font-size:11px">
${state.carrierImageName ? `<div class="secondary" style="margin-top:4px;font-size:11px">✓ ${escapeHtml(state.carrierImageName)}</div>` : ''}
</div>
<div class="form-group">
<div class="label">PASSPHRASE</div>
<input type="password" id="passphrase" value="${escapeHtml(state.passphrase)}" placeholder="Min 8 characters">
${strength ? `
<div class="strength-bar"><div class="strength-bar-fill" style="width:${strength.pct}%;background:${strength.color}"></div></div>
<div style="font-size:10px;color:${strength.color};margin-top:2px">${strength.label}</div>
` : ''}
</div>
<div class="form-group">
<div class="label">CONFIRM PASSPHRASE</div>
<input type="password" id="passphrase-confirm" value="${escapeHtml(state.passphraseConfirm)}" placeholder="Type it again">
${mismatch ? '<div class="error" style="margin-top:2px">Passphrases do not match</div>' : ''}
</div>
<div class="actions">
<button class="btn" data-action="back">← Back</button>
<button class="btn btn-primary" data-action="create-vault" ${state.creating ? 'disabled' : ''}>
${state.creating ? '<span class="spinner"></span> Creating...' : 'Create Vault'}
</button>
</div>
`;
}
// --- Step 4: Finish ---
function renderStep4(): string {
return `
<div class="success-box" style="margin-top:16px">
<div style="color:#3fb950;font-size:14px;margin-bottom:8px">✓ Vault created</div>
<p class="secondary" style="font-size:12px">
Your vault has been pushed to <strong>${escapeHtml(state.repoPath)}</strong>.
</p>
</div>
<div class="form-group">
<div class="label">REFERENCE IMAGE</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Download this image and keep it safe. You need it alongside your passphrase to unlock the vault.
Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library.
</p>
<button class="btn btn-primary" data-action="download-image">Download reference.jpg</button>
</div>
${state.extensionDetected ? `
<div class="form-group" style="margin-top:16px">
<div class="label">EXTENSION</div>
${state.configPushed ? `
<p class="secondary" style="font-size:11px;color:#3fb950">
✓ Extension configured. Open the extension popup and enter your passphrase to unlock.
</p>
` : `
<p class="secondary" style="font-size:11px;margin-bottom:8px">
idfoto extension detected. Push your vault config to it?
</p>
<button class="btn" data-action="push-to-extension">Configure Extension</button>
`}
</div>
` : `
<div class="form-group" style="margin-top:16px">
<div class="label">EXTENSION SETUP</div>
<p class="secondary" style="font-size:11px;margin-bottom:8px">
Install the idfoto extension, then enter these details in the setup wizard:
</p>
<div class="config-blob" data-action="copy-config" title="Click to copy">
${escapeHtml(JSON.stringify({
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
}, null, 2))}
</div>
<div class="secondary" style="font-size:10px;margin-top:4px">Click to copy</div>
</div>
`}
`;
}
// --- Event Listeners ---
function attachListeners(): void {
// Host type toggle
document.querySelectorAll('[data-action="host"]').forEach(btn => {
btn.addEventListener('click', () => {
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
state.connectionTested = false;
render();
});
});
// Navigation
document.querySelectorAll('[data-action="next"]').forEach(btn => {
btn.addEventListener('click', () => {
readInputs();
state.error = '';
state.step++;
render();
});
});
document.querySelectorAll('[data-action="back"]').forEach(btn => {
btn.addEventListener('click', () => {
readInputs();
state.error = '';
state.step--;
render();
});
});
// Test connection
document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => {
readInputs();
state.error = '';
if (!state.hostUrl || !state.repoPath || !state.apiToken) {
state.error = 'All fields are required';
render();
return;
}
const resultEl = document.getElementById('test-result')!;
resultEl.innerHTML = '<div class="test-result"><span class="spinner"></span> Testing...</div>';
try {
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
// Try to list the root directory — if the repo exists and token works, this succeeds
await git.listDir('');
state.connectionTested = true;
resultEl.innerHTML = '<div class="test-result test-ok">✓ Connected</div>';
// Re-render to enable Next button
const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement;
if (nextBtn) nextBtn.disabled = false;
} catch (err) {
state.connectionTested = false;
resultEl.innerHTML = `<div class="test-result test-fail">✗ ${escapeHtml(String(err))}</div>`;
}
});
// Carrier image file picker
document.getElementById('carrier-image')?.addEventListener('change', (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const arrayBuf = reader.result as ArrayBuffer;
state.carrierImageBytes = new Uint8Array(arrayBuf);
state.carrierImageName = file.name;
render();
};
reader.readAsArrayBuffer(file);
});
// Passphrase inputs (read on change, re-render for strength indicator)
document.getElementById('passphrase')?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
// Only re-render the strength indicator, not the whole page (avoids losing focus)
const strength = passwordStrength(state.passphrase);
const bar = document.querySelector('.strength-bar-fill') as HTMLElement;
if (bar) {
bar.style.width = `${strength.pct}%`;
bar.style.background = strength.color;
}
const label = bar?.parentElement?.nextElementSibling as HTMLElement;
if (label) {
label.textContent = strength.label;
label.style.color = strength.color;
}
});
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
state.passphraseConfirm = (e.target as HTMLInputElement).value;
});
// Create vault
document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => {
readInputs();
state.error = '';
// Validation
if (!state.carrierImageBytes) {
state.error = 'Select a carrier image';
render();
return;
}
if (state.passphrase.length < 8) {
state.error = 'Passphrase must be at least 8 characters';
render();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
render();
return;
}
state.creating = true;
render();
try {
await createVault();
state.creating = false;
// Detect extension
state.extensionDetected = await detectExtension();
state.step = 4;
render();
} catch (err) {
state.creating = false;
state.error = `Vault creation failed: ${String(err)}`;
render();
}
});
// Download reference image
document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reference.jpg';
a.click();
URL.revokeObjectURL(url);
});
// Push config to extension
document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => {
try {
const config: VaultConfig = {
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!);
chrome.runtime.sendMessage(
{ type: 'save_setup', config, imageBase64 },
(response) => {
if (response?.ok) {
state.configPushed = true;
render();
}
}
);
} catch {
state.error = 'Failed to push config to extension';
render();
}
});
// Copy config blob
document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => {
const config = JSON.stringify({
hostType: state.hostType,
hostUrl: state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
}, null, 2);
await navigator.clipboard.writeText(config);
const el = document.querySelector('[data-action="copy-config"]')!;
el.classList.add('test-ok');
setTimeout(() => el.classList.remove('test-ok'), 1500);
});
}
// --- Read form inputs into state ---
function readInputs(): void {
const hostUrl = document.getElementById('host-url') as HTMLInputElement;
const repoPath = document.getElementById('repo-path') as HTMLInputElement;
const apiToken = document.getElementById('api-token') as HTMLInputElement;
const passphrase = document.getElementById('passphrase') as HTMLInputElement;
const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement;
if (hostUrl) state.hostUrl = hostUrl.value.trim();
if (repoPath) state.repoPath = repoPath.value.trim();
if (apiToken) state.apiToken = apiToken.value.trim();
if (passphrase) state.passphrase = passphrase.value;
if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value;
}
// --- Vault Creation ---
async function createVault(): Promise<void> {
const w = await initWasm();
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
// 1. Generate random 32-byte image_secret
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG
const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret);
state.referenceImageBytes = new Uint8Array(referenceJpeg);
// 3. Generate random 32-byte salt
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// 4. Create KDF params
const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
const paramsJson = JSON.stringify(params);
// 5. Derive master_key
const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson);
// 6. Encrypt empty manifest
const emptyManifest = JSON.stringify({ entries: {}, version: 1 });
const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey);
// 7. Push vault files to repo
await git.writeFile('.idfoto/salt', salt, 'feat: initialize idfoto vault');
await git.writeFile('.idfoto/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
await git.writeFile('.idfoto/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest');
}
// --- Extension Detection ---
function detectExtension(): Promise<boolean> {
return new Promise((resolve) => {
try {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
resolve(false);
return;
}
chrome.runtime.sendMessage(
{ type: 'get_setup_state' },
(response) => {
if (chrome.runtime.lastError || !response) {
resolve(false);
} else {
resolve(true);
}
}
);
} catch {
resolve(false);
}
});
}
// --- Init ---
document.addEventListener('DOMContentLoaded', render);
```
- [ ] **Step 2: Commit**
```bash
git add extension/src/setup/setup.ts
git commit -m "feat: add vault initialization wizard"
```
---
## Task 5: Update Webpack and Manifest
**Files:**
- Modify: `extension/webpack.config.js`
- Modify: `extension/manifest.json`
- [ ] **Step 1: Add setup entry point to webpack**
In `extension/webpack.config.js`, add `setup` to the `entry` object:
```javascript
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
```
Add `setup.html` to the CopyPlugin patterns array:
```javascript
{ from: 'setup.html', to: '.' },
```
- [ ] **Step 2: Add web_accessible_resources to manifest.json**
Add to `extension/manifest.json`, after the `content_security_policy` block:
```json
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"matches": ["<all_urls>"]
}]
```
- [ ] **Step 3: Build and verify**
```bash
cd extension && bun run build
```
Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output.
- [ ] **Step 4: Commit**
```bash
git add extension/webpack.config.js extension/manifest.json
git commit -m "feat: add setup wizard to webpack build and extension manifest"
```
---
## Task 6: Build and Manual Test
**Files:** None (integration testing)
- [ ] **Step 1: Rebuild WASM**
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
```
- [ ] **Step 2: Rebuild extension**
```bash
cd extension && bun run build
```
- [ ] **Step 3: Run Rust tests**
```bash
cargo test
```
Expected: All tests pass (including the new `embed_then_extract_round_trip`).
- [ ] **Step 4: Load in Chrome and test**
1. Open `chrome://extensions/`, reload the unpacked extension from `extension/dist/`
2. Open `chrome-extension://<extension-id>/setup.html`
3. Verify:
- Step 1: host toggle switches between Gitea/GitHub instructions
- Step 2: enter real host/token/repo, test connection works
- Step 3: pick a JPEG, enter passphrase, create vault pushes files
- Step 4: download reference image works, extension detection works
4. Verify the vault repo now has `.idfoto/salt`, `.idfoto/params.json`, `.idfoto/devices.json`, `manifest.enc`
5. Open extension popup, unlock with passphrase — should work with the just-created vault
- [ ] **Step 5: Fix any issues found**
- [ ] **Step 6: Final commit**
```bash
git add -A
git commit -m "feat: complete vault initialization wizard"
```
---
## Task Summary
| Task | Description | Dependencies |
|------|-------------|--------------|
| 1 | Add `embed_image_secret` to WASM crate | None |
| 2 | Add WASM type declaration | Task 1 |
| 3 | Create setup page HTML | None |
| 4 | Create setup wizard TypeScript | Task 2, 3 |
| 5 | Update webpack and manifest | Task 3, 4 |
| 6 | Build and manual test | All |
Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
# idfoto — Credential Capture Design
Experimental feature that detects login form submissions and prompts the user to save or update credentials in the vault. Configurable prompt style (notification bar or toast). Off by default.
## Scope
- Content script: detect form submissions with password fields, capture credentials
- Prompt UI: injected notification bar or floating toast (user-configurable)
- Dedup: check manifest before prompting — skip if already saved, offer update if password changed
- Blacklist: "Never for this site" option, persisted in `chrome.storage.local`
- Settings: enable/disable capture, choose prompt style
- Popup: settings view accessible from unlock screen
## Trigger
The content script listens for two events on forms that contain a password field:
1. `submit` event on the `<form>` element
2. `click` event on submit buttons (`button[type=submit]`, `input[type=submit]`, or buttons inside the form)
When triggered:
1. Read the username value from the detected username field (same detection priority as `detector.ts`)
2. Read the password value from the password field
3. If either is empty, skip
4. Send `{ type: 'check_credential', url, username, password }` to the service worker
## Service Worker: `check_credential` Message
New message type added to the Request union:
```typescript
{ type: 'check_credential'; url: string; username: string; password: string }
```
Response:
```typescript
{ ok: true; data: { action: 'save' | 'update' | 'skip'; entryId?: string; entryName?: string } }
```
Logic:
1. If vault is locked, respond `skip`
2. Check `captureBlacklist` in `chrome.storage.local` — if the URL's hostname is blacklisted, respond `skip`
3. Check `captureEnabled` setting — if false, respond `skip`
4. Search manifest entries by hostname match (same as `findByUrl`)
5. If no match: respond `{ action: 'save' }`
6. If match with same username and same password: respond `{ action: 'skip' }` (already saved)
7. If match with same username but different password: respond `{ action: 'update', entryId, entryName }` (password changed)
8. If match with different username: respond `{ action: 'save' }` (new account on same site)
To compare passwords in step 6/7, the service worker must decrypt the matched entry to read the stored password. This is acceptable because it only happens on form submission, not on every page load.
## Prompt UI
Two styles, user-configurable:
### Bar Mode (default)
A fixed-position bar at the top of the page, injected into the DOM:
```
┌──────────────────────────────────────────────────────────────────┐
│ idfoto: Save login for github.com? (alee) [Save] [Never] [✕] │
└──────────────────────────────────────────────────────────────────┘
```
- Background: #161b22, border-bottom: 1px solid #30363d
- Text: #c9d1d9, monospace font
- Slides down from top with CSS transition
- z-index: 2147483647 (max, above everything)
- Save button: #1f6feb, Never button: #21262d, Dismiss: ✕ icon
- For updates: "Update password for github.com? (alee)" with [Update] button
### Toast Mode
A floating element in the bottom-right corner:
```
┌─────────────────────────────────┐
│ idfoto │
│ Save login for github.com? │
│ alee │
│ [Save] [Never] [✕] │
└─────────────────────────────────┘
```
- Position: fixed, bottom: 16px, right: 16px
- Same color scheme as bar mode
- Border: 1px solid #30363d, border-radius: 4px
- Auto-dismiss after 15 seconds if not interacted with
- For updates: same layout with "Update password?" text
### Prompt Behavior
When user clicks:
- **Save:** Content script sends `add_entry` message to service worker with `{ name: hostname, url: full_url, username, password }`. On success, prompt shows brief "Saved" confirmation then disappears.
- **Update:** Content script sends `update_entry` message with the existing entry ID and new password. Brief "Updated" confirmation.
- **Never:** Content script sends `{ type: 'blacklist_site', hostname }` to service worker, which appends to `captureBlacklist` in `chrome.storage.local`. Prompt disappears.
- **Dismiss (✕):** Prompt disappears. No action taken. Will prompt again next time.
## Settings
Stored in `chrome.storage.local` under key `settings`:
```typescript
interface IdfotoSettings {
captureEnabled: boolean; // default: false
captureStyle: 'bar' | 'toast'; // default: 'bar'
}
```
Plus a separate key `captureBlacklist: string[]` (array of hostnames).
### Settings View in Popup
Accessible from the unlock screen via a "settings" link (already exists as a button). New popup view `settings` that shows:
```
← back
SETTINGS
CREDENTIAL CAPTURE (experimental)
[toggle] Auto-detect logins
Style: [bar ▾] / [toast]
BLACKLISTED SITES
github.com [✕]
netflix.com [✕]
```
The toggle and style selector write to `chrome.storage.local`. Blacklist entries can be removed individually.
## New Message Types
```typescript
// Request
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<IdfotoSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
// Response for check_credential
{ ok: true; data: { action: 'save' | 'update' | 'skip'; entryId?: string; entryName?: string } }
```
## File Structure
### New files
```
extension/src/content/capture.ts # Form submission listener + prompt injection
extension/src/popup/components/settings.ts # Settings view
```
### Modified files
```
extension/src/content/detector.ts # Import and init capture module
extension/src/service-worker/index.ts # Handle new message types
extension/src/shared/messages.ts # Add new Request/Response types
extension/src/shared/types.ts # Add IdfotoSettings interface
extension/src/popup/popup.ts # Add 'settings' view to state machine
extension/src/popup/components/unlock.ts # Wire up settings button
```
## Security
- Credentials are captured from the DOM only on form submission — no keylogging, no continuous monitoring
- Captured credentials are sent to the service worker via `chrome.runtime.sendMessage` (same secure channel as autofill)
- The prompt UI is injected into the page DOM but styled with inline styles and high z-index to avoid CSS conflicts
- The "Never" blacklist prevents unwanted prompting but doesn't affect manual autofill
## Non-Goals
- Detecting password change forms (change old → new password flows)
- Capturing credentials from non-standard login flows (OAuth redirects, SSO)
- Syncing settings across devices

View File

@@ -0,0 +1,196 @@
# idfoto — Firefox Extension Port Design
Port the existing Chrome MV3 extension to Firefox. Shared TypeScript source, separate manifests, separate build outputs. No code changes to components, popup, or content script.
## Scope
- Firefox-specific `manifest.json`
- WASM loading compatibility (dynamic import for Firefox background script)
- Second webpack config for Firefox build target
- npm scripts for building both browsers
## What Stays the Same
All TypeScript source files are shared between Chrome and Firefox:
- `src/service-worker/` — all files (with one environment check for WASM loading)
- `src/popup/` — all components, styles, HTML
- `src/content/` — detector, fill, icon, capture
- `src/setup/` — setup wizard
- `src/shared/` — types, messages
- `setup.html` — init wizard
Firefox supports the `chrome.*` namespace for WebExtension APIs, so no `browser.*` polyfill is needed. All Chrome API calls (`chrome.runtime.sendMessage`, `chrome.storage.local`, `chrome.tabs`, etc.) work as-is.
## Manifest Differences
### Chrome (`manifest.json` — existing)
```json
{
"manifest_version": 3,
"background": {
"service_worker": "service-worker.js",
"type": "module"
}
}
```
### Firefox (`manifest.firefox.json` — new)
```json
{
"manifest_version": 3,
"name": "idfoto",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"strict_min_version": "128.0"
}
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["service-worker.js"]
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"]
}]
}
```
Key differences from Chrome manifest:
- `browser_specific_settings.gecko.id` — required for Firefox, uses email-style ID
- `browser_specific_settings.gecko.strict_min_version` — Firefox 128+ for stable MV3 support
- `background.scripts` instead of `background.service_worker` + `type: module` — Firefox MV3 background scripts are NOT service workers, they're persistent scripts
- `web_accessible_resources` — Firefox doesn't use `matches` field in the resource entries
## WASM Loading
The service worker `index.ts` currently uses `initSync` with `chrome.runtime.getURL` because Chrome MV3 service workers don't support dynamic `import()`. Firefox background scripts DO support `import()`.
Add an environment check to `index.ts`:
```typescript
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
if (typeof ServiceWorkerGlobalScope !== 'undefined') {
// Chrome MV3: service worker context — use initSync
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script context — dynamic import works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
await initDefault(wasmUrl);
}
vault.setWasm(wasmBindings);
wasm = wasmBindings;
wasmReady = true;
return wasm;
}
```
This uses the static import of `initSync` and the default export (`initDefault`) from the WASM glue, branching on runtime environment. Both paths end with the same `wasmBindings` module reference.
## Build Pipeline
### New file: `extension/webpack.firefox.config.js`
Identical to `webpack.config.js` except:
- Output directory: `dist-firefox/` instead of `dist/`
- CopyPlugin copies `manifest.firefox.json` as `manifest.json` (instead of `manifest.json`)
### Updated `extension/package.json` scripts
```json
{
"scripts": {
"build": "webpack --mode production",
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
}
}
```
### Output structure
```
extension/
├── dist/ # Chrome build (existing)
│ ├── service-worker.js
│ ├── popup.js
│ ├── content.js
│ ├── setup.js
│ ├── manifest.json # Chrome manifest
│ └── ...
├── dist-firefox/ # Firefox build (new)
│ ├── service-worker.js
│ ├── popup.js
│ ├── content.js
│ ├── setup.js
│ ├── manifest.json # Firefox manifest (copied from manifest.firefox.json)
│ └── ...
└── wasm/ # Shared WASM (same for both)
```
## Testing
### Load in Firefox
1. Open `about:debugging#/runtime/this-firefox`
2. Click "Load Temporary Add-on..."
3. Select `extension/dist-firefox/manifest.json`
4. Extension appears in toolbar
### Test matrix
Same as Chrome — all features should work identically:
- Setup wizard (`setup.html`)
- Unlock with passphrase
- Entry list, search, group filtering
- Entry detail with TOTP countdown
- Add/edit/delete entries
- Autofill via field icon
- Credential capture (if enabled)
- Settings view
### Firefox-specific checks
- WASM loads correctly (background script, not service worker)
- master_key persists longer (background script stays alive)
- Popup dimensions render correctly
- Content script injection works on all pages
## .gitignore
Add `extension/dist-firefox/` to `.gitignore`.
## Non-Goals
- Firefox for Android (different extension API surface)
- Publishing to addons.mozilla.org (manual for now)
- Automated cross-browser testing
- Shared webpack config with conditional logic (two separate configs is clearer)

View File

@@ -0,0 +1,178 @@
# idfoto — Standalone Vault Initialization Wizard Design
A browser-based wizard that guides new users through creating an idfoto vault from scratch. Lives at `extension/setup.html`, uses the same WASM module as the extension, same terminal dark aesthetic. No server, no Rust toolchain required.
## Scope
- Single HTML page with inline JS (bundled by webpack) at `extension/setup.html`
- 4-step wizard: choose host → configure connection → create vault → finish
- Pushes vault files directly to Gitea/GitHub via API
- Downloads reference image to user's machine
- Optionally pushes config to the Chrome extension if installed
## Flow
### Step 1: Choose Git Host
Toggle between Gitea and GitHub. Below the toggle, show inline setup instructions:
**Gitea instructions:**
1. Log in to your Gitea instance
2. Create a new empty repository (no README, no .gitignore)
3. Go to Settings → Applications → Generate New Token
4. Select scope: `repo` (read/write)
5. Copy the token
**GitHub instructions:**
1. Go to github.com → New Repository
2. Create an empty repository (no README, no .gitignore, no license)
3. Go to Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens
4. Generate new token, select only the target repository
5. Permissions: Contents → Read and write
6. Copy the token
Step includes a "Next" button. No validation needed at this step.
### Step 2: Configure Connection
Fields:
- Host URL (e.g. `https://git.adlee.work` or `https://github.com`) — pre-filled based on host type selection
- Repository path (e.g. `alee/idfoto-vault`)
- API token (password field)
"Test Connection" button:
- Hits the git API to verify the token works and the repo exists
- Checks that the repo is empty (no files) or contains only a README
- Shows green checkmark on success, red error on failure
- Must pass before "Next" is enabled
Uses the same `GitHost` interface (GiteaHost/GitHubHost) from the extension's service worker code.
### Step 3: Create Vault
Two inputs:
- **Carrier image:** File picker for a JPEG. Shows preview thumbnail after selection. Minimum size guidance ("use a photo from your phone — at least 400x300").
- **Passphrase:** Password field with confirmation. Minimum 8 characters enforced. Shows basic strength indicator (weak/ok/strong based on length + character variety).
"Create Vault" button triggers:
1. Load WASM module
2. Generate random 32-byte `image_secret` via `crypto.getRandomValues()`
3. Embed secret into carrier JPEG via WASM `extract_image_secret` — wait, that's extract. We need `embed`. Check: the WASM crate currently only exposes `extract_image_secret`, not `embed`. **We need to add a `embed_image_secret` function to `idfoto-wasm`.**
4. Generate random 32-byte `salt` via `crypto.getRandomValues()`
5. Create `params.json` with default KDF params (`{"argon2_m":65536,"argon2_t":3,"argon2_p":4}`)
6. Derive `master_key` via WASM `derive_master_key(passphrase, image_secret, salt, params_json)`
7. Encrypt empty manifest (`{"entries":{},"version":1}`) via WASM `encrypt_manifest`
8. Push files to repo via git API:
- `.idfoto/salt` (raw 32 bytes)
- `.idfoto/params.json` (JSON string)
- `.idfoto/devices.json` (`[]`)
- `manifest.enc` (encrypted manifest bytes)
9. Show progress bar during push operations
Spinner/progress during the Argon2id derivation (~1-2 seconds) and API calls.
### Step 4: Finish
Two things happen:
**Download reference image:**
- Browser downloads the steganographic JPEG as `reference.jpg`
- Show warning: "Keep this image safe. You need it alongside your passphrase to unlock the vault. Store it somewhere you won't lose it."
**Push config to extension (if available):**
- Try to detect the idfoto extension via `chrome.runtime.sendMessage` with a `get_setup_state` message
- If extension responds: push `save_setup` message with `{ config: { hostType, hostUrl, repoPath, apiToken }, imageBase64 }`. Show "Extension configured! You can now open the extension and unlock your vault."
- If extension not detected: show the config as a copyable JSON blob with instructions: "Install the idfoto extension, then paste this into the setup wizard." (Or just tell them to run through the extension setup manually with the same host/token/repo.)
## WASM Crate Change
The `idfoto-wasm` crate needs one new function:
```rust
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue>
```
This wraps `idfoto_core::imgsecret::embed`. Currently only `extract_image_secret` is exposed.
## File Structure
```
extension/
├── setup.html # standalone wizard page
├── src/
│ └── setup/
│ └── setup.ts # wizard logic (4-step state machine)
├── webpack.config.js # add 'setup' entry point
```
The setup page reuses:
- `extension/wasm/` — same WASM module
- `extension/src/service-worker/git-host.ts`, `gitea.ts`, `github.ts` — git API layer
- `extension/src/popup/styles.css` — terminal dark theme (imported or linked)
- `extension/src/shared/types.ts` — VaultConfig type
## UI Design
Same terminal dark aesthetic as the popup but in a full-page layout (not 360px constrained). Centered content area, max-width ~600px. Same color scheme (#0d1117 bg, #58a6ff blue, monospace font).
Progress bar at top showing step 1-4. Each step is a full-page view with back/next navigation.
## Extension Detection
```typescript
// Try to send a message to the extension
function detectExtension(): Promise<boolean> {
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage(
{ type: 'get_setup_state' },
(response) => {
if (chrome.runtime.lastError || !response) {
resolve(false);
} else {
resolve(true);
}
}
);
} catch {
resolve(false);
}
});
}
```
Note: this only works if `setup.html` is served from the extension itself (`chrome-extension://<id>/setup.html`) or if we use `externally_connectable` in the manifest. For a local file, extension detection won't work — fall back to manual config copy.
If we add `setup.html` to the extension's web_accessible_resources and the user opens it via `chrome-extension://` URL, messaging works natively.
## manifest.json Changes
Add `setup.html` to the extension so it can be opened as a chrome-extension page:
```json
{
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"matches": ["<all_urls>"]
}]
}
```
The setup page can then be opened at `chrome-extension://<extension-id>/setup.html`. The extension popup can link to it, or the user can navigate directly.
## Security
- Passphrase never leaves the browser
- image_secret generated client-side, embedded client-side, never transmitted
- master_key derived and used in-browser only, then discarded
- API token used only for pushing vault files and optionally passed to extension storage
- The reference image download is the only artifact the user needs to keep safe
## Non-Goals
- Creating repos via API (user creates the repo manually — API permissions for repo creation vary widely)
- Git operations beyond file CRUD (no commits history, no branches)
- Password strength estimation beyond basic length/variety checks
- Mobile support (desktop Chrome only for now)

View File

@@ -0,0 +1,502 @@
# idfoto — WASM + Chrome MV3 Extension Design
The browser extension for idfoto. Compiles `idfoto-core` to WASM, wraps it in a Chrome MV3 extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. No CLI dependency, no native messaging bridge.
## Scope
- `idfoto-wasm` crate — wasm-bindgen wrapper around `idfoto-core`
- Chrome MV3 extension:
- One-time setup wizard (git host + token + repo + reference image)
- Service worker — WASM runtime, master_key holder, vault operations, git API
- Popup — unlock, search/list, group filtering, entry detail, TOTP countdown, keyboard-first
- Content script — conservative login form detection, explicit-trigger autofill
- Data model addition: `group` field on entries for logical organization
## Data Model Changes
### Entry struct
```rust
pub struct Entry {
pub name: String,
pub url: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub notes: Option<String>,
pub totp_secret: Option<String>,
pub group: Option<String>, // NEW — None = ungrouped
pub created_at: String,
pub updated_at: String,
}
```
### ManifestEntry struct
```rust
pub struct ManifestEntry {
pub name: String,
pub url: Option<String>,
pub username: Option<String>,
pub group: Option<String>, // NEW — for popup filtering without decrypting entries
pub updated_at: String,
}
```
The `group` field is a free-form string. No predefined list, no nesting. User types "work" or "family" and entries cluster. Backwards-compatible — existing vaults without `group` deserialize as `None` (ungrouped).
## WASM Crate (`idfoto-wasm`)
Thin wasm-bindgen wrapper exposing `idfoto-core` functions to JavaScript. Lives at `crates/idfoto-wasm/`.
### Public API
```rust
// KDF + crypto
#[wasm_bindgen]
pub fn derive_master_key(passphrase: &str, image_secret: &[u8], salt: &[u8], params_json: &str) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
// Image secret extraction
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue>
// Vault operations (convenience wrappers — JSON in, encrypted bytes out)
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
// TOTP — RFC 6238, HMAC-SHA1, 6-digit codes, 30-second step
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue>
// Utilities
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String
#[wasm_bindgen]
pub fn generate_entry_id() -> String
```
### Dependencies
```toml
[dependencies]
idfoto-core = { path = "../idfoto-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10" # TOTP requires HMAC-SHA1 per RFC 6238
data-encoding = "2" # base32 decoding for TOTP secrets
```
### WASM build
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
```
Output: `idfoto_wasm.js` (JS glue) + `idfoto_wasm_bg.wasm` (binary). Expected size ~200-500 KB gzipped. The `image` crate's JPEG decoder is the heaviest component — optimize only if measured size is a problem.
### TOTP implementation
Standard RFC 6238:
1. Base32-decode the secret
2. Compute time step: `counter = timestamp_secs / 30`
3. HMAC-SHA1(secret, counter as big-endian u64)
4. Dynamic truncation → 6-digit code
5. Zero-pad to 6 digits
Implemented in the WASM crate, not in JavaScript. No JS crypto dependency.
## Extension Architecture
### Approach: Monolith Service Worker
All logic lives in the service worker. Popup and content script are thin UI/DOM layers that communicate via `chrome.runtime.sendMessage`.
The master_key exists only in the service worker's memory. Chrome MV3 may terminate idle service workers after ~30 seconds — this clears the key and requires re-unlock. This is a feature: natural session timeout with zero additional code.
Mitigations for premature termination:
- Chrome keeps workers alive while message ports are open (popup open = worker alive)
- Content scripts can send periodic keepalive pings on active tabs
- Re-unlock is fast enough (~1-2s for Argon2id in WASM) that it's not painful
### Service Worker State
```typescript
interface WorkerState {
masterKey: Uint8Array | null; // held in memory after unlock, cleared on termination
manifest: Manifest | null; // cached after first decrypt, refreshed on sync
config: VaultConfig | null; // from chrome.storage.local
}
interface VaultConfig {
hostType: "gitea" | "github";
hostUrl: string; // e.g. "https://git.adlee.work"
repoPath: string; // e.g. "alee/idfoto-vault"
apiToken: string; // personal access token
imageBytes: Uint8Array; // reference JPEG, stored in chrome.storage.local
}
```
### Message API
Popup and content script communicate with the service worker via typed messages:
```typescript
// Auth
{ type: "unlock", passphrase: string } { ok: true } | { error: string }
{ type: "lock" } { ok: true }
{ type: "is_unlocked" } { unlocked: boolean }
// Vault reads
{ type: "list_entries", group?: string } ManifestEntry[]
{ type: "get_entry", id: string } Entry
{ type: "search_entries", query: string } ManifestEntry[]
// Vault writes
{ type: "add_entry", entry: EntryInput } { id: string }
{ type: "update_entry", id: string, entry: EntryInput } { ok: true }
{ type: "delete_entry", id: string } { ok: true }
// TOTP
{ type: "get_totp", id: string } { code: string, remaining_seconds: number }
// Autofill
{ type: "get_autofill_candidates", url: string } ManifestEntry[]
{ type: "get_credentials", id: string } { username: string, password: string }
// Sync
{ type: "sync" } { ok: true } | { error: string }
```
### Unlock Flow
1. User enters passphrase in popup
2. Popup sends `{ type: "unlock", passphrase }` to service worker
3. Service worker loads vault config from `chrome.storage.local` (includes image bytes)
4. WASM: `extract_image_secret(image_bytes)``image_secret`
5. Service worker fetches `.idfoto/salt` and `.idfoto/params.json` via git API
6. WASM: `derive_master_key(passphrase, image_secret, salt, params)``master_key`
7. Service worker fetches `manifest.enc` via git API
8. WASM: `decrypt_manifest(manifest_enc, master_key)` → manifest
9. Cache `master_key` and `manifest` in worker memory
10. Reply `{ ok: true }` to popup
Steps 4-6 take ~1-2 seconds (Argon2id dominates). Popup shows a spinner.
## Git API Layer
Abstracts Gitea and GitHub behind a common interface. Both use nearly identical REST APIs for file CRUD.
```typescript
interface GitHost {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
```
### GiteaHost
- Base: `{hostUrl}/api/v1/repos/{repoPath}/contents/{path}`
- Auth: `Authorization: token {apiToken}`
- File content returned as base64 in JSON response
- Write/delete requires the file's SHA (fetched first, then sent with the update)
### GitHubHost
- Base: `https://api.github.com/repos/{repoPath}/contents/{path}`
- Auth: `Authorization: Bearer {apiToken}`
- Same base64 content model, same SHA requirement for updates
### Sync behavior
- On unlock: fetch salt, params, manifest
- On entry access: fetch individual entry file on demand
- On write (add/edit/rm): two sequential API commits — entry file first, then updated manifest
- Each API call = one commit. A write operation is two commits (entry + manifest), linear history
- No branching, no merging, no conflict resolution in V1
- If the remote has changed since last read (SHA mismatch on write), the API returns 409 — surface the error, user re-syncs
## Popup UI
### Design Language
- **Theme:** Dark background (#0d1117), monospace typography (system monospace stack, JetBrains Mono preferred)
- **Aesthetic:** Terminal/dev tool feel. Minimal chrome, tight spacing, no rounded corners beyond 2px
- **Colors:** Blue (#58a6ff) for interactive elements and branding, green (#3fb950) for TOTP codes, muted gray (#8b949e) for secondary text, dark surfaces (#161b22) for inputs
- **Interactions:** Keyboard-first. Every action has a single-key shortcut. Mouse works but isn't required.
### Popup States
The popup is a state machine with four primary states:
**1. Locked (unlock prompt)**
- Single passphrase input field
- ENTER to submit, ESC to close popup
- Spinner during Argon2id derivation
- Error message on bad passphrase (inline, red text)
**2. Entry List**
- Search bar at top (focused by `/`)
- Group filter tabs below search (all, personal, work, etc. — derived from entries)
- Scrollable entry list with keyboard navigation (↑↓)
- Each entry shows: name, username, domain (extracted from URL)
- Active entry highlighted with left blue border
- Footer: keybinding hints
- `+` to add new entry
- ENTER to open selected entry
**3. Entry Detail**
- Back navigation (ESC)
- Entry name as header, group label
- Fields: URL, username (c to copy), password masked (p to copy), TOTP code with countdown bar (t to copy)
- Notes section (if present)
- Actions: f = autofill active tab, e = edit, d = delete (with confirmation)
- TOTP countdown: green progress bar, updates every second, code regenerates at 0
**4. Setup Wizard**
- Three steps with progress bar:
1. Git host config: host type toggle (Gitea/GitHub), host URL, repo path, API token
2. Reference image: file upload (drag-and-drop or file picker), stored to `chrome.storage.local`
3. Test unlock: enter passphrase, verify derivation succeeds against the remote vault
- Back/next navigation, validation on each step
### Additional Views (modal overlays)
- **Add/Edit Entry:** Form with fields for name, URL, username, password (with generate button), TOTP secret, group, notes. Save commits to git.
- **Delete Confirmation:** "Delete {name}? This commits a removal to the vault." Yes/No.
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `/` | List | Focus search |
| `↑↓` | List | Navigate entries |
| `Enter` | List | Open selected entry |
| `Esc` | Detail/Edit | Back to list |
| `Esc` | List | Close popup |
| `+` | List | Add new entry |
| `c` | Detail | Copy username |
| `p` | Detail | Copy password |
| `t` | Detail | Copy TOTP code |
| `f` | Detail | Autofill active tab |
| `e` | Detail | Edit entry |
| `d` | Detail | Delete entry (with confirmation) |
### Popup Dimensions
Width: 360px. Height: auto, max 500px with scroll. Standard Chrome extension popup constraints.
## Content Script
Runs on all HTTP/HTTPS pages. Three responsibilities:
### 1. Login Form Detection
Conservative detection — standard selectors only:
```typescript
// Password field detection
const passwordFields = document.querySelectorAll('input[type="password"]');
// Username field detection (adjacent to password field)
// Priority order:
// 1. input[autocomplete="username"]
// 2. input[autocomplete="email"]
// 3. input[type="email"]
// 4. input[name] matching /user|email|login|account/i
// 5. Nearest preceding text/email input in the same form
```
No shadow DOM traversal. No heuristic scoring. No iframe inspection. If the form uses non-standard markup, the user copies from the popup manually.
### 2. Field Icon Injection
When a password field is detected:
- Small idfoto icon (16x16, inline SVG) appears at the right edge of the password field
- Click triggers: send page URL to service worker → get matching entries
- Single match: fill immediately
- Multiple matches: show inline picker (small dropdown below the icon)
- Icon styled to not conflict with existing field content
### 3. Credential Fill
On fill trigger (from popup `f` key or field icon click):
1. Service worker sends `{ username, password }` to content script
2. Content script sets `.value` on detected fields
3. Dispatches `input` and `change` events (required for React/Vue/Angular controlled inputs)
4. Focuses the next logical element (submit button or next field)
The content script never receives the master_key, manifest, or any vault data beyond the specific credentials being filled.
## Extension File Structure
```
extension/
├── manifest.json # MV3 manifest
├── package.json # TypeScript, build tooling
├── tsconfig.json
├── webpack.config.js # or vite.config.ts
├── src/
│ ├── service-worker/
│ │ ├── index.ts # WASM init, message router, state management
│ │ ├── vault.ts # vault CRUD operations
│ │ ├── git-host.ts # GitHost interface definition
│ │ ├── gitea.ts # Gitea API implementation
│ │ ├── github.ts # GitHub API implementation
│ │ ├── totp.ts # TOTP code request handling
│ │ └── autofill.ts # content script coordination
│ ├── popup/
│ │ ├── index.html # popup shell
│ │ ├── popup.ts # state machine: locked → list → detail → edit
│ │ ├── components/
│ │ │ ├── unlock.ts # passphrase prompt
│ │ │ ├── entry-list.ts # search + group filter + entry rows
│ │ │ ├── entry-detail.ts # field display + TOTP countdown
│ │ │ ├── entry-form.ts # add/edit form
│ │ │ └── setup-wizard.ts # three-step setup flow
│ │ └── styles.css # terminal dark theme
│ ├── content/
│ │ ├── detector.ts # login form field detection
│ │ ├── fill.ts # credential injection + event dispatch
│ │ └── icon.ts # field icon injection + inline picker
│ └── shared/
│ ├── messages.ts # typed message definitions
│ └── types.ts # Entry, ManifestEntry, VaultConfig, etc.
├── wasm/ # wasm-pack output (idfoto_wasm.js + .wasm)
├── icons/ # extension icons (16, 48, 128px)
└── dist/ # build output → load unpacked into Chrome
```
No framework. Vanilla TypeScript + DOM manipulation. The popup is small enough that a framework adds overhead without value. Bundle stays tiny.
## Build Pipeline
### WASM build
```bash
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
```
### Extension build
```bash
cd extension && npm run build # TypeScript → bundled JS via webpack/vite → dist/
```
### Combined
```bash
make extension # or: npm run build:all from extension/
```
Chains wasm-pack then webpack. Dev mode: `npm run dev` watches TypeScript and auto-rebuilds. WASM only needs rebuild when Rust source changes.
### Chrome manifest.json
```json
{
"manifest_version": 3,
"name": "idfoto",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
```
Note: `wasm-unsafe-eval` is required in MV3 to instantiate WASM modules. This is the standard approach — Chrome explicitly added this directive for WASM use cases.
`host_permissions: ["<all_urls>"]` is needed for the content script to run on all pages and for the service worker to make API calls to arbitrary git hosts.
## Security Considerations
### What's stored in `chrome.storage.local`
| Data | Sensitivity | Rationale |
|------|-------------|-----------|
| Reference image bytes | Low | Public in threat model (can live on social media). Provides image_secret but useless without passphrase. |
| API token | Medium | Grants repo access. Scoped to repo-only permissions. |
| Host URL, repo path | Low | Not secret. |
### What's never persisted
- Passphrase
- master_key (service worker memory only, cleared on termination)
- image_secret (derived in memory during unlock, not cached)
### Content script isolation
The content script runs in the page's DOM context but never receives vault-level data. It only gets the specific `{ username, password }` pair for a fill operation, delivered on demand by the service worker.
### API token security
The token is stored in `chrome.storage.local`, which is sandboxed per-extension and inaccessible to web pages. A compromised extension could leak it, but that's true of any credential stored by any extension. Mitigation: scope the token to minimum required permissions (repo read/write only).
## Testing Strategy
### WASM crate
- Unit tests: each wrapper function round-trips correctly (`wasm-pack test --node`)
- TOTP: test vectors from RFC 6238 appendix B
- Integration: derive key + encrypt + decrypt cycle matches `idfoto-core` output
### Extension (manual for V1)
- Setup wizard: configure Gitea host, upload reference image, test unlock
- CRUD: add, view, edit, delete entries through popup
- Groups: create entries in different groups, verify filter works
- Autofill: test on standard login forms (GitHub, Google, etc.)
- TOTP: verify generated codes match Google Authenticator for same seed
- Service worker lifecycle: close popup, wait >30s, reopen — verify re-unlock required
- Offline: verify graceful error when git host unreachable
### Future: automated extension testing with Puppeteer/Playwright
Not in V1 scope. The extension is small enough that manual testing covers it.
## Non-Goals
- Firefox/Safari extensions (later plan)
- Offline vault cache (extension always needs git host access)
- Conflict resolution (409 on write = re-sync, no merge)
- Framework (React, Vue, etc.) for popup UI
- Automated E2E testing (manual for V1)
- Multiple vaults per extension (single vault, groups for organization)

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

BIN
extension/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 B

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

View File

@@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "idfoto",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "idfoto@adlee.work",
"strict_min_version": "128.0"
}
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["service-worker.js"]
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"]
}]
}

32
extension/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "idfoto",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
"matches": ["<all_urls>"]
}]
}

21
extension/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "idfoto-extension",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "webpack --mode production",
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm"
},
"devDependencies": {
"@types/chrome": "^0.1.40",
"copy-webpack-plugin": "^12.0",
"ts-loader": "^9.5",
"typescript": "^5.4",
"webpack": "^5.90",
"webpack-cli": "^5.1"
}
}

113
extension/setup.html Normal file
View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>idfoto — vault setup</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
width: auto;
max-width: 560px;
margin: 40px auto;
padding: 0 20px;
max-height: none;
overflow-y: auto;
}
.step-instructions {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
margin: 12px 0;
font-size: 12px;
line-height: 1.7;
}
.step-instructions ol {
padding-left: 20px;
}
.step-instructions li {
margin-bottom: 4px;
}
.step-instructions code {
background: #21262d;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.image-preview {
max-width: 200px;
max-height: 150px;
border-radius: 4px;
border: 1px solid #30363d;
margin-top: 8px;
}
.strength-bar {
height: 4px;
background: #21262d;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.strength-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.2s, background 0.2s;
}
.strength-bar-fill.weak { background: #f85149; width: 25%; }
.strength-bar-fill.fair { background: #d29922; width: 50%; }
.strength-bar-fill.good { background: #3fb950; width: 75%; }
.strength-bar-fill.strong { background: #58a6ff; width: 100%; }
.success-box {
background: #0d1b0e;
border: 1px solid #238636;
border-radius: 6px;
padding: 20px;
margin: 16px 0;
text-align: center;
}
.success-box h3 {
color: #3fb950;
margin-bottom: 8px;
}
.config-blob {
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
padding: 12px;
font-size: 11px;
word-break: break-all;
user-select: all;
margin: 12px 0;
max-height: 120px;
overflow-y: auto;
}
.test-result {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 12px;
}
.test-result.pass { color: #3fb950; }
.test-result.fail { color: #f85149; }
</style>
</head>
<body>
<div id="app"></div>
<script src="setup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,308 @@
/// Credential capture module.
///
/// Detects login form submissions and prompts the user to save or update
/// credentials in the vault. Supports bar and toast prompt styles.
import type { Request, Response } from '../shared/messages';
import type { IdfotoSettings } from '../shared/types';
// --- State ---
const hookedForms = new WeakSet<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();
// --- Messaging ---
function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
resolve(response);
});
});
}
// --- Username detection (same priority as detector.ts) ---
function findUsernameValue(pwField: HTMLInputElement): string {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// 1. autocomplete="username"
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'username' && input.value) return input.value;
}
// 2. autocomplete="email"
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'email' && input.value) return input.value;
}
// 3. type="email"
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'email' && input.value) return input.value;
}
// 4. name/id matching common patterns
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'hidden' || input.type === 'password') continue;
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
}
// 5. Nearest preceding visible text input
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
}
return '';
}
// --- Form submission handler ---
async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
const password = pwField.value;
if (!password) return;
const username = findUsernameValue(pwField);
const url = window.location.href;
const resp = await sendMessage({
type: 'check_credential',
url,
username,
password,
});
if (!resp.ok) return;
const data = resp.data as { action: string; entryId?: string; entryName?: string };
if (data.action === 'skip') return;
// Fetch settings for prompt style
const settingsResp = await sendMessage({ type: 'get_settings' });
const settings: IdfotoSettings = settingsResp.ok
? (settingsResp.data as { settings: IdfotoSettings }).settings
: { captureEnabled: true, captureStyle: 'bar' };
showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId);
}
// --- Prompt UI ---
function removeExistingPrompt(): void {
const existing = document.getElementById('idfoto-capture-prompt');
if (existing) existing.remove();
}
function showPrompt(
style: 'bar' | 'toast',
action: string,
url: string,
username: string,
password: string,
entryId?: string,
): void {
removeExistingPrompt();
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
hostname = url;
}
const container = document.createElement('div');
container.id = 'idfoto-capture-prompt';
// Common styles
const baseStyles = [
'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace',
'font-size: 13px',
'color: #c9d1d9',
'background: #161b22',
'z-index: 2147483647',
'box-sizing: border-box',
'line-height: 1.4',
];
if (style === 'bar') {
container.style.cssText = [
...baseStyles,
'position: fixed',
'top: 0',
'left: 0',
'right: 0',
'padding: 10px 16px',
'display: flex',
'align-items: center',
'gap: 12px',
'border-bottom: 1px solid #30363d',
'box-shadow: 0 2px 8px rgba(0,0,0,0.4)',
'transform: translateY(-100%)',
'transition: transform 0.3s ease',
].join('; ');
} else {
container.style.cssText = [
...baseStyles,
'position: fixed',
'bottom: 16px',
'right: 16px',
'padding: 12px 16px',
'border-radius: 4px',
'border: 1px solid #30363d',
'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
'max-width: 360px',
'opacity: 0',
'transition: opacity 0.3s ease',
].join('; ');
}
const actionLabel = action === 'update' ? 'Update' : 'Save';
const displayUser = username ? ` (${username})` : '';
container.innerHTML = `
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${actionLabel} login for <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>${escapeForHtml(displayUser)}?
</span>
<button id="idfoto-save-btn" style="
background:#1f6feb; color:#fff; border:none; padding:5px 14px;
border-radius:3px; cursor:pointer; font-family:inherit; font-size:12px;
white-space:nowrap;
">${actionLabel}</button>
<button id="idfoto-never-btn" style="
background:transparent; color:#8b949e; border:1px solid #30363d;
padding:5px 10px; border-radius:3px; cursor:pointer;
font-family:inherit; font-size:12px; white-space:nowrap;
">Never</button>
<button id="idfoto-close-btn" style="
background:transparent; color:#8b949e; border:none;
cursor:pointer; font-size:16px; padding:2px 6px;
font-family:inherit; line-height:1;
">\u2715</button>
`;
document.body.appendChild(container);
// Animate in
requestAnimationFrame(() => {
if (style === 'bar') {
container.style.transform = 'translateY(0)';
} else {
container.style.opacity = '1';
}
});
// Auto-dismiss for toast
let autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
if (style === 'toast') {
autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15000);
}
const clearAutoDismiss = (): void => {
if (autoDismissTimer) clearTimeout(autoDismissTimer);
};
// Save button
container.querySelector('#idfoto-save-btn')?.addEventListener('click', async () => {
clearAutoDismiss();
const now = new Date().toISOString();
if (action === 'update' && entryId) {
await sendMessage({
type: 'update_entry',
id: entryId,
entry: {
name: hostname,
url,
username,
password,
created_at: now,
updated_at: now,
},
});
} else {
await sendMessage({
type: 'add_entry',
entry: {
name: hostname,
url,
username,
password,
created_at: now,
updated_at: now,
},
});
}
// Show confirmation
const span = container.querySelector('span');
if (span) span.textContent = '\u2713 Saved';
const saveBtn = container.querySelector('#idfoto-save-btn') as HTMLElement | null;
const neverBtn = container.querySelector('#idfoto-never-btn') as HTMLElement | null;
if (saveBtn) saveBtn.style.display = 'none';
if (neverBtn) neverBtn.style.display = 'none';
setTimeout(() => removeExistingPrompt(), 1500);
});
// Never button
container.querySelector('#idfoto-never-btn')?.addEventListener('click', async () => {
clearAutoDismiss();
await sendMessage({ type: 'blacklist_site', hostname });
removeExistingPrompt();
});
// Close button
container.querySelector('#idfoto-close-btn')?.addEventListener('click', () => {
clearAutoDismiss();
removeExistingPrompt();
});
}
function escapeForHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --- Form hooking ---
export function hookForms(): void {
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
for (const pwField of passwordFields) {
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
const form = pwField.closest('form');
if (form && !hookedForms.has(form)) {
hookedForms.add(form);
form.addEventListener('submit', () => {
onFormSubmit(pwField);
});
}
// Hook submit buttons (for forms that submit via JS click handlers)
const scope = form ?? pwField.parentElement;
if (!scope) continue;
const buttons = scope.querySelectorAll<HTMLElement>(
'button[type="submit"], input[type="submit"], button:not([type])',
);
for (const btn of buttons) {
if (hookedButtons.has(btn)) continue;
hookedButtons.add(btn);
btn.addEventListener('click', () => {
onFormSubmit(pwField);
});
}
}
}

View File

@@ -0,0 +1,103 @@
/// Content script entry point.
///
/// Detects login forms on the page by finding password fields and their
/// associated username inputs. Injects small icons into detected fields
/// and sets up a fill listener to receive credentials from the service worker.
import { setupFillListener } from './fill';
import { injectFieldIcons } from './icon';
import { hookForms } from './capture';
/// Find password fields on the page and detect their associated username inputs.
function detectLoginForms(): Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> {
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
const forms: Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> = [];
for (const pwField of passwordFields) {
// Skip hidden or very small fields (likely honeypots).
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
const username = findUsernameField(pwField);
forms.push({ password: pwField, username });
}
return forms;
}
/// Find the most likely username field associated with a password field.
///
/// Priority:
/// 1. autocomplete="username" in the same form
/// 2. autocomplete="email" in the same form
/// 3. type="email" in the same form
/// 4. name/id matching /user|email|login|account/i in the same form
/// 5. Nearest preceding visible text input (sibling or DOM-adjacent)
function findUsernameField(pwField: HTMLInputElement): HTMLInputElement | null {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// 1. autocomplete="username"
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'username') return input;
}
// 2. autocomplete="email"
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'email') return input;
}
// 3. type="email"
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'email') return input;
}
// 4. name/id matching common patterns
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'hidden' || input.type === 'password') continue;
if (pattern.test(input.name) || pattern.test(input.id)) return input;
}
// 5. Nearest preceding visible text input
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0) return input;
}
return null;
}
/// Scan the page for login forms and inject icons.
function scan(): void {
const forms = detectLoginForms();
for (const { password, username } of forms) {
injectFieldIcons(password, username);
}
hookForms();
}
// --- Initialization ---
// Set up the fill listener (receives credentials from service worker).
setupFillListener();
// Initial scan.
scan();
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
const observer = new MutationObserver(() => {
scan();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});

View File

@@ -0,0 +1,88 @@
/// Fill listener — receives credentials from the service worker and fills form fields.
///
/// Uses the native value setter trick to work with React/Vue controlled inputs
/// that override the value property.
/// Set up a listener for fill_credentials messages from the service worker.
export function setupFillListener(): void {
chrome.runtime.onMessage.addListener(
(message: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => {
if (message.type !== 'fill_credentials') return false;
fillFields(message.username, message.password);
sendResponse({ ok: true });
return false;
},
);
}
/// Fill username and password fields on the page.
///
/// Finds the first visible password field and its associated username field,
/// then sets their values using the native setter trick for React/Vue compat.
export function fillFields(username: string, password: string): void {
const pwField = document.querySelector<HTMLInputElement>('input[type="password"]');
if (!pwField) return;
// Set the password.
setNativeValue(pwField, password);
// Find the username field (same logic as detector).
if (username) {
const usernameField = findUsernameForFill(pwField);
if (usernameField) {
setNativeValue(usernameField, username);
}
}
}
/// Use the native HTMLInputElement.value setter to bypass React/Vue wrappers.
/// Then dispatch input and change events so the framework picks up the change.
function setNativeValue(input: HTMLInputElement, value: string): void {
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
if (nativeSetter) {
nativeSetter.call(input, value);
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
/// Find the username field associated with a password field (simplified version for fill).
function findUsernameForFill(pwField: HTMLInputElement): HTMLInputElement | null {
const form = pwField.closest('form');
const scope = form ?? document;
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
// Priority: autocomplete > type=email > name pattern > preceding text input.
for (const input of inputs) {
if (input === pwField) continue;
if (input.autocomplete === 'username' || input.autocomplete === 'email') return input;
}
for (const input of inputs) {
if (input === pwField) continue;
if (input.type === 'email') return input;
}
const pattern = /user|email|login|account/i;
for (const input of inputs) {
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
if (pattern.test(input.name) || pattern.test(input.id)) return input;
}
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField);
for (let i = pwIndex - 1; i >= 0; i--) {
const input = allInputs[i];
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
if (input.offsetWidth > 0 && input.offsetHeight > 0) return input;
}
return null;
}

View File

@@ -0,0 +1,161 @@
/// Inject a small "id" icon into password fields for quick autofill access.
///
/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver).
import type { ManifestEntry } from '../shared/types';
/// Track which fields already have an injected icon.
const injected = new WeakSet<HTMLInputElement>();
/// Inject a small blue "id" icon at the right edge of a password field.
/// Clicking it queries for autofill candidates and either fills immediately
/// (single match) or shows an inline picker (multiple matches).
export function injectFieldIcons(
passwordField: HTMLInputElement,
_usernameField: HTMLInputElement | null,
): void {
if (injected.has(passwordField)) return;
injected.add(passwordField);
// Create the icon element.
const icon = document.createElement('div');
icon.textContent = 'id';
icon.setAttribute('role', 'button');
icon.setAttribute('aria-label', 'idfoto autofill');
Object.assign(icon.style, {
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
width: '20px',
height: '20px',
lineHeight: '20px',
textAlign: 'center',
fontSize: '10px',
fontWeight: '700',
fontFamily: 'monospace',
color: '#fff',
background: '#1f6feb',
borderRadius: '3px',
cursor: 'pointer',
zIndex: '999999',
userSelect: 'none',
});
// Ensure the password field's parent is positioned so the icon can be absolute.
const parent = passwordField.parentElement;
if (parent) {
const parentPosition = getComputedStyle(parent).position;
if (parentPosition === 'static') {
parent.style.position = 'relative';
}
}
// Insert the icon after the password field.
passwordField.insertAdjacentElement('afterend', icon);
// Click handler: query for autofill candidates.
icon.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const url = window.location.href;
const resp = await chrome.runtime.sendMessage({
type: 'get_autofill_candidates',
url,
});
if (!resp || !resp.ok) return;
const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
if (candidates.length === 0) return;
if (candidates.length === 1) {
// Single match — fill immediately.
const [id] = candidates[0];
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
} else {
// Multiple matches — show inline picker.
showPicker(icon, candidates);
}
});
}
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
function showPicker(
anchor: HTMLElement,
candidates: Array<[string, ManifestEntry]>,
): void {
// Remove any existing picker.
document.querySelectorAll('.idfoto-picker').forEach(el => el.remove());
const picker = document.createElement('div');
picker.className = 'idfoto-picker';
Object.assign(picker.style, {
position: 'absolute',
right: '0',
top: '100%',
marginTop: '4px',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: '9999999',
minWidth: '180px',
maxHeight: '200px',
overflowY: 'auto',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '12px',
});
for (const [id, entry] of candidates) {
const row = document.createElement('div');
row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`;
Object.assign(row.style, {
padding: '8px 12px',
cursor: 'pointer',
color: '#c9d1d9',
borderBottom: '1px solid #21262d',
});
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', async (e) => {
e.stopPropagation();
picker.remove();
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
});
picker.appendChild(row);
}
anchor.parentElement?.appendChild(picker);
// Close picker on outside click.
const closeHandler = (e: MouseEvent) => {
if (!picker.contains(e.target as Node) && e.target !== anchor) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
}

View File

@@ -0,0 +1,255 @@
/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { ManifestEntry } from '../../shared/types';
let totpInterval: ReturnType<typeof setInterval> | null = null;
function stopTotpTimer(): void {
if (totpInterval !== null) {
clearInterval(totpInterval);
totpInterval = null;
}
}
async function copyToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for older browsers.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
export function renderEntryDetail(app: HTMLElement): void {
const state = getState();
const entry = state.selectedEntry;
const id = state.selectedId;
if (!entry || !id) {
navigate('list');
return;
}
stopTotpTimer();
let html = `
<div class="detail-header">
<span class="detail-title">${escapeHtml(entry.name)}</span>
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
</div>
`;
// URL
if (entry.url) {
html += `
<div class="field">
<div class="label">url</div>
<div class="field-value">${escapeHtml(entry.url)}</div>
</div>
`;
}
// Username
if (entry.username) {
html += `
<div class="field">
<div class="label">username</div>
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
</div>
`;
}
// Password (masked by default)
html += `
<div class="field">
<div class="label">password</div>
<div class="field-value" id="password-val" style="cursor:pointer;">
<span id="password-display">********</span>
</div>
</div>
`;
// TOTP
if (entry.totp_secret) {
html += `
<div class="field">
<div class="label">totp</div>
<div class="totp-code" id="totp-code">------</div>
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
</div>
`;
}
// Notes
if (entry.notes) {
html += `
<div class="field">
<div class="label">notes</div>
<div class="field-value">${escapeHtml(entry.notes)}</div>
</div>
`;
}
// Group
if (entry.group) {
html += `
<div class="field">
<div class="label">group</div>
<div class="field-value">${escapeHtml(entry.group)}</div>
</div>
`;
}
// Metadata
html += `
<div class="field">
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
</div>
`;
// Key hints
html += `
<div class="keyhints">
<span><kbd>c</kbd> copy user</span>
<span><kbd>p</kbd> copy pass</span>
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
<span><kbd>f</kbd> autofill</span>
<span><kbd>e</kbd> edit</span>
<span><kbd>d</kbd> delete</span>
</div>
`;
app.innerHTML = html;
// --- Password toggle ---
let passwordVisible = false;
const passwordDisplay = document.getElementById('password-display')!;
const passwordVal = document.getElementById('password-val')!;
passwordVal?.addEventListener('click', () => {
passwordVisible = !passwordVisible;
passwordDisplay.textContent = passwordVisible ? entry.password : '********';
});
// --- Back button ---
document.getElementById('back-btn')?.addEventListener('click', goBack);
// --- TOTP timer ---
if (entry.totp_secret) {
refreshTotp(id);
totpInterval = setInterval(() => refreshTotp(id), 1000);
}
// --- Keyboard shortcuts ---
const handler = async (e: KeyboardEvent) => {
// Ignore if typing in an input.
if ((e.target as HTMLElement).tagName === 'INPUT') return;
switch (e.key) {
case 'Escape':
document.removeEventListener('keydown', handler);
goBack();
break;
case 'c':
if (entry.username) await copyToClipboard(entry.username);
break;
case 'p':
await copyToClipboard(entry.password);
break;
case 't':
if (entry.totp_secret) {
const codeEl = document.getElementById('totp-code');
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
}
break;
case 'f': {
const resp = await sendMessage({
type: 'fill_credentials',
username: entry.username ?? '',
password: entry.password,
});
if (!resp.ok) setState({ error: resp.error });
break;
}
case 'e':
document.removeEventListener('keydown', handler);
stopTotpTimer();
navigate('edit');
break;
case 'd':
e.preventDefault();
showDeleteConfirm(id, entry.name, handler);
break;
}
};
document.addEventListener('keydown', handler);
}
async function refreshTotp(id: string): Promise<void> {
const resp = await sendMessage({ type: 'get_totp', id });
if (resp.ok) {
const data = resp.data as { code: string; remaining_seconds: number };
const codeEl = document.getElementById('totp-code');
const barEl = document.getElementById('totp-bar-fill');
if (codeEl) codeEl.textContent = data.code;
if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`;
}
}
function goBack(): void {
stopTotpTimer();
// Reload the entry list.
sendMessage({ type: 'list_entries' }).then(resp => {
if (resp.ok) {
const data = resp.data as { entries: Array<[string, ManifestEntry]> };
navigate('list', {
entries: data.entries,
selectedId: null,
selectedEntry: null,
});
}
});
}
function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
<button class="btn" id="cancel-delete">cancel</button>
<button class="btn btn-danger" id="confirm-delete">delete</button>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('cancel-delete')?.addEventListener('click', () => {
overlay.remove();
});
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
overlay.remove();
setState({ loading: true });
const resp = await sendMessage({ type: 'delete_entry', id });
if (resp.ok) {
document.removeEventListener('keydown', parentHandler);
stopTotpTimer();
goBack();
} else {
setState({ loading: false, error: resp.error });
}
});
}

View File

@@ -0,0 +1,142 @@
/// Entry form — add or edit an entry.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { Entry, ManifestEntry } from '../../shared/types';
export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const state = getState();
const existing = mode === 'edit' ? state.selectedEntry : null;
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label class="label" for="f-name">name *</label>
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
</div>
<div class="form-group">
<label class="label" for="f-url">url</label>
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
</div>
<div class="form-group">
<label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
</div>
<div class="form-group">
<label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
<button class="btn" id="gen-btn" title="generate">gen</button>
</div>
</div>
<div class="form-group">
<label class="label" for="f-totp">totp secret</label>
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
</div>
<div class="form-group">
<label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
</div>
<div class="form-group">
<label class="label" for="f-notes">notes</label>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
</div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div>
</div>
`;
// --- Generate password ---
document.getElementById('gen-btn')?.addEventListener('click', async () => {
const resp = await sendMessage({ type: 'generate_password', length: 24 });
if (resp.ok) {
const data = resp.data as { password: string };
const pwInput = document.getElementById('f-password') as HTMLInputElement;
pwInput.value = data.password;
pwInput.type = 'text'; // Show generated password.
}
});
// --- Cancel ---
document.getElementById('cancel-btn')?.addEventListener('click', () => {
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
navigate('detail');
} else {
navigate('list');
}
});
// --- Save ---
document.getElementById('save-btn')?.addEventListener('click', async () => {
const name = (document.getElementById('f-name') as HTMLInputElement).value.trim();
const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined;
const password = (document.getElementById('f-password') as HTMLInputElement).value;
const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined;
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;
if (!name) {
setState({ error: 'Name is required' });
return;
}
if (!password) {
setState({ error: 'Password is required' });
return;
}
const now = new Date().toISOString();
const entry: Entry = {
name,
url,
username,
password,
notes,
totp_secret,
group,
created_at: existing?.created_at ?? now,
updated_at: now,
};
setState({ loading: true, error: null });
let resp;
if (mode === 'add') {
resp = await sendMessage({ type: 'add_entry', entry });
} else {
resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry });
}
if (resp.ok) {
// Refresh entries and go to list.
const listResp = await sendMessage({ type: 'list_entries' });
if (listResp.ok) {
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null });
} else {
navigate('list');
}
} else {
setState({ loading: false, error: resp.error });
}
});
// --- Escape to cancel ---
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
navigate('detail');
} else {
navigate('list');
}
}
};
document.addEventListener('keydown', escHandler);
// Focus the name field.
(document.getElementById('f-name') as HTMLInputElement)?.focus();
}

View File

@@ -0,0 +1,176 @@
/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { ManifestEntry } from '../../shared/types';
/// Extract the domain from a URL for display.
function domainOf(url: string | undefined): string {
if (!url) return '';
try {
return new URL(url).hostname;
} catch {
return '';
}
}
/// Derive unique group names from the current entries.
function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
const groups = new Set<string>();
for (const [, e] of entries) {
if (e.group) groups.add(e.group);
}
return Array.from(groups).sort();
}
export function renderEntryList(app: HTMLElement): void {
const state = getState();
const groups = getGroups(state.entries);
const filtered = getFilteredEntries();
const groupTabsHtml = groups.length > 0
? `<div class="group-tabs">
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
${groups.map(g =>
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
).join('')}
</div>`
: '';
const entriesHtml = filtered.length > 0
? filtered.map(([id, e], i) => `
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
<span class="entry-name">${escapeHtml(e.name)}</span>
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
</div>
`).join('')
: '<div class="empty">no entries</div>';
app.innerHTML = `
<div class="search-bar">
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
</div>
${groupTabsHtml}
<div class="entry-list" id="entry-list">
${entriesHtml}
</div>
<div class="keyhints">
<span><kbd>/</kbd> search</span>
<span><kbd>+</kbd> add</span>
<span><kbd>&uarr;&darr;</kbd> nav</span>
<span><kbd>Enter</kbd> open</span>
</div>
`;
// --- Event listeners ---
const searchInput = document.getElementById('search-input') as HTMLInputElement;
searchInput?.addEventListener('input', () => {
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
});
// Group tab clicks.
const groupTabs = app.querySelectorAll('.group-tab');
groupTabs.forEach(tab => {
tab.addEventListener('click', () => {
const group = (tab as HTMLElement).dataset.group || null;
setState({ activeGroup: group, selectedIndex: 0 });
});
});
// Entry row clicks.
const rows = app.querySelectorAll('.entry-row');
rows.forEach(row => {
row.addEventListener('click', async () => {
const id = (row as HTMLElement).dataset.id!;
await openEntry(id);
});
});
// Keyboard navigation.
document.addEventListener('keydown', handleListKeydown);
// Focus search on / key (unless already focused).
searchInput?.focus();
}
async function openEntry(id: string): Promise<void> {
setState({ loading: true });
const resp = await sendMessage({ type: 'get_entry', id });
if (resp.ok) {
const data = resp.data as { entry: import('../../shared/types').Entry };
navigate('detail', {
selectedId: id,
selectedEntry: data.entry,
});
} else {
setState({ loading: false, error: resp.error });
}
}
/// Compute the visible (filtered) entry list from current state.
function getFilteredEntries(): Array<[string, ManifestEntry]> {
const state = getState();
let filtered = state.entries;
if (state.activeGroup) {
const g = state.activeGroup.toLowerCase();
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
}
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
filtered = filtered.filter(([, e]) => {
if (e.name.toLowerCase().includes(q)) return true;
if (e.url?.toLowerCase().includes(q)) return true;
if (e.username?.toLowerCase().includes(q)) return true;
return false;
});
}
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
return filtered;
}
function handleListKeydown(e: KeyboardEvent): void {
const state = getState();
const target = e.target as HTMLElement;
const isSearch = target.id === 'search-input';
if (e.key === '/' && !isSearch) {
e.preventDefault();
(document.getElementById('search-input') as HTMLInputElement)?.focus();
return;
}
if (e.key === '+' && !isSearch) {
e.preventDefault();
navigate('add');
return;
}
const filtered = getFilteredEntries();
if (e.key === 'ArrowDown') {
e.preventDefault();
const max = Math.max(filtered.length - 1, 0);
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
return;
}
if (e.key === 'Enter' && !isSearch) {
e.preventDefault();
if (filtered[state.selectedIndex]) {
openEntry(filtered[state.selectedIndex][0]);
}
return;
}
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleListKeydown);
window.close();
return;
}
}

View File

@@ -0,0 +1,98 @@
/// Settings view — capture toggle, prompt style, and blacklist management.
import { sendMessage, navigate, escapeHtml } from '../popup';
import type { IdfotoSettings } from '../../shared/types';
export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
// Load settings and blacklist in parallel
const [settingsResp, blacklistResp] = await Promise.all([
sendMessage({ type: 'get_settings' }),
sendMessage({ type: 'get_blacklist' }),
]);
const settings: IdfotoSettings = settingsResp.ok
? (settingsResp.data as { settings: IdfotoSettings }).settings
: { captureEnabled: false, captureStyle: 'bar' };
const blacklist: string[] = blacklistResp.ok
? (blacklistResp.data as { blacklist: string[] }).blacklist
: [];
const blacklistHtml = blacklist.length > 0
? blacklist.map((h) => `
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
<button class="idfoto-remove-bl" data-hostname="${escapeHtml(h)}" style="
background:transparent; color:#f85149; border:none; cursor:pointer;
font-size:11px; padding:2px 6px;
">remove</button>
</div>
`).join('')
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div style="display:flex; align-items:center; margin-bottom:16px;">
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">&larr;</button>
<span style="font-size:14px; font-weight:600;">settings</span>
</div>
<div style="margin-bottom:16px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
auto-detect logins
</label>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
<div style="display:flex; gap:8px;">
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#1f6feb; color:#fff;' : ''}">bar</button>
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#1f6feb; color:#fff;' : ''}">toast</button>
</div>
</div>
<div>
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
<div id="blacklist-container">
${blacklistHtml}
</div>
</div>
</div>
`;
// Back button
document.getElementById('settings-back')?.addEventListener('click', () => {
navigate('locked');
});
// Capture enabled toggle
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
const checked = (e.target as HTMLInputElement).checked;
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
});
// Style buttons
document.getElementById('style-bar')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
renderSettings(app);
});
document.getElementById('style-toast')?.addEventListener('click', async () => {
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
renderSettings(app);
});
// Blacklist remove buttons
document.querySelectorAll('.idfoto-remove-bl').forEach((btn) => {
btn.addEventListener('click', async () => {
const hostname = (btn as HTMLElement).dataset.hostname;
if (hostname) {
await sendMessage({ type: 'remove_blacklist', hostname });
renderSettings(app);
}
});
});
}

View File

@@ -0,0 +1,30 @@
/// Setup prompt — directs users to the full-page setup wizard.
///
/// The popup is too constrained for file pickers and multi-step forms
/// (Chrome closes it when focus shifts). All real setup happens in
/// setup.html, which pushes config to chrome.storage.local when done.
import { escapeHtml } from '../popup';
export function renderSetupWizard(app: HTMLElement): void {
app.innerHTML = `
<div class="pad" style="padding-top:24px;text-align:center;">
<div class="brand" style="font-size:16px;margin-bottom:4px;">idfoto</div>
<p class="secondary" style="margin-bottom:20px;">two-factor vault</p>
<p class="muted" style="margin-bottom:16px;font-size:11px;line-height:1.6;">
No vault configured yet. Open the setup wizard to
create a new vault or connect to an existing one.
</p>
<button class="btn btn-primary" id="open-setup-btn" style="width:100%;">
open setup wizard
</button>
</div>
`;
document.getElementById('open-setup-btn')?.addEventListener('click', () => {
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
window.close();
});
}

View File

@@ -0,0 +1,56 @@
/// Unlock view — passphrase input with ENTER to submit.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
import type { ManifestEntry } from '../../shared/types';
export function renderUnlock(app: HTMLElement): void {
const state = getState();
app.innerHTML = `
<div class="pad" style="text-align:center; padding-top:40px;">
<div class="brand">idfoto</div>
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
<div class="form-group">
<input
type="password"
id="passphrase-input"
placeholder="passphrase"
autocomplete="off"
${state.loading ? 'disabled' : ''}
>
</div>
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div style="margin-top:24px;">
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
</div>
</div>
`;
const input = document.getElementById('passphrase-input') as HTMLInputElement;
if (input && !state.loading) {
input.focus();
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
const passphrase = input.value;
if (!passphrase) return;
setState({ loading: true, error: null });
const resp = await sendMessage({ type: 'unlock', passphrase });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_entries' });
if (listResp.ok) {
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
navigate('list', { entries: data.entries });
} else {
setState({ loading: false, error: listResp.error });
}
} else {
setState({ loading: false, error: resp.error });
}
}
});
}
const settingsBtn = document.getElementById('settings-btn');
settingsBtn?.addEventListener('click', () => navigate('settings'));
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=360">
<link rel="stylesheet" href="styles.css">
<title>idfoto</title>
</head>
<body>
<div id="app"></div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,137 @@
/// Popup entry point — state machine with view routing.
///
/// Views: setup | locked | list | detail | add | edit
/// Navigation works by updating `currentState` and calling `render()`.
import type { Request, Response } from '../shared/messages';
import type { ManifestEntry, Entry } from '../shared/types';
import { renderUnlock } from './components/unlock';
import { renderEntryList } from './components/entry-list';
import { renderEntryDetail } from './components/entry-detail';
import { renderEntryForm } from './components/entry-form';
import { renderSetupWizard } from './components/setup-wizard';
import { renderSettings } from './components/settings';
// --- Escape HTML to prevent XSS ---
export function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --- State ---
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
export interface PopupState {
view: View;
entries: Array<[string, ManifestEntry]>;
selectedId: string | null;
selectedEntry: Entry | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
}
let currentState: PopupState = {
view: 'locked',
entries: [],
selectedId: null,
selectedEntry: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
error: null,
loading: false,
};
export function getState(): PopupState {
return currentState;
}
export function setState(partial: Partial<PopupState>): void {
currentState = { ...currentState, ...partial };
render();
}
// --- Messaging ---
export function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
resolve(response);
});
});
}
// --- Navigation ---
export function navigate(view: View, extras?: Partial<PopupState>): void {
setState({ view, error: null, loading: false, ...extras });
}
// --- Render ---
function render(): void {
const app = document.getElementById('app');
if (!app) return;
switch (currentState.view) {
case 'setup':
renderSetupWizard(app);
break;
case 'locked':
renderUnlock(app);
break;
case 'list':
renderEntryList(app);
break;
case 'detail':
renderEntryDetail(app);
break;
case 'add':
renderEntryForm(app, 'add');
break;
case 'edit':
renderEntryForm(app, 'edit');
break;
case 'settings':
renderSettings(app);
break;
}
}
// --- Init ---
async function init(): Promise<void> {
// Check if extension is configured.
const setupResp = await sendMessage({ type: 'get_setup_state' });
if (setupResp.ok) {
const data = setupResp.data as { isConfigured: boolean };
if (!data.isConfigured) {
navigate('setup');
return;
}
}
// Check if vault is unlocked.
const unlockResp = await sendMessage({ type: 'is_unlocked' });
if (unlockResp.ok) {
const data = unlockResp.data as { unlocked: boolean };
if (data.unlocked) {
// Load entries and go to list.
const listResp = await sendMessage({ type: 'list_entries' });
if (listResp.ok) {
const listData = listResp.data as { entries: Array<[string, ManifestEntry]> };
navigate('list', { entries: listData.entries });
return;
}
}
}
navigate('locked');
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,454 @@
/* idfoto extension — terminal dark theme */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 360px;
max-height: 500px;
overflow-y: auto;
background: #0d1117;
color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.5;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 2px;
}
/* Typography */
.brand {
font-size: 16px;
font-weight: 700;
color: #58a6ff;
letter-spacing: 1px;
}
.label {
font-size: 11px;
font-weight: 600;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.secondary {
color: #8b949e;
}
.muted {
color: #484f58;
font-size: 11px;
}
.error {
color: #f85149;
font-size: 12px;
margin-top: 8px;
}
/* Buttons */
.btn {
display: inline-block;
padding: 6px 14px;
font-family: inherit;
font-size: 12px;
border: 1px solid #30363d;
border-radius: 4px;
background: #21262d;
color: #c9d1d9;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover {
background: #30363d;
}
.btn:focus {
outline: 1px solid #58a6ff;
outline-offset: 1px;
}
.btn-primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
.btn-primary:hover {
background: #388bfd;
}
.btn-danger {
background: #da3633;
border-color: #da3633;
color: #fff;
}
.btn-danger:hover {
background: #f85149;
}
/* Inputs */
input, textarea, select {
width: 100%;
padding: 8px 10px;
font-family: inherit;
font-size: 13px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
outline: none;
transition: border-color 0.15s;
}
input:focus, textarea:focus, select:focus {
border-color: #58a6ff;
}
input::placeholder, textarea::placeholder {
color: #484f58;
}
textarea {
resize: vertical;
min-height: 60px;
}
/* Layout */
.pad {
padding: 16px;
}
/* Search bar */
.search-bar {
position: sticky;
top: 0;
z-index: 10;
padding: 8px 12px;
background: #0d1117;
border-bottom: 1px solid #21262d;
}
.search-bar input {
padding: 6px 10px;
font-size: 12px;
}
/* Group tabs */
.group-tabs {
display: flex;
gap: 2px;
padding: 6px 12px;
background: #0d1117;
border-bottom: 1px solid #21262d;
overflow-x: auto;
}
.group-tab {
padding: 4px 10px;
font-size: 11px;
border: none;
border-radius: 3px;
background: transparent;
color: #8b949e;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.group-tab:hover {
color: #c9d1d9;
background: #161b22;
}
.group-tab.active {
color: #58a6ff;
background: #161b22;
}
/* Entry list */
.entry-list {
max-height: 360px;
overflow-y: auto;
}
.entry-row {
display: flex;
flex-direction: column;
padding: 8px 12px;
border-bottom: 1px solid #21262d;
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s;
}
.entry-row:hover {
background: #161b22;
}
.entry-row.selected {
background: #161b22;
border-left-color: #58a6ff;
}
.entry-row .entry-name {
font-size: 13px;
font-weight: 500;
color: #c9d1d9;
}
.entry-row .entry-meta {
font-size: 11px;
color: #8b949e;
margin-top: 2px;
}
/* Detail view */
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid #21262d;
}
.detail-title {
font-size: 15px;
font-weight: 600;
color: #c9d1d9;
}
.field {
padding: 10px 12px;
border-bottom: 1px solid #21262d;
}
.field-value {
font-size: 13px;
color: #c9d1d9;
word-break: break-all;
user-select: all;
}
/* TOTP */
.totp-code {
font-size: 22px;
font-weight: 700;
color: #3fb950;
letter-spacing: 4px;
}
.totp-bar {
height: 3px;
background: #21262d;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.totp-bar-fill {
height: 100%;
background: #3fb950;
border-radius: 2px;
transition: width 1s linear;
}
/* Keyboard hints */
.keyhints {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 8px 12px;
border-top: 1px solid #21262d;
background: #0d1117;
}
.keyhints span {
font-size: 10px;
color: #484f58;
}
.keyhints kbd {
display: inline-block;
padding: 1px 4px;
font-family: inherit;
font-size: 10px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 3px;
color: #8b949e;
}
/* Wizard */
.wizard-step {
padding: 16px;
}
.wizard-step h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: #c9d1d9;
}
.progress-bar {
display: flex;
gap: 4px;
padding: 12px 16px 0;
}
.progress-bar .step {
flex: 1;
height: 3px;
background: #21262d;
border-radius: 2px;
}
.progress-bar .step.done {
background: #58a6ff;
}
.progress-bar .step.current {
background: #388bfd;
}
/* Spinner */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #30363d;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Confirm overlay */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.confirm-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
max-width: 280px;
text-align: center;
}
.confirm-box p {
margin-bottom: 16px;
font-size: 13px;
}
.confirm-box .btn + .btn {
margin-left: 8px;
}
/* Empty state */
.empty {
text-align: center;
padding: 40px 16px;
color: #484f58;
font-size: 13px;
}
/* Form layout */
.form-group {
margin-bottom: 12px;
}
.form-group .label {
display: block;
margin-bottom: 4px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.inline-row {
display: flex;
gap: 8px;
align-items: center;
}
.inline-row input {
flex: 1;
}
/* Toggle (for host type) */
.toggle-group {
display: flex;
gap: 0;
border: 1px solid #30363d;
border-radius: 4px;
overflow: hidden;
}
.toggle-group button {
flex: 1;
padding: 6px 12px;
font-family: inherit;
font-size: 12px;
border: none;
background: #21262d;
color: #8b949e;
cursor: pointer;
}
.toggle-group button.active {
background: #1f6feb;
color: #fff;
}
/* File upload area */
.file-drop {
border: 2px dashed #30363d;
border-radius: 6px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s;
}
.file-drop:hover {
border-color: #58a6ff;
}
.file-drop.has-file {
border-color: #3fb950;
border-style: solid;
}

View File

@@ -0,0 +1,54 @@
/// Abstract interface for reading/writing vault files on a git host.
///
/// Both Gitea and GitHub expose a "repo contents" REST API that lets us
/// read, write, and delete individual files without cloning the repo.
/// This interface captures just the operations the vault needs.
export interface GitHost {
/// Read a single file from the repo, returning its raw bytes.
readFile(path: string): Promise<Uint8Array>;
/// Create or update a file in the repo with a commit message.
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
/// Delete a file from the repo with a commit message.
deleteFile(path: string, message: string): Promise<void>;
/// List file names in a directory (non-recursive).
listDir(path: string): Promise<string[]>;
}
/// Convert a Uint8Array to a base64 string (works in service worker context).
export function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/// Convert a base64 string to a Uint8Array.
export function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/// Factory function that returns the appropriate GitHost implementation.
import { GiteaHost } from './gitea';
import { GitHubHost } from './github';
export function createGitHost(
hostType: 'gitea' | 'github',
hostUrl: string,
repoPath: string,
apiToken: string,
): GitHost {
if (hostType === 'gitea') {
return new GiteaHost(hostUrl, repoPath, apiToken);
}
return new GitHubHost(repoPath, apiToken);
}

View File

@@ -0,0 +1,114 @@
import type { GitHost } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
/// Gitea Contents API implementation.
///
/// Endpoints:
/// GET {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
/// POST {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (create)
/// PUT {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (update)
/// DELETE {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
///
/// Auth: `token {apiToken}` header.
/// Content is base64-encoded in both request and response bodies.
/// Updates and deletes require the current file SHA.
export class GiteaHost implements GitHost {
private baseUrl: string;
private headers: Record<string, string>;
constructor(hostUrl: string, repoPath: string, apiToken: string) {
// Remove trailing slash from hostUrl
const host = hostUrl.replace(/\/+$/, '');
this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
this.headers = {
'Authorization': `token ${apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
async readFile(path: string): Promise<Uint8Array> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
throw new Error(`Gitea readFile ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
// Gitea returns base64 content with possible newlines
const clean = (json.content as string).replace(/\n/g, '');
return base64ToUint8Array(clean);
}
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
const b64 = uint8ArrayToBase64(content);
// Try to get the current SHA for an update; if 404 it's a create.
let sha: string | null = null;
try {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (existing.ok) {
const json = await existing.json();
sha = json.sha as string;
}
} catch {
// File does not exist — will create.
}
const body: Record<string, string> = { content: b64, message };
if (sha) {
body.sha = sha;
}
const method = sha ? 'PUT' : 'POST';
const resp = await fetch(`${this.baseUrl}/${path}`, {
method,
headers: this.headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Gitea writeFile ${path}: ${resp.status} ${text}`);
}
}
async deleteFile(path: string, message: string): Promise<void> {
// Need the current SHA to delete.
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!existing.ok) {
throw new Error(`Gitea deleteFile ${path}: file not found (${existing.status})`);
}
const json = await existing.json();
const sha = json.sha as string;
const resp = await fetch(`${this.baseUrl}/${path}`, {
method: 'DELETE',
headers: this.headers,
body: JSON.stringify({ message, sha }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Gitea deleteFile ${path}: ${resp.status} ${text}`);
}
}
async listDir(path: string): Promise<string[]> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
if (resp.status === 404) return [];
throw new Error(`Gitea listDir ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (!Array.isArray(json)) return [];
return json.map((item: { name: string }) => item.name);
}
}

View File

@@ -0,0 +1,108 @@
import type { GitHost } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
/// GitHub Contents API implementation.
///
/// Endpoints:
/// GET https://api.github.com/repos/{repoPath}/contents/{path}
/// PUT https://api.github.com/repos/{repoPath}/contents/{path} (create/update)
/// DELETE https://api.github.com/repos/{repoPath}/contents/{path}
///
/// Auth: `Bearer {apiToken}` header.
/// Content is base64-encoded. Updates and deletes require the current file SHA.
export class GitHubHost implements GitHost {
private baseUrl: string;
private headers: Record<string, string>;
constructor(repoPath: string, apiToken: string) {
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': '2022-11-28',
};
}
async readFile(path: string): Promise<Uint8Array> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
throw new Error(`GitHub readFile ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
const clean = (json.content as string).replace(/\n/g, '');
return base64ToUint8Array(clean);
}
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
const b64 = uint8ArrayToBase64(content);
// Try to get the current SHA for an update; if 404 it's a create.
let sha: string | null = null;
try {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (existing.ok) {
const json = await existing.json();
sha = json.sha as string;
}
} catch {
// File does not exist — will create.
}
const body: Record<string, unknown> = { content: b64, message };
if (sha) {
body.sha = sha;
}
const resp = await fetch(`${this.baseUrl}/${path}`, {
method: 'PUT',
headers: this.headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`GitHub writeFile ${path}: ${resp.status} ${text}`);
}
}
async deleteFile(path: string, message: string): Promise<void> {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!existing.ok) {
throw new Error(`GitHub deleteFile ${path}: file not found (${existing.status})`);
}
const json = await existing.json();
const sha = json.sha as string;
const resp = await fetch(`${this.baseUrl}/${path}`, {
method: 'DELETE',
headers: this.headers,
body: JSON.stringify({ message, sha }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`GitHub deleteFile ${path}: ${resp.status} ${text}`);
}
}
async listDir(path: string): Promise<string[]> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
if (resp.status === 404) return [];
throw new Error(`GitHub listDir ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (!Array.isArray(json)) return [];
return json.map((item: { name: string }) => item.name);
}
}

View File

@@ -0,0 +1,441 @@
/// Background script entry point for the idfoto browser extension.
///
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
/// as a persistent background script. WASM loading adapts automatically.
///
/// Loads the WASM module, manages vault state (master key, manifest, git host),
/// and routes all messages from the popup and content scripts.
import type { Request, Response } from '../shared/messages';
import type { Manifest, VaultConfig, SetupState, IdfotoSettings } from '../shared/types';
import { DEFAULT_SETTINGS } from '../shared/types';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
import { base64ToUint8Array } from './git-host';
import * as vault from './vault';
// --- State held in memory (cleared on lock or service worker restart) ---
let masterKey: Uint8Array | null = null;
let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
let wasmReady = false;
// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second
const totpSecretCache: Map<string, string> = new Map();
// --- WASM initialization ---
// Chrome MV3 uses service workers which do NOT support dynamic import().
// Firefox MV3 uses background scripts which DO support dynamic import().
// We detect the environment at runtime and use the appropriate loading strategy.
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/idfoto_wasm.js';
// @ts-ignore TS2307
import * as wasmBindings from '../../wasm/idfoto_wasm.js';
type WasmModule = typeof wasmBindings;
let wasm: WasmModule | null = null;
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
const isServiceWorker = typeof SWGlobalScope !== 'undefined'
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
if (isServiceWorker) {
// Chrome: fetch WASM binary and instantiate synchronously
const wasmResponse = await fetch(chrome.runtime.getURL('idfoto_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script — async init works
const wasmUrl = chrome.runtime.getURL('idfoto_wasm_bg.wasm');
await initDefault(wasmUrl);
}
vault.setWasm(wasmBindings);
wasm = wasmBindings;
wasmReady = true;
return wasm;
}
// --- Storage helpers ---
async function loadConfig(): Promise<VaultConfig | null> {
const result = await chrome.storage.local.get('vaultConfig');
return (result.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const result = await chrome.storage.local.get('imageBase64');
return (result.imageBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
const config = await loadConfig();
const imageBase64 = await loadImageBase64();
return {
config,
imageBase64,
isConfigured: config !== null && imageBase64 !== null,
};
}
// --- Settings & blacklist helpers ---
async function loadSettings(): Promise<IdfotoSettings> {
const result = await chrome.storage.local.get('idfotoSettings');
return (result.idfotoSettings as IdfotoSettings) ?? { ...DEFAULT_SETTINGS };
}
async function saveSettings(settings: IdfotoSettings): Promise<void> {
await chrome.storage.local.set({ idfotoSettings: settings });
}
async function loadBlacklist(): Promise<string[]> {
const result = await chrome.storage.local.get('captureBlacklist');
return (result.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function ensureGitHost(config: VaultConfig): GitHost {
if (!gitHost) {
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
}
return gitHost;
}
// --- Message handler ---
chrome.runtime.onMessage.addListener(
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
handleMessage(request)
.then(sendResponse)
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
// Return true to indicate async response.
return true;
},
);
async function handleMessage(req: Request): Promise<Response> {
switch (req.type) {
// --- Auth ---
case 'is_unlocked':
return { ok: true, data: { unlocked: masterKey !== null } };
case 'unlock': {
const w = await initWasm();
const config = await loadConfig();
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
const imageB64 = await loadImageBase64();
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
const imageBytes = base64ToUint8Array(imageB64);
const imageSecret = w.extract_image_secret(imageBytes);
const git = ensureGitHost(config);
const meta = await vault.fetchVaultMeta(git);
const key = w.derive_master_key(
req.passphrase,
new Uint8Array(imageSecret),
meta.salt,
meta.paramsJson,
);
masterKey = new Uint8Array(key);
// Verify the key works by decrypting the manifest.
manifest = await vault.fetchAndDecryptManifest(git, masterKey);
return { ok: true };
}
case 'lock':
masterKey = null;
manifest = null;
totpSecretCache.clear();
return { ok: true };
// --- Entries ---
case 'list_entries': {
if (!manifest) return { ok: false, error: 'Vault is locked' };
const entries = vault.listEntries(manifest, req.group);
return { ok: true, data: { entries } };
}
case 'get_entry': {
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
return { ok: true, data: { entry } };
}
case 'search_entries': {
if (!manifest) return { ok: false, error: 'Vault is locked' };
const entries = vault.searchEntries(manifest, req.query);
return { ok: true, data: { entries } };
}
case 'add_entry': {
if (!masterKey || !gitHost || !manifest) {
return { ok: false, error: 'Vault is locked' };
}
const w = await initWasm();
const id = w.generate_entry_id();
await vault.encryptAndWriteEntry(
gitHost, masterKey, id, req.entry,
`add: ${req.entry.name}`,
);
manifest.entries[id] = {
name: req.entry.name,
url: req.entry.url,
username: req.entry.username,
group: req.entry.group,
updated_at: req.entry.updated_at,
};
await vault.encryptAndWriteManifest(
gitHost, masterKey, manifest,
`manifest: add ${req.entry.name}`,
);
return { ok: true, data: { id } };
}
case 'update_entry': {
if (!masterKey || !gitHost || !manifest) {
return { ok: false, error: 'Vault is locked' };
}
await vault.encryptAndWriteEntry(
gitHost, masterKey, req.id, req.entry,
`update: ${req.entry.name}`,
);
manifest.entries[req.id] = {
name: req.entry.name,
url: req.entry.url,
username: req.entry.username,
group: req.entry.group,
updated_at: req.entry.updated_at,
};
await vault.encryptAndWriteManifest(
gitHost, masterKey, manifest,
`manifest: update ${req.entry.name}`,
);
return { ok: true };
}
case 'delete_entry': {
if (!masterKey || !gitHost || !manifest) {
return { ok: false, error: 'Vault is locked' };
}
const name = manifest.entries[req.id]?.name ?? req.id;
await gitHost.deleteFile(`entries/${req.id}.enc`, `delete: ${name}`);
delete manifest.entries[req.id];
await vault.encryptAndWriteManifest(
gitHost, masterKey, manifest,
`manifest: delete ${name}`,
);
return { ok: true };
}
// --- TOTP ---
case 'get_totp': {
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
const w = await initWasm();
// Use cached TOTP secret to avoid re-fetching the entry every second
let totpSecret = totpSecretCache.get(req.id);
if (!totpSecret) {
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
totpSecret = entry.totp_secret;
totpSecretCache.set(req.id, totpSecret);
}
const now = Math.floor(Date.now() / 1000);
const code = w.generate_totp(totpSecret, BigInt(now));
const remaining = 30 - (now % 30);
return { ok: true, data: { code, remaining_seconds: remaining } };
}
// --- Autofill ---
case 'get_autofill_candidates': {
if (!manifest) return { ok: false, error: 'Vault is locked' };
const candidates = vault.findByUrl(manifest, req.url);
return { ok: true, data: { candidates } };
}
case 'get_credentials': {
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
return {
ok: true,
data: { username: entry.username ?? '', password: entry.password },
};
}
// --- Sync ---
case 'sync': {
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
// Re-fetch the manifest from the remote to pick up changes from other devices.
manifest = await vault.fetchAndDecryptManifest(gitHost, masterKey);
return { ok: true };
}
// --- Setup ---
case 'get_setup_state': {
const state = await loadSetupState();
return { ok: true, data: state };
}
case 'save_setup': {
await chrome.storage.local.set({
vaultConfig: req.config,
imageBase64: req.imageBase64,
});
// Reset git host so it picks up new config on next use.
gitHost = null;
return { ok: true };
}
// --- Password generation ---
case 'generate_password': {
const w = await initWasm();
const password = w.generate_password(req.length);
return { ok: true, data: { password } };
}
// --- Content script fill (forwarded to active tab) ---
case 'fill_credentials': {
// This is actually sent TO the content script, not FROM it.
// The popup sends this to the service worker, which forwards it.
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id) {
await chrome.tabs.sendMessage(tab.id, {
type: 'fill_credentials',
username: req.username,
password: req.password,
});
}
return { ok: true };
}
// --- Settings & blacklist ---
case 'get_settings': {
const settings = await loadSettings();
return { ok: true, data: { settings } };
}
case 'update_settings': {
const current = await loadSettings();
const updated = { ...current, ...req.settings };
await saveSettings(updated);
return { ok: true };
}
case 'get_blacklist': {
const blacklist = await loadBlacklist();
return { ok: true, data: { blacklist } };
}
case 'remove_blacklist': {
const bl = await loadBlacklist();
await saveBlacklist(bl.filter((h) => h !== req.hostname));
return { ok: true };
}
case 'blacklist_site': {
const bl2 = await loadBlacklist();
if (!bl2.includes(req.hostname)) {
bl2.push(req.hostname);
await saveBlacklist(bl2);
}
return { ok: true };
}
// --- Credential capture ---
case 'check_credential': {
// Skip if vault locked
if (!masterKey || !gitHost || !manifest) {
return { ok: true, data: { action: 'skip' } };
}
// Skip if capture disabled
const captureSettings = await loadSettings();
if (!captureSettings.captureEnabled) {
return { ok: true, data: { action: 'skip' } };
}
// Skip if hostname blacklisted
let checkHostname: string;
try {
checkHostname = new URL(req.url).hostname;
} catch {
return { ok: true, data: { action: 'skip' } };
}
const captureBlacklist = await loadBlacklist();
if (captureBlacklist.includes(checkHostname)) {
return { ok: true, data: { action: 'skip' } };
}
// Search manifest by hostname
const candidates = vault.findByUrl(manifest, req.url);
if (candidates.length === 0) {
return { ok: true, data: { action: 'save' } };
}
// Check for matching username
for (const [entryId, entry] of candidates) {
if (entry.username === req.username) {
// Same hostname + username — compare passwords
try {
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, entryId);
if (fullEntry.password === req.password) {
return { ok: true, data: { action: 'skip' } };
} else {
return { ok: true, data: { action: 'update', entryId, entryName: entry.name } };
}
} catch {
// If we can't decrypt, skip rather than error
return { ok: true, data: { action: 'skip' } };
}
}
}
// Same hostname, different username — new account
return { ok: true, data: { action: 'save' } };
}
default:
return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` };
}
}

View File

@@ -0,0 +1,137 @@
/// Vault operations module.
///
/// Bridges the WASM crypto functions with the git host API to provide
/// high-level vault operations: fetch/decrypt manifest, fetch/decrypt entries,
/// encrypt/write entries, search, and URL matching.
import type { GitHost } from './git-host';
import type { Entry, Manifest, ManifestEntry } from '../shared/types';
// WASM module reference — set once during init.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
/// Store the WASM module reference after initialization.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void {
wasm = w;
}
function requireWasm(): any {
if (!wasm) throw new Error('WASM module not initialized');
return wasm;
}
/// Vault metadata: salt and KDF params stored unencrypted in the repo.
export interface VaultMeta {
salt: Uint8Array;
paramsJson: string;
}
/// Read the vault salt and KDF params from the git repo.
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
const saltBytes = await git.readFile('.idfoto/salt');
const paramsRaw = await git.readFile('.idfoto/params.json');
const paramsJson = new TextDecoder().decode(paramsRaw);
return { salt: saltBytes, paramsJson };
}
/// Fetch and decrypt the manifest from the git repo.
export async function fetchAndDecryptManifest(
git: GitHost,
masterKey: Uint8Array,
): Promise<Manifest> {
const w = requireWasm();
const ciphertext = await git.readFile('manifest.enc');
const json = w.decrypt_manifest(ciphertext, masterKey);
return JSON.parse(json) as Manifest;
}
/// Fetch and decrypt a single entry from the git repo.
export async function fetchAndDecryptEntry(
git: GitHost,
masterKey: Uint8Array,
id: string,
): Promise<Entry> {
const w = requireWasm();
const ciphertext = await git.readFile(`entries/${id}.enc`);
const json = w.decrypt_entry(ciphertext, masterKey);
return JSON.parse(json) as Entry;
}
/// Encrypt an entry and write it to the git repo.
export async function encryptAndWriteEntry(
git: GitHost,
masterKey: Uint8Array,
id: string,
entry: Entry,
message: string,
): Promise<void> {
const w = requireWasm();
const entryJson = JSON.stringify(entry);
const ciphertext = w.encrypt_entry(entryJson, masterKey);
await git.writeFile(`entries/${id}.enc`, ciphertext, message);
}
/// Encrypt the manifest and write it to the git repo.
export async function encryptAndWriteManifest(
git: GitHost,
masterKey: Uint8Array,
manifest: Manifest,
message: string,
): Promise<void> {
const w = requireWasm();
const manifestJson = JSON.stringify(manifest);
const ciphertext = w.encrypt_manifest(manifestJson, masterKey);
await git.writeFile('manifest.enc', ciphertext, message);
}
/// Filter manifest entries by group (case-insensitive). If no group given, returns all.
export function listEntries(
manifest: Manifest,
group?: string,
): Array<[string, ManifestEntry]> {
const entries = Object.entries(manifest.entries);
if (!group) return entries;
const g = group.toLowerCase();
return entries.filter(([, e]) =>
e.group?.toLowerCase() === g
);
}
/// Case-insensitive substring search on name, url, and username.
export function searchEntries(
manifest: Manifest,
query: string,
): Array<[string, ManifestEntry]> {
const q = query.toLowerCase();
return Object.entries(manifest.entries).filter(([, e]) => {
if (e.name.toLowerCase().includes(q)) return true;
if (e.url?.toLowerCase().includes(q)) return true;
if (e.username?.toLowerCase().includes(q)) return true;
return false;
});
}
/// Find entries whose URL matches the given page URL by hostname.
export function findByUrl(
manifest: Manifest,
url: string,
): Array<[string, ManifestEntry]> {
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
return [];
}
return Object.entries(manifest.entries).filter(([, e]) => {
if (!e.url) return false;
try {
const entryHost = new URL(e.url).hostname;
return entryHost === hostname;
} catch {
return false;
}
});
}

View File

@@ -0,0 +1,592 @@
/// Vault initialization wizard — 4-step flow for creating new idfoto vaults.
///
/// Step 1: Choose host type (Gitea / GitHub)
/// Step 2: Configure connection (URL, repo, token) + test
/// Step 3: Create vault (carrier image, passphrase, generate secrets, push files)
/// Step 4: Finish (download reference image, push config to extension or copy JSON)
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types';
// --- WASM module (loaded dynamically) ---
type WasmModule = typeof import('idfoto-wasm');
let wasm: WasmModule | null = null;
async function loadWasm(): Promise<WasmModule> {
if (wasm) return wasm;
const mod = await import(
// @ts-ignore TS2307 — resolved at runtime, not by TS/webpack
/* webpackIgnore: true */ '../idfoto_wasm.js'
) as WasmModule & { default: (input?: string | URL) => Promise<void> };
await mod.default('../idfoto_wasm_bg.wasm');
wasm = mod;
return mod;
}
// --- State ---
interface WizardState {
step: number;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
connectionTested: boolean;
carrierImageBytes: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
referenceImageBytes: Uint8Array | null;
creating: boolean;
error: string | null;
extensionDetected: boolean;
configPushed: boolean;
}
const state: WizardState = {
step: 1,
hostType: 'gitea',
hostUrl: '',
repoPath: '',
apiToken: '',
connectionTested: false,
carrierImageBytes: null,
passphrase: '',
passphraseConfirm: '',
referenceImageBytes: null,
creating: false,
error: null,
extensionDetected: false,
configPushed: false,
};
// --- Helpers ---
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 14) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 1) return 'weak';
if (score <= 2) return 'fair';
if (score <= 3) return 'good';
return 'strong';
}
// --- Render ---
function render(): void {
const app = document.getElementById('app');
if (!app) return;
const progressHtml = `
<div class="progress-bar">
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
</div>
`;
let stepHtml = '';
switch (state.step) {
case 1: stepHtml = renderStep1(); break;
case 2: stepHtml = renderStep2(); break;
case 3: stepHtml = renderStep3(); break;
case 4: stepHtml = renderStep4(); break;
}
app.innerHTML = `
<div class="pad" style="padding-top:12px;">
<div class="brand" style="margin-bottom:4px;">idfoto vault setup</div>
${progressHtml}
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
${stepHtml}
</div>
`;
switch (state.step) {
case 1: attachStep1(); break;
case 2: attachStep2(); break;
case 3: attachStep3(); break;
case 4: attachStep4(); break;
}
}
// --- Step 1: Choose Host ---
function renderStep1(): string {
const giteaInstructions = `
<div class="step-instructions">
<ol>
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Applications</strong></li>
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol>
</div>
`;
const githubInstructions = `
<div class="step-instructions">
<ol>
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Developer settings &rarr; Personal access tokens &rarr; Fine-grained tokens</strong></li>
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol>
</div>
`;
return `
<div class="wizard-step">
<h3>choose host</h3>
<div class="form-group">
<label class="label">host type</label>
<div class="toggle-group">
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
</div>
</div>
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
<div class="form-actions">
<button class="btn btn-primary" id="next-btn">next</button>
</div>
</div>
`;
}
function attachStep1(): void {
document.querySelectorAll('.toggle-group button').forEach(btn => {
btn.addEventListener('click', () => {
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
state.connectionTested = false;
render();
});
});
document.getElementById('next-btn')?.addEventListener('click', () => {
state.step = 2;
state.error = null;
render();
});
}
// --- Step 2: Configure Connection ---
function renderStep2(): string {
return `
<div class="wizard-step">
<h3>configure connection</h3>
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
<label class="label" for="host-url">host url</label>
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
</div>
<div class="form-group">
<label class="label" for="repo-path">repository path</label>
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
</div>
<div class="form-group">
<label class="label" for="api-token">api token</label>
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
</div>
<div class="form-actions">
<button class="btn" id="test-btn">test connection</button>
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
</div>
<div class="form-actions" style="margin-top:12px;">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="next-btn" ${!state.connectionTested ? 'disabled' : ''}>next</button>
</div>
</div>
`;
}
function attachStep2(): void {
document.getElementById('test-btn')?.addEventListener('click', async () => {
const hostUrl = state.hostType === 'github'
? 'https://api.github.com'
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
if (!repoPath || !apiToken) {
state.error = 'Repository path and API token are required';
render();
return;
}
if (state.hostType === 'gitea' && !hostUrl) {
state.error = 'Host URL is required for Gitea';
render();
return;
}
state.hostUrl = hostUrl;
state.repoPath = repoPath;
state.apiToken = apiToken;
try {
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
await host.listDir('');
state.connectionTested = true;
state.error = null;
} catch (err: unknown) {
state.connectionTested = false;
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
}
render();
});
document.getElementById('back-btn')?.addEventListener('click', () => {
state.step = 1;
state.error = null;
render();
});
document.getElementById('next-btn')?.addEventListener('click', () => {
if (!state.connectionTested) return;
state.step = 3;
state.error = null;
render();
});
}
// --- Step 3: Create Vault ---
function renderStep3(): string {
const strength = state.passphrase ? passphraseStrength(state.passphrase) : null;
return `
<div class="wizard-step">
<h3>create vault</h3>
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
${state.carrierImageBytes
? '<p class="secondary">image loaded</p>'
: '<p class="secondary">click to select a JPEG photo</p>'}
</div>
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase">
${strength ? `
<div class="strength-bar">
<div class="strength-bar-fill ${strength}"></div>
</div>
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
` : ''}
</div>
<div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label>
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase">
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
</button>
</div>
</div>
`;
}
function attachStep3(): void {
const fileDrop = document.getElementById('file-drop')!;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
fileDrop.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
render();
};
reader.readAsArrayBuffer(file);
});
// Track passphrase changes without full re-render
document.getElementById('passphrase')?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
// Update strength bar inline
const strength = passphraseStrength(state.passphrase);
const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null;
const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null;
if (bar) {
bar.className = `strength-bar-fill ${strength}`;
}
if (label) {
label.textContent = `strength: ${strength}`;
}
if (!bar && state.passphrase) {
render();
}
});
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
state.passphraseConfirm = (e.target as HTMLInputElement).value;
});
document.getElementById('back-btn')?.addEventListener('click', () => {
state.step = 2;
state.error = null;
render();
});
document.getElementById('create-btn')?.addEventListener('click', async () => {
// Read current values from DOM
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
if (!state.carrierImageBytes) {
state.error = 'Please select a carrier JPEG image';
render();
return;
}
if (!state.passphrase) {
state.error = 'Passphrase is required';
render();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
render();
return;
}
state.creating = true;
state.error = null;
render();
try {
const w = await loadWasm();
// 1. Generate 32-byte image secret
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG
state.referenceImageBytes = new Uint8Array(
w.embed_image_secret(state.carrierImageBytes, imageSecret)
);
// 3. Generate 32-byte salt
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// 4. Create KDF params
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
// 5. Derive master key
const masterKey = w.derive_master_key(
state.passphrase,
imageSecret,
salt,
paramsJson,
);
// 6. Encrypt empty manifest
const manifestJson = '{"entries":{},"version":1}';
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey);
// 7. Push vault files via git API
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await host.writeFile(
'.idfoto/salt',
salt,
'init: vault salt',
);
const paramsBytes = new TextEncoder().encode(paramsJson);
await host.writeFile(
'.idfoto/params.json',
paramsBytes,
'init: KDF parameters',
);
const devicesJson = '{"devices":[]}';
const devicesBytes = new TextEncoder().encode(devicesJson);
await host.writeFile(
'.idfoto/devices.json',
devicesBytes,
'init: device registry',
);
await host.writeFile(
'manifest.enc',
new Uint8Array(encryptedManifest),
'init: encrypted manifest',
);
// 8. Advance to step 4
state.creating = false;
state.step = 4;
state.error = null;
// Detect extension
detectExtension();
render();
} catch (err: unknown) {
state.creating = false;
state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`;
render();
}
});
}
// --- Step 4: Finish ---
function detectExtension(): void {
try {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
// Try to ping the extension
chrome.runtime.sendMessage({ type: 'is_unlocked' }, (response) => {
if (chrome.runtime.lastError) {
state.extensionDetected = false;
} else {
state.extensionDetected = true;
}
render();
});
}
} catch {
state.extensionDetected = false;
}
}
function renderStep4(): string {
const config: VaultConfig = {
hostType: state.hostType,
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
const configJson = JSON.stringify(config, null, 2);
return `
<div class="wizard-step">
<div class="success-box">
<h3>vault created</h3>
<p class="secondary">Your vault has been initialized and pushed to the repository.</p>
</div>
<div class="form-group">
<label class="label">reference image</label>
<p class="muted" style="margin-bottom:8px;">
Download and store this image securely. It is your second factor for decryption.
Without it, you cannot unlock the vault.
</p>
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
</div>
${state.extensionDetected ? `
<div class="form-group" style="margin-top:16px;">
<label class="label">extension configuration</label>
<button class="btn btn-primary" id="push-config-btn" ${state.configPushed ? 'disabled' : ''}>
${state.configPushed ? 'config saved to extension' : 'save config to extension'}
</button>
${state.configPushed ? '<span class="test-result pass" style="margin-left:8px;">saved</span>' : ''}
</div>
` : `
<div class="form-group" style="margin-top:16px;">
<label class="label">extension configuration</label>
<p class="muted" style="margin-bottom:8px;">
Copy this JSON and paste it into the extension setup, or save it for later.
</p>
<div class="config-blob" id="config-blob">${escapeHtml(configJson)}</div>
<button class="btn" id="copy-config-btn">copy to clipboard</button>
</div>
`}
</div>
`;
}
function attachStep4(): void {
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reference.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('push-config-btn')?.addEventListener('click', async () => {
if (!state.referenceImageBytes) return;
const config: VaultConfig = {
hostType: state.hostType,
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes);
try {
chrome.runtime.sendMessage(
{ type: 'save_setup', config, imageBase64 },
(response: { ok: boolean; error?: string }) => {
if (response?.ok) {
state.configPushed = true;
} else {
state.error = response?.error ?? 'Failed to save config to extension';
}
render();
},
);
} catch (err: unknown) {
state.error = `Failed to communicate with extension: ${err instanceof Error ? err.message : String(err)}`;
render();
}
});
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
const blob = document.getElementById('config-blob');
if (!blob) return;
try {
await navigator.clipboard.writeText(blob.textContent ?? '');
const btn = document.getElementById('copy-config-btn')!;
btn.textContent = 'copied!';
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
} catch {
// Fallback: select the text
const range = document.createRange();
range.selectNodeContents(blob);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
});
}
// --- Boot ---
document.addEventListener('DOMContentLoaded', () => {
render();
});

View File

@@ -0,0 +1,74 @@
import type { Entry, Manifest, ManifestEntry, VaultConfig, SetupState } from './types';
// --- Request types (popup/content -> service worker) ---
export type Request =
| { type: 'unlock'; passphrase: string }
| { type: 'lock' }
| { type: 'is_unlocked' }
| { type: 'list_entries'; group?: string }
| { type: 'get_entry'; id: string }
| { type: 'search_entries'; query: string }
| { type: 'add_entry'; entry: Entry }
| { type: 'update_entry'; id: string; entry: Entry }
| { type: 'delete_entry'; id: string }
| { type: 'get_totp'; id: string }
| { type: 'get_autofill_candidates'; url: string }
| { type: 'get_credentials'; id: string }
| { type: 'sync' }
| { type: 'get_setup_state' }
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
| { type: 'generate_password'; length: number }
| { type: 'fill_credentials'; username: string; password: string }
| { type: 'check_credential'; url: string; username: string; password: string }
| { type: 'blacklist_site'; hostname: string }
| { type: 'get_settings' }
| { type: 'update_settings'; settings: Partial<import('./types').IdfotoSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string };
// --- Response types (service worker -> popup/content) ---
export type Response =
| { ok: true; data?: unknown }
| { ok: false; error: string };
export interface UnlockResponse extends Extract<Response, { ok: true }> {
data: undefined;
}
export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
data: { unlocked: boolean };
}
export interface ListEntriesResponse extends Extract<Response, { ok: true }> {
data: { entries: Array<[string, ManifestEntry]> };
}
export interface GetEntryResponse extends Extract<Response, { ok: true }> {
data: { entry: Entry };
}
export interface SearchEntriesResponse extends Extract<Response, { ok: true }> {
data: { entries: Array<[string, ManifestEntry]> };
}
export interface TotpResponse extends Extract<Response, { ok: true }> {
data: { code: string; remaining_seconds: number };
}
export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
data: { candidates: Array<[string, ManifestEntry]> };
}
export interface CredentialsResponse extends Extract<Response, { ok: true }> {
data: { username: string; password: string };
}
export interface SetupStateResponse extends Extract<Response, { ok: true }> {
data: SetupState;
}
export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
data: { password: string };
}

View File

@@ -0,0 +1,53 @@
/// Full credential entry (matches Rust Entry struct in idfoto-core).
export interface Entry {
name: string;
url?: string;
username?: string;
password: string;
notes?: string;
totp_secret?: string;
group?: string;
created_at: string;
updated_at: string;
}
/// Lightweight manifest entry for listing/searching without full decrypt.
export interface ManifestEntry {
name: string;
url?: string;
username?: string;
group?: string;
updated_at: string;
}
/// Encrypted manifest containing all entry metadata.
export interface Manifest {
entries: Record<string, ManifestEntry>;
version: number;
}
/// Configuration for connecting to a git host.
export interface VaultConfig {
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
}
/// Persisted setup state in chrome.storage.local.
export interface SetupState {
config: VaultConfig | null;
imageBase64: string | null;
isConfigured: boolean;
}
/// User-configurable credential capture settings.
export interface IdfotoSettings {
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
export const DEFAULT_SETTINGS: IdfotoSettings = {
captureEnabled: false,
captureStyle: 'bar',
};

25
extension/src/wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
/// Type declarations for the idfoto WASM module produced by wasm-pack.
// Ambient module declarations for the WASM glue code.
// The module specifier must exactly match what's used in import statements.
declare module 'idfoto-wasm' {
export default function init(input?: string | URL): Promise<void>;
export function derive_master_key(
passphrase: string,
image_secret: Uint8Array,
salt: Uint8Array,
params_json: string,
): Uint8Array;
export function encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array;
export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array;
export function extract_image_secret(jpeg_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
export function encrypt_entry(entry_json: string, key: Uint8Array): Uint8Array;
export function decrypt_entry(ciphertext: Uint8Array, key: Uint8Array): string;
export function encrypt_manifest(manifest_json: string, key: Uint8Array): Uint8Array;
export function decrypt_manifest(ciphertext: Uint8Array, key: Uint8Array): string;
export function generate_totp(secret_base32: string, timestamp_secs: bigint): string;
export function generate_password(length: number): string;
export function generate_entry_id(): string;
}

19
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"idfoto-wasm": ["./wasm/idfoto_wasm.js"]
},
"baseUrl": "."
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "wasm"]
}

View File

@@ -0,0 +1,36 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
clean: true,
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'manifest.json', to: '.' },
{ from: 'src/popup/index.html', to: 'popup.html' },
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
],
}),
],
experiments: { asyncWebAssembly: true },
};

View File

@@ -0,0 +1,36 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
entry: {
'service-worker': './src/service-worker/index.ts',
popup: './src/popup/popup.ts',
content: './src/content/detector.ts',
setup: './src/setup/setup.ts',
},
output: {
path: path.resolve(__dirname, 'dist-firefox'),
filename: '[name].js',
clean: true,
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'manifest.firefox.json', to: 'manifest.json' },
{ from: 'src/popup/index.html', to: 'popup.html' },
{ from: 'src/popup/styles.css', to: 'styles.css' },
{ from: 'setup.html', to: '.' },
{ from: 'icons', to: 'icons' },
{ from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
{ from: 'wasm/idfoto_wasm.js', to: '.' },
],
}),
],
experiments: { asyncWebAssembly: true },
};