Compare commits
41 Commits
c50e0d448b
...
2524270524
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2524270524 | ||
|
|
b71ebcc418 | ||
|
|
051c98dece | ||
|
|
39f04a0b97 | ||
|
|
ff19faff03 | ||
|
|
baf6416805 | ||
|
|
a56114650a | ||
|
|
1916fa0f81 | ||
|
|
68f2908156 | ||
|
|
cdbd648079 | ||
|
|
c50285c4a5 | ||
|
|
4c26b4c534 | ||
|
|
0551efe69e | ||
|
|
336e90fc84 | ||
|
|
8236a18433 | ||
|
|
9a53b264f2 | ||
|
|
5397d385e6 | ||
|
|
26e68b133c | ||
|
|
a1c9d567b1 | ||
|
|
0c800bcd4f | ||
|
|
b48ff0a05c | ||
|
|
8e63ccc23b | ||
|
|
8093649757 | ||
|
|
029784b67a | ||
|
|
78ffeb4b8d | ||
|
|
b4febbbe45 | ||
|
|
caf360c978 | ||
|
|
ff62970917 | ||
|
|
ea9dee00e1 | ||
|
|
7cf7960aff | ||
|
|
71f7bf9797 | ||
|
|
6866250f78 | ||
|
|
98c20b613c | ||
|
|
eae8fd4a24 | ||
|
|
7baec1cd67 | ||
|
|
c7aab28484 | ||
|
|
847051216d | ||
|
|
0d374f3faf | ||
|
|
822547f349 | ||
|
|
01d5fd5d0d | ||
|
|
596daf320a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
target/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
extension/node_modules/
|
||||
extension/dist/
|
||||
extension/dist-firefox/
|
||||
extension/wasm/
|
||||
|
||||
302
Cargo.lock
generated
302
Cargo.lock
generated
@@ -106,6 +106,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -142,6 +153,12 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -154,6 +171,22 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -318,6 +351,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -446,6 +485,12 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -456,6 +501,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -483,8 +552,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -510,6 +581,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idfoto-cli"
|
||||
version = "0.1.0"
|
||||
@@ -542,6 +622,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -579,12 +674,30 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
@@ -621,6 +734,16 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -641,6 +764,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -648,6 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -723,12 +856,24 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -781,6 +926,12 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
@@ -936,6 +1087,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -991,6 +1157,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1002,6 +1179,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
@@ -1017,6 +1200,12 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
@@ -1144,12 +1333,116 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -1172,6 +1465,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -3,4 +3,5 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/idfoto-core",
|
||||
"crates/idfoto-cli",
|
||||
"crates/idfoto-wasm",
|
||||
]
|
||||
|
||||
@@ -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 clap::{Parser, Subcommand};
|
||||
use idfoto_core::{
|
||||
@@ -14,6 +53,7 @@ use std::process::Command;
|
||||
|
||||
// ─── CLI structure ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Top-level CLI argument parser.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "idfoto",
|
||||
@@ -25,70 +65,105 @@ struct Cli {
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
/// All available CLI subcommands.
|
||||
#[derive(Subcommand)]
|
||||
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 {
|
||||
/// Path to the carrier JPEG image to embed the secret into.
|
||||
#[arg(long)]
|
||||
image: PathBuf,
|
||||
/// Output path for the reference image (with embedded secret).
|
||||
#[arg(long, default_value = "reference.jpg")]
|
||||
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,
|
||||
/// 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 },
|
||||
/// List all entries
|
||||
/// List all entries in the vault (names, URLs, usernames only -- no passwords).
|
||||
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 },
|
||||
/// Remove an entry
|
||||
/// Remove an entry from the vault by name (fuzzy search).
|
||||
/// Prompts for confirmation before deleting.
|
||||
Rm { name: String },
|
||||
/// Sync vault with git remote
|
||||
/// Sync the vault with the git remote (pull --rebase, then push).
|
||||
Sync,
|
||||
/// Generate a random password
|
||||
/// Generate a random password and print it to stdout.
|
||||
Generate {
|
||||
/// Length of the generated password in characters.
|
||||
#[arg(short, long, default_value = "20")]
|
||||
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 {
|
||||
#[command(subcommand)]
|
||||
action: DeviceCommands,
|
||||
},
|
||||
}
|
||||
|
||||
/// Subcommands for device key management.
|
||||
#[derive(Subcommand)]
|
||||
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 {
|
||||
/// Human-readable name for this device (e.g., "macbook", "phone").
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
/// List registered devices
|
||||
/// List all registered devices and their public keys.
|
||||
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 },
|
||||
}
|
||||
|
||||
// ─── 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)]
|
||||
struct DeviceEntry {
|
||||
/// Human-readable device name (e.g., "macbook-pro", "pixel-7").
|
||||
name: String,
|
||||
/// Hex-encoded ed25519 public key (64 hex chars = 32 bytes).
|
||||
public_key: String, // hex-encoded
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
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 {
|
||||
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]> {
|
||||
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
||||
let mut salt = [0u8; 32];
|
||||
@@ -99,6 +174,7 @@ fn read_salt() -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
/// Read the KDF parameters from `.idfoto/params.json`.
|
||||
fn read_params() -> Result<KdfParams> {
|
||||
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
||||
.context("failed to read params.json")?;
|
||||
@@ -106,6 +182,10 @@ fn read_params() -> Result<KdfParams> {
|
||||
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> {
|
||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
@@ -114,6 +194,13 @@ fn get_image_path() -> Result<PathBuf> {
|
||||
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]> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Decrypt and return the vault 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 manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Encrypt and write the vault manifest to disk.
|
||||
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
||||
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")?;
|
||||
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<()> {
|
||||
let status = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
@@ -162,6 +256,10 @@ fn git_commit(message: &str) -> Result<()> {
|
||||
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 {
|
||||
let duration = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -169,6 +267,7 @@ fn now_iso8601() -> String {
|
||||
format!("{}", duration.as_secs())
|
||||
}
|
||||
|
||||
/// Prompt the user for input via stderr (so stdout remains clean for piping).
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
eprint!("{}: ", message);
|
||||
io::stderr().flush()?;
|
||||
@@ -177,6 +276,7 @@ fn prompt(message: &str) -> Result<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>> {
|
||||
let value = prompt(message)?;
|
||||
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> {
|
||||
eprint!("{} [{}]: ", field, current);
|
||||
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 {
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||||
let mut rng = OsRng;
|
||||
@@ -212,6 +318,19 @@ fn generate_password(length: usize) -> String {
|
||||
|
||||
// ─── 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<()> {
|
||||
// 1. Read carrier JPEG
|
||||
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)
|
||||
.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")
|
||||
.context("failed to write .gitignore")?;
|
||||
|
||||
@@ -292,11 +412,16 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a random password and print it to stdout.
|
||||
fn cmd_generate(length: usize) -> Result<()> {
|
||||
println!("{}", generate_password(length));
|
||||
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<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -332,6 +457,7 @@ fn cmd_add() -> Result<()> {
|
||||
password,
|
||||
notes,
|
||||
totp_secret,
|
||||
group: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
@@ -351,6 +477,7 @@ fn cmd_add() -> Result<()> {
|
||||
name: name.clone(),
|
||||
url,
|
||||
username,
|
||||
group: None,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
@@ -362,6 +489,10 @@ fn cmd_add() -> Result<()> {
|
||||
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)> {
|
||||
let results = manifest.search(query);
|
||||
if results.is_empty() {
|
||||
@@ -394,6 +525,11 @@ fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, Manife
|
||||
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<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -422,7 +558,10 @@ fn cmd_get(query: String) -> Result<()> {
|
||||
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() {
|
||||
Ok(mut clipboard) => {
|
||||
if clipboard.set_text(&entry.password).is_ok() {
|
||||
@@ -448,6 +587,10 @@ fn cmd_get(query: String) -> Result<()> {
|
||||
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<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -477,6 +620,8 @@ fn cmd_list() -> Result<()> {
|
||||
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<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -517,6 +662,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
password,
|
||||
notes,
|
||||
totp_secret,
|
||||
group: entry.group,
|
||||
created_at: entry.created_at,
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
@@ -535,6 +681,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
name: name.clone(),
|
||||
url,
|
||||
username,
|
||||
group: updated_entry.group,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
@@ -546,6 +693,10 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
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<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -576,6 +727,11 @@ fn cmd_rm(query: String) -> Result<()> {
|
||||
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<()> {
|
||||
eprintln!("Pulling...");
|
||||
let status = Command::new("git")
|
||||
@@ -601,6 +757,7 @@ fn cmd_sync() -> Result<()> {
|
||||
|
||||
// ─── Device management ──────────────────────────────────────────────────────
|
||||
|
||||
/// Read the device registry from `.idfoto/devices.json`.
|
||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||
let path = idfoto_dir().join("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)
|
||||
}
|
||||
|
||||
/// Write the device registry to `.idfoto/devices.json`.
|
||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||||
let data = serde_json::to_string_pretty(devices)?;
|
||||
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
||||
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<()> {
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
let mut devices = read_devices()?;
|
||||
|
||||
// Check for duplicate
|
||||
// Check for duplicate device names
|
||||
if devices.iter().any(|d| d.name == name) {
|
||||
bail!("device '{}' already exists", name);
|
||||
}
|
||||
|
||||
// Generate ed25519 keypair
|
||||
// Generate ed25519 keypair using the OS CSPRNG
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let private_key_hex = hex::encode(signing_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()
|
||||
.context("failed to find config directory")?
|
||||
.join("idfoto");
|
||||
@@ -646,7 +812,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
||||
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 {
|
||||
name: name.clone(),
|
||||
public_key: public_key_hex,
|
||||
@@ -660,6 +826,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered devices with their public keys.
|
||||
fn cmd_device_list() -> Result<()> {
|
||||
let devices = read_devices()?;
|
||||
|
||||
@@ -677,6 +844,13 @@ fn cmd_device_list() -> Result<()> {
|
||||
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<()> {
|
||||
let mut devices = read_devices()?;
|
||||
let initial_len = devices.len();
|
||||
@@ -695,6 +869,7 @@ fn cmd_device_revoke(name: String) -> Result<()> {
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point: parse CLI arguments and dispatch to the appropriate command handler.
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
|
||||
@@ -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 chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
@@ -8,14 +53,35 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{IdfotoError, Result};
|
||||
|
||||
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||
const VERSION_BYTE: u8 = 0x01;
|
||||
|
||||
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
|
||||
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;
|
||||
|
||||
/// Total header size: version byte + nonce. The ciphertext (including tag)
|
||||
/// follows immediately after the header.
|
||||
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>> {
|
||||
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];
|
||||
OsRng.fill_bytes(&mut 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)
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
// 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 {
|
||||
return Err(IdfotoError::Format(
|
||||
"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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
/// 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,
|
||||
/// Parallelism degree. Default is 4. Sets the number of independent lanes
|
||||
/// in the Argon2id memory-hard computation.
|
||||
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 {
|
||||
fn default() -> 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(
|
||||
passphrase: &[u8],
|
||||
image_secret: &[u8; 32],
|
||||
@@ -92,7 +218,10 @@ pub fn derive_master_key(
|
||||
|
||||
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);
|
||||
password.extend_from_slice(passphrase);
|
||||
password.extend_from_slice(image_secret);
|
||||
|
||||
@@ -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 serde::{Deserialize, Serialize};
|
||||
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)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
@@ -15,29 +56,56 @@ pub struct Entry {
|
||||
pub notes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 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)]
|
||||
pub struct ManifestEntry {
|
||||
/// Human-readable label for display and search matching.
|
||||
pub name: String,
|
||||
/// Login URL for search matching and browser extension autofill.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Account username for display in entry listings.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct Manifest {
|
||||
/// Map from entry ID (8-char hex string) to entry metadata.
|
||||
pub entries: HashMap<String, ManifestEntry>,
|
||||
/// Schema version. Currently always `1`.
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Create a new empty manifest with version 1.
|
||||
pub fn new() -> Self {
|
||||
Manifest {
|
||||
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) {
|
||||
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> {
|
||||
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)> {
|
||||
let q = query.to_lowercase();
|
||||
self.entries
|
||||
@@ -75,6 +152,10 @@ impl Default for Manifest {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 4] = rng.gen();
|
||||
@@ -94,6 +175,7 @@ mod tests {
|
||||
password: "s3cr3t".to_string(),
|
||||
notes: None,
|
||||
totp_secret: None,
|
||||
group: None,
|
||||
created_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(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
manifest.add_entry("abc12345".to_string(), me);
|
||||
@@ -136,6 +219,7 @@ mod tests {
|
||||
name: "Gmail".to_string(),
|
||||
url: Some("https://mail.google.com".to_string()),
|
||||
username: Some("user@gmail.com".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-06-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -164,6 +248,7 @@ mod tests {
|
||||
name: "GitHub Account".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: None,
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -173,6 +258,7 @@ mod tests {
|
||||
name: "Work Email".to_string(),
|
||||
url: Some("https://mail.example.com".to_string()),
|
||||
username: None,
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -191,4 +277,59 @@ mod tests {
|
||||
let results = manifest.search("nonexistent");
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 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)]
|
||||
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}")]
|
||||
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}")]
|
||||
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")]
|
||||
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}")]
|
||||
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}")]
|
||||
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}")]
|
||||
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}")]
|
||||
ImageTooSmall {
|
||||
min_width: u32,
|
||||
@@ -28,14 +62,25 @@ pub enum IdfotoError {
|
||||
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")]
|
||||
ExtractionFailed,
|
||||
|
||||
/// JSON serialization or deserialization of an entry or manifest failed.
|
||||
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
|
||||
#[error("json error: {0}")]
|
||||
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}")]
|
||||
DeviceKey(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
||||
|
||||
@@ -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
|
||||
//! channel using Quantization Index Modulation (QIM) with majority voting.
|
||||
//! This is the novel component of idfoto. It hides a 32-byte secret inside a
|
||||
//! 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 image::codecs::jpeg::JpegEncoder;
|
||||
@@ -12,43 +48,97 @@ use std::io::Cursor;
|
||||
|
||||
// ─── 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Number of secret bits to embed: 256 bits = 32 bytes.
|
||||
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;
|
||||
|
||||
/// 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()
|
||||
|
||||
/// 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
|
||||
|
||||
/// Mid-frequency DCT positions (zig-zag positions 4–15)
|
||||
/// 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] = [
|
||||
(0, 3),
|
||||
(1, 2),
|
||||
(2, 1),
|
||||
(3, 0), // zig-zag 4-7
|
||||
(3, 0), // zig-zag 6-9
|
||||
(0, 4),
|
||||
(1, 3),
|
||||
(2, 2),
|
||||
(3, 1), // zig-zag 8-11
|
||||
(3, 1), // zig-zag 10-13
|
||||
(4, 0),
|
||||
(0, 5),
|
||||
(1, 4),
|
||||
(2, 3), // zig-zag 12-15
|
||||
(2, 3), // zig-zag 14-17
|
||||
];
|
||||
|
||||
// ─── 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 {
|
||||
/// 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>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
impl YChannel {
|
||||
/// Get the luminance value at pixel (x, y).
|
||||
fn get(&self, x: usize, y: usize) -> f64 {
|
||||
self.data[y * self.width + x]
|
||||
}
|
||||
|
||||
/// Set the luminance value at pixel (x, y).
|
||||
fn set(&mut self, x: usize, y: usize, val: f64) {
|
||||
self.data[y * self.width + x] = val;
|
||||
}
|
||||
@@ -56,19 +146,36 @@ impl YChannel {
|
||||
|
||||
// ─── 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 {
|
||||
/// Pixel offset from the left edge to the start of the embed region.
|
||||
x_offset: usize,
|
||||
/// Pixel offset from the top edge to the start of the embed region.
|
||||
y_offset: usize,
|
||||
/// Width of the embed region in pixels.
|
||||
#[allow(dead_code)]
|
||||
region_width: usize,
|
||||
/// Height of the embed region in pixels.
|
||||
#[allow(dead_code)]
|
||||
region_height: usize,
|
||||
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
|
||||
blocks_x: usize,
|
||||
/// Number of complete 8x8 blocks that fit vertically in the embed region.
|
||||
blocks_y: usize,
|
||||
}
|
||||
|
||||
// ─── 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> {
|
||||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||||
.with_guessed_format()
|
||||
@@ -82,6 +189,7 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
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;
|
||||
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 {
|
||||
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 {
|
||||
let margin_x = (width 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]> {
|
||||
if px + 8 > y.width || py + 8 > y.height {
|
||||
return None;
|
||||
@@ -129,12 +247,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
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] {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
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]) {
|
||||
let start_x = region.x_offset + bx * 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 ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for k in 0..8 {
|
||||
@@ -164,6 +301,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
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] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for i in 0..8 {
|
||||
@@ -181,11 +322,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
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] {
|
||||
// Step 1: DCT along rows
|
||||
let mut temp = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
temp[row] = dct1d(&block[row]);
|
||||
}
|
||||
// Step 2: DCT along columns
|
||||
let mut result = [[0.0f64; 8]; 8];
|
||||
for col in 0..8 {
|
||||
let mut column = [0.0f64; 8];
|
||||
@@ -200,7 +348,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
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] {
|
||||
// Step 1: IDCT along columns
|
||||
let mut temp = [[0.0f64; 8]; 8];
|
||||
for col in 0..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];
|
||||
}
|
||||
}
|
||||
// Step 2: IDCT along rows
|
||||
let mut result = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
result[row] = idct1d(&temp[row]);
|
||||
@@ -220,7 +374,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
||||
let shifted = coef - offset;
|
||||
@@ -228,8 +403,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||||
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 {
|
||||
// Distance to the nearest bit-0 grid point
|
||||
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 shifted = coef - offset;
|
||||
let d1 = (shifted - (shifted / q).round() * q).abs();
|
||||
@@ -238,6 +420,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
|
||||
|
||||
// ─── 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> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
@@ -248,6 +434,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
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> {
|
||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||
for chunk in bits.chunks(8) {
|
||||
@@ -263,7 +452,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
// ─── Block selection ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 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)> {
|
||||
let region = compute_region(img_width, img_height);
|
||||
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 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 mut positions = Vec::with_capacity(target_count);
|
||||
let mut idx = 0;
|
||||
@@ -287,11 +488,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
||||
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)> {
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
if total_blocks == 0 || target_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Even stride distributes blocks uniformly across the region
|
||||
let stride = (total_blocks / target_count).max(1);
|
||||
let mut blocks = Vec::with_capacity(target_count);
|
||||
let mut idx = 0;
|
||||
@@ -306,6 +513,17 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
|
||||
|
||||
// ─── 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>> {
|
||||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||||
.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 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 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;
|
||||
|
||||
// Use the modified Y value from our watermarked luminance channel
|
||||
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 g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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>> {
|
||||
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 bits = bytes_to_bits(secret);
|
||||
|
||||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||||
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
||||
|
||||
// Embed each copy of the secret into its assigned blocks
|
||||
for copy in 0..num_copies {
|
||||
for block_idx in 0..BLOCKS_PER_COPY {
|
||||
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, ®ion);
|
||||
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() {
|
||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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]> {
|
||||
extract_with_crop_recovery(jpeg_bytes)
|
||||
}
|
||||
|
||||
/// Try to extract using a specific assumed original image size and pixel offset.
|
||||
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
|
||||
/// `dx`/`dy` shift all block positions when reading from the actual image.
|
||||
/// Attempt to extract the secret assuming specific original image dimensions
|
||||
/// and a pixel offset (for crop recovery).
|
||||
///
|
||||
/// 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(
|
||||
y: &YChannel,
|
||||
orig_w: usize,
|
||||
@@ -438,6 +702,7 @@ fn try_extract_with_layout(
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
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_total = vec![0usize; SECRET_BITS];
|
||||
|
||||
@@ -447,6 +712,8 @@ fn try_extract_with_layout(
|
||||
if global_idx >= positions.len() {
|
||||
break;
|
||||
}
|
||||
// Apply crop offset to find the actual block position in the
|
||||
// (possibly cropped) image
|
||||
let (orig_px, orig_py) = positions[global_idx];
|
||||
let actual_px = orig_px as isize + dx;
|
||||
let actual_py = orig_py as isize + dy;
|
||||
@@ -462,6 +729,7 @@ fn try_extract_with_layout(
|
||||
};
|
||||
let dct = dct2_8x8(&block);
|
||||
|
||||
// Extract bits from mid-frequency coefficients and tally votes
|
||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||
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];
|
||||
for i in 0..SECRET_BITS {
|
||||
if votes_total[i] == 0 {
|
||||
@@ -498,6 +768,19 @@ fn try_extract_with_layout(
|
||||
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]> {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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_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) {
|
||||
// Right-side crop: dx = 0 (left edge unchanged)
|
||||
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) {
|
||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
||||
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) {
|
||||
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) {
|
||||
return Ok(secret);
|
||||
|
||||
@@ -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 use error::{IdfotoError, Result};
|
||||
|
||||
|
||||
@@ -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::entry::{Entry, Manifest};
|
||||
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>> {
|
||||
let json = serde_json::to_vec(entry)?;
|
||||
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> {
|
||||
let json = crypto::decrypt(master_key, data)?;
|
||||
let entry: Entry = serde_json::from_slice(&json)?;
|
||||
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>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
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> {
|
||||
let json = crypto::decrypt(master_key, data)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&json)?;
|
||||
@@ -45,6 +94,7 @@ mod tests {
|
||||
password: "secret123".to_string(),
|
||||
notes: None,
|
||||
totp_secret: None,
|
||||
group: None,
|
||||
created_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(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ fn full_vault_workflow() {
|
||||
password: "supersecret123!".to_string(),
|
||||
notes: Some("my main account".to_string()),
|
||||
totp_secret: None,
|
||||
group: None,
|
||||
created_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(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
22
crates/idfoto-wasm/Cargo.toml
Normal file
22
crates/idfoto-wasm/Cargo.toml
Normal 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"] }
|
||||
364
crates/idfoto-wasm/src/lib.rs
Normal file
364
crates/idfoto-wasm/src/lib.rs
Normal 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, ¶ms)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
845
docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md
Normal file
845
docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md
Normal 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.
|
||||
323
docs/superpowers/plans/2026-04-12-idfoto-firefox-extension.md
Normal file
323
docs/superpowers/plans/2026-04-12-idfoto-firefox-extension.md
Normal 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.
|
||||
955
docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md
Normal file
955
docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md
Normal 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.
|
||||
3290
docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md
Normal file
3290
docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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)
|
||||
178
docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md
Normal file
178
docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md
Normal 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)
|
||||
@@ -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)
|
||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 B |
BIN
extension/icons/icon-16.png
Normal file
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
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 B |
36
extension/manifest.firefox.json
Normal file
36
extension/manifest.firefox.json
Normal 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
32
extension/manifest.json
Normal 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
21
extension/package.json
Normal 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
113
extension/setup.html
Normal 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>
|
||||
308
extension/src/content/capture.ts
Normal file
308
extension/src/content/capture.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
103
extension/src/content/detector.ts
Normal file
103
extension/src/content/detector.ts
Normal 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,
|
||||
});
|
||||
88
extension/src/content/fill.ts
Normal file
88
extension/src/content/fill.ts
Normal 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;
|
||||
}
|
||||
161
extension/src/content/icon.ts
Normal file
161
extension/src/content/icon.ts
Normal 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);
|
||||
}
|
||||
255
extension/src/popup/components/entry-detail.ts
Normal file
255
extension/src/popup/components/entry-detail.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
142
extension/src/popup/components/entry-form.ts
Normal file
142
extension/src/popup/components/entry-form.ts
Normal 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();
|
||||
}
|
||||
176
extension/src/popup/components/entry-list.ts
Normal file
176
extension/src/popup/components/entry-list.ts
Normal 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>↑↓</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;
|
||||
}
|
||||
}
|
||||
98
extension/src/popup/components/settings.ts
Normal file
98
extension/src/popup/components/settings.ts
Normal 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;">←</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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
30
extension/src/popup/components/setup-wizard.ts
Normal file
30
extension/src/popup/components/setup-wizard.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
56
extension/src/popup/components/unlock.ts
Normal file
56
extension/src/popup/components/unlock.ts
Normal 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'));
|
||||
}
|
||||
13
extension/src/popup/index.html
Normal file
13
extension/src/popup/index.html
Normal 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>
|
||||
137
extension/src/popup/popup.ts
Normal file
137
extension/src/popup/popup.ts
Normal 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);
|
||||
454
extension/src/popup/styles.css
Normal file
454
extension/src/popup/styles.css
Normal 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;
|
||||
}
|
||||
54
extension/src/service-worker/git-host.ts
Normal file
54
extension/src/service-worker/git-host.ts
Normal 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);
|
||||
}
|
||||
114
extension/src/service-worker/gitea.ts
Normal file
114
extension/src/service-worker/gitea.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
108
extension/src/service-worker/github.ts
Normal file
108
extension/src/service-worker/github.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
441
extension/src/service-worker/index.ts
Normal file
441
extension/src/service-worker/index.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
137
extension/src/service-worker/vault.ts
Normal file
137
extension/src/service-worker/vault.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
592
extension/src/setup/setup.ts
Normal file
592
extension/src/setup/setup.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 → Applications</strong></li>
|
||||
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
|
||||
<li>Copy the token — 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 → Developer settings → Personal access tokens → 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 — 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();
|
||||
});
|
||||
74
extension/src/shared/messages.ts
Normal file
74
extension/src/shared/messages.ts
Normal 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 };
|
||||
}
|
||||
53
extension/src/shared/types.ts
Normal file
53
extension/src/shared/types.ts
Normal 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
25
extension/src/wasm.d.ts
vendored
Normal 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
19
extension/tsconfig.json
Normal 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"]
|
||||
}
|
||||
36
extension/webpack.config.js
Normal file
36
extension/webpack.config.js
Normal 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 },
|
||||
};
|
||||
36
extension/webpack.firefox.config.js
Normal file
36
extension/webpack.firefox.config.js
Normal 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 },
|
||||
};
|
||||
Reference in New Issue
Block a user