From 4d9589960673991576d397c08433f534f5bc310c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 23:15:20 -0400 Subject: [PATCH] chore: add Cargo.lock, design spec, and implementation plan --- Cargo.lock | 1398 ++++++++ .../plans/2026-04-11-idfoto-core-cli.md | 2994 +++++++++++++++++ .../specs/2026-04-11-idfoto-design.md | 369 ++ 3 files changed, 4761 insertions(+) create mode 100644 Cargo.lock create mode 100644 docs/superpowers/plans/2026-04-11-idfoto-core-cli.md create mode 100644 docs/superpowers/specs/2026-04-11-idfoto-design.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2395f8f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1398 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "idfoto-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "dirs", + "ed25519-dalek", + "hex", + "idfoto-core", + "rand", + "rpassword", + "serde", + "serde_json", +] + +[[package]] +name = "idfoto-core" +version = "0.1.0" +dependencies = [ + "argon2", + "chacha20poly1305", + "ed25519-dalek", + "image", + "rand", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/docs/superpowers/plans/2026-04-11-idfoto-core-cli.md b/docs/superpowers/plans/2026-04-11-idfoto-core-cli.md new file mode 100644 index 0000000..6e789cd --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-idfoto-core-cli.md @@ -0,0 +1,2994 @@ +# idfoto Core + CLI 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 working git-backed password manager with a Rust core library and CLI that can create vaults, add/get/list/edit/rm credentials, sync via git, and manage device keys — all backed by the reference-image + passphrase two-factor KDF. + +**Architecture:** Cargo workspace with two crates: `idfoto-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `idfoto-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout. + +**Tech Stack:** Rust (stable, 2021 edition), argon2, chacha20poly1305, image, serde/serde_json, clap, ed25519-dalek + +**Scope:** This is Plan 1 of 2. This plan covers `idfoto-core` and `idfoto-cli`. Plan 2 (idfoto-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager. + +**Prerequisites:** Rust stable installed via `rustup`. Git installed. A test JPEG image (any cell phone photo) available for manual testing. + +**Design spec:** `docs/superpowers/specs/2026-04-11-idfoto-design.md` + +--- + +## File Structure + +``` +idfoto/ (project root = /home/alee/Sources/axsbadge.me) +├── Cargo.toml # workspace root +├── crates/ +│ ├── idfoto-core/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── lib.rs # re-exports public API +│ │ ├── error.rs # IdfotoError enum (thiserror) +│ │ ├── crypto.rs # derive_master_key(), encrypt(), decrypt() +│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs +│ │ ├── vault.rs # encrypt/decrypt entries + manifest, binary format +│ │ └── imgsecret.rs # embed(), extract() — DCT embedding primitive +│ └── idfoto-cli/ +│ ├── Cargo.toml +│ └── src/ +│ └── main.rs # clap CLI with all subcommands +├── docs/ +│ └── superpowers/ +│ ├── specs/ +│ │ └── 2026-04-11-idfoto-design.md +│ └── plans/ +│ └── 2026-04-11-idfoto-core-cli.md (this file) +└── README.md +``` + +--- + +### Task 1: Workspace Scaffolding + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/idfoto-core/Cargo.toml` +- Create: `crates/idfoto-core/src/lib.rs` +- Create: `crates/idfoto-cli/Cargo.toml` +- Create: `crates/idfoto-cli/src/main.rs` + +- [ ] **Step 1: Create workspace root Cargo.toml** + +```toml +# Cargo.toml +[workspace] +resolver = "2" +members = [ + "crates/idfoto-core", + "crates/idfoto-cli", +] +``` + +- [ ] **Step 2: Create idfoto-core crate** + +```toml +# crates/idfoto-core/Cargo.toml +[package] +name = "idfoto-core" +version = "0.1.0" +edition = "2021" +description = "Core library for idfoto password manager" + +[dependencies] +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +argon2 = "0.5" +chacha20poly1305 = "0.10" +rand = "0.8" +sha2 = "0.10" +ed25519-dalek = { version = "2", features = ["rand_core"] } +image = { version = "0.25", default-features = false, features = ["jpeg"] } + +[dev-dependencies] +``` + +```rust +// crates/idfoto-core/src/lib.rs +pub mod error; +``` + +- [ ] **Step 3: Create idfoto-cli crate** + +```toml +# crates/idfoto-cli/Cargo.toml +[package] +name = "idfoto-cli" +version = "0.1.0" +edition = "2021" +description = "CLI for idfoto password manager" + +[[bin]] +name = "idfoto" +path = "src/main.rs" + +[dependencies] +idfoto-core = { path = "../idfoto-core" } +clap = { version = "4", features = ["derive"] } +anyhow = "1" +rpassword = "5" +arboard = "3" +dirs = "5" +``` + +```rust +// crates/idfoto-cli/src/main.rs +fn main() { + println!("idfoto v0.1.0"); +} +``` + +- [ ] **Step 4: Verify build** + +Run: `cargo build` +Expected: Compiles with no errors. May show warnings about unused dependencies — that's fine. + +- [ ] **Step 5: Commit** + +```bash +git init +echo "target/" > .gitignore +echo ".superpowers/" >> .gitignore +git add Cargo.toml crates/ .gitignore docs/ +git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli" +``` + +--- + +### Task 2: Error Types + +**Files:** +- Create: `crates/idfoto-core/src/error.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write the error enum** + +```rust +// crates/idfoto-core/src/error.rs +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum IdfotoError { + #[error("key derivation failed: {0}")] + Kdf(String), + + #[error("encryption failed: {0}")] + Encrypt(String), + + #[error("decryption failed: wrong key or corrupted data")] + Decrypt, + + #[error("invalid vault format: {0}")] + Format(String), + + #[error("entry not found: {0}")] + EntryNotFound(String), + + #[error("imgsecret: {0}")] + ImgSecret(String), + + #[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")] + ImageTooSmall { + min_width: u32, + min_height: u32, + actual_width: u32, + actual_height: u32, + }, + + #[error("extraction failed: no valid secret found in image")] + ExtractionFailed, + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("device key error: {0}")] + DeviceKey(String), +} + +pub type Result = std::result::Result; +``` + +- [ ] **Step 2: Update lib.rs to re-export** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod error; + +pub use error::{IdfotoError, Result}; +``` + +- [ ] **Step 3: Verify build** + +Run: `cargo build` +Expected: Compiles cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/error.rs crates/idfoto-core/src/lib.rs +git commit -m "feat: add IdfotoError enum with thiserror" +``` + +--- + +### Task 3: Crypto — Key Derivation + +**Files:** +- Create: `crates/idfoto-core/src/crypto.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write the failing test** + +```rust +// crates/idfoto-core/src/crypto.rs + +// ... (implementation comes in step 3) + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_master_key_deterministic() { + let passphrase = b"apple forest thunder mountain"; + let image_secret = [0xABu8; 32]; + let salt = [0x01u8; 32]; + let params = KdfParams::default(); + + let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); + let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); + assert_eq!(key1, key2, "same inputs must produce same key"); + } + + #[test] + fn derive_master_key_different_passphrase() { + let image_secret = [0xABu8; 32]; + let salt = [0x01u8; 32]; + let params = KdfParams::default(); + + let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap(); + let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn derive_master_key_different_image_secret() { + let passphrase = b"same passphrase"; + let salt = [0x01u8; 32]; + let params = KdfParams::default(); + + let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap(); + let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap(); + assert_ne!(key1, key2); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p idfoto-core derive_master_key` +Expected: FAIL — `derive_master_key` and `KdfParams` not defined. + +- [ ] **Step 3: Write the implementation** + +```rust +// crates/idfoto-core/src/crypto.rs +use argon2::{Algorithm, Argon2, Params, Version}; +use crate::error::{IdfotoError, Result}; + +/// Argon2id tuning parameters. Stored in .idfoto/params.json. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KdfParams { + /// Memory cost in KiB (default: 65536 = 64 MiB) + pub argon2_m: u32, + /// Time cost / iterations (default: 3) + pub argon2_t: u32, + /// Parallelism (default: 4) + pub argon2_p: u32, +} + +impl Default for KdfParams { + fn default() -> Self { + Self { + argon2_m: 65536, + argon2_t: 3, + argon2_p: 4, + } + } +} + +/// Derive a 32-byte master key from passphrase + image_secret + salt. +/// +/// password = passphrase_bytes || image_secret_bytes (concatenated) +/// salt = vault_salt (32 bytes from .idfoto/salt) +pub fn derive_master_key( + passphrase: &[u8], + image_secret: &[u8; 32], + salt: &[u8; 32], + params: &KdfParams, +) -> Result<[u8; 32]> { + // Concatenate passphrase and image_secret as the Argon2id "password" input + let mut password = Vec::with_capacity(passphrase.len() + 32); + password.extend_from_slice(passphrase); + password.extend_from_slice(image_secret); + + let argon2_params = Params::new( + params.argon2_m, + params.argon2_t, + params.argon2_p, + Some(32), + ) + .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); + + let mut output = [0u8; 32]; + argon2 + .hash_password_into(&password, salt, &mut output) + .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Use fast params for tests so they don't take forever + fn test_params() -> KdfParams { + KdfParams { + argon2_m: 256, // 256 KiB — fast for tests + argon2_t: 1, + argon2_p: 1, + } + } + + #[test] + fn derive_master_key_deterministic() { + let passphrase = b"apple forest thunder mountain"; + let image_secret = [0xABu8; 32]; + let salt = [0x01u8; 32]; + let params = test_params(); + + let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); + let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); + assert_eq!(key1, key2, "same inputs must produce same key"); + } + + #[test] + fn derive_master_key_different_passphrase() { + let image_secret = [0xABu8; 32]; + let salt = [0x01u8; 32]; + let params = test_params(); + + let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap(); + let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn derive_master_key_different_image_secret() { + let passphrase = b"same passphrase"; + let salt = [0x01u8; 32]; + let params = test_params(); + + let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap(); + let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap(); + assert_ne!(key1, key2); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p idfoto-core derive_master_key` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Update lib.rs** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod crypto; +pub mod error; + +pub use crypto::{derive_master_key, KdfParams}; +pub use error::{IdfotoError, Result}; +``` + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/ +git commit -m "feat: add Argon2id key derivation with tests" +``` + +--- + +### Task 4: Crypto — Encrypt / Decrypt + +**Files:** +- Modify: `crates/idfoto-core/src/crypto.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block: + +```rust + #[test] + fn encrypt_decrypt_round_trip() { + let key = [0x42u8; 32]; + let plaintext = b"hello world, this is a secret"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + let decrypted = decrypt(&key, &ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_wrong_key_fails() { + let key = [0x42u8; 32]; + let wrong_key = [0x43u8; 32]; + let plaintext = b"secret data"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + let result = decrypt(&wrong_key, &ciphertext); + assert!(result.is_err()); + } + + #[test] + fn decrypt_tampered_data_fails() { + let key = [0x42u8; 32]; + let plaintext = b"secret data"; + + let mut ciphertext = encrypt(&key, plaintext).unwrap(); + // Flip a byte in the ciphertext portion (after version + nonce = 25 bytes) + if ciphertext.len() > 26 { + ciphertext[26] ^= 0xFF; + } + let result = decrypt(&key, &ciphertext); + assert!(result.is_err()); + } + + #[test] + fn ciphertext_format_has_correct_structure() { + let key = [0x42u8; 32]; + let plaintext = b"test"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + // version(1) + nonce(24) + ciphertext(4) + tag(16) = 45 + assert_eq!(ciphertext.len(), 1 + 24 + plaintext.len() + 16); + assert_eq!(ciphertext[0], 0x01); // version byte + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core encrypt` +Expected: FAIL — `encrypt` and `decrypt` not defined. + +- [ ] **Step 3: Write the implementation** + +Add to `crates/idfoto-core/src/crypto.rs`, above the `#[cfg(test)]` block: + +```rust +use chacha20poly1305::{ + aead::{Aead, KeyInit, OsRng}, + XChaCha20Poly1305, XNonce, +}; +use rand::RngCore; + +/// Current format version byte. +const FORMAT_VERSION: u8 = 0x01; +/// XChaCha20-Poly1305 nonce size in bytes. +const NONCE_SIZE: usize = 24; + +/// Encrypt plaintext with XChaCha20-Poly1305. +/// +/// Output format: version(1) || nonce(24) || ciphertext(N) || tag(16) +/// Nonce is generated fresh from CSPRNG on each call. +pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { + let cipher = XChaCha20Poly1305::new(key.into()); + + let mut nonce_bytes = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|_| IdfotoError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?; + + let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len()); + output.push(FORMAT_VERSION); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +/// Decrypt ciphertext produced by encrypt(). +/// +/// Expects format: version(1) || nonce(24) || ciphertext(N) || tag(16) +pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { + let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext) + if data.len() < min_len { + return Err(IdfotoError::Format(format!( + "ciphertext too short: {} bytes, need at least {}", + data.len(), + min_len + ))); + } + + let version = data[0]; + if version != FORMAT_VERSION { + return Err(IdfotoError::Format(format!( + "unsupported format version: {version}" + ))); + } + + let nonce = XNonce::from_slice(&data[1..1 + NONCE_SIZE]); + let ciphertext = &data[1 + NONCE_SIZE..]; + + let cipher = XChaCha20Poly1305::new(key.into()); + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| IdfotoError::Decrypt) +} +``` + +- [ ] **Step 4: Fix the import for OsRng** + +The `OsRng` import from chacha20poly1305 may not be re-exported. Update the imports at the top of crypto.rs: + +```rust +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, +}; +use rand::rngs::OsRng; +use rand::RngCore; +``` + +- [ ] **Step 5: Run all crypto tests** + +Run: `cargo test -p idfoto-core` +Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests). + +- [ ] **Step 6: Update lib.rs exports** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod crypto; +pub mod error; + +pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams}; +pub use error::{IdfotoError, Result}; +``` + +- [ ] **Step 7: Commit** + +```bash +git add crates/idfoto-core/src/ +git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format" +``` + +--- + +### Task 5: Entry & Manifest Data Model + +**Files:** +- Create: `crates/idfoto-core/src/entry.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write tests for serialization** + +```rust +// crates/idfoto-core/src/entry.rs + +// ... (implementation in step 3) + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_serialization_round_trip() { + let entry = Entry { + name: "GitHub".into(), + url: Some("https://github.com/login".into()), + username: Some("alee".into()), + password: "hunter2".into(), + notes: Some("2FA enabled".into()), + totp_secret: None, + created_at: "2026-04-11T22:30:00Z".into(), + updated_at: "2026-04-11T22:30:00Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let deserialized: Entry = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "GitHub"); + assert_eq!(deserialized.password, "hunter2"); + } + + #[test] + fn manifest_add_and_lookup() { + let mut manifest = Manifest::new(); + manifest.add_entry( + "a1b2c3d4".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + updated_at: "2026-04-11T22:30:00Z".into(), + }, + ); + + assert_eq!(manifest.entries.len(), 1); + assert!(manifest.entries.contains_key("a1b2c3d4")); + } + + #[test] + fn manifest_serialization_round_trip() { + let mut manifest = Manifest::new(); + manifest.add_entry( + "a1b2c3d4".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + updated_at: "2026-04-11T22:30:00Z".into(), + }, + ); + + let json = serde_json::to_string(&manifest).unwrap(); + let deserialized: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.entries.len(), 1); + assert_eq!(deserialized.version, 1); + } + + #[test] + fn generate_entry_id_is_8_hex_chars() { + let id = generate_entry_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core entry` +Expected: FAIL — types not defined. + +- [ ] **Step 3: Write the implementation** + +```rust +// crates/idfoto-core/src/entry.rs +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A single password entry (stored encrypted in entries/.enc). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub totp_secret: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Summary info about an entry (stored in the manifest). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub updated_at: String, +} + +/// The vault manifest — maps entry IDs to their metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub entries: HashMap, + pub version: u32, +} + +impl Manifest { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + version: 1, + } + } + + pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { + self.entries.insert(id, entry); + } + + pub fn remove_entry(&mut self, id: &str) -> Option { + self.entries.remove(id) + } + + /// Case-insensitive substring search on name and URL. + pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { + let query_lower = query.to_lowercase(); + self.entries + .iter() + .filter(|(_, e)| { + e.name.to_lowercase().contains(&query_lower) + || e.url + .as_ref() + .map(|u| u.to_lowercase().contains(&query_lower)) + .unwrap_or(false) + }) + .collect() + } +} + +/// Generate a random 8-character hex entry ID. +pub fn generate_entry_id() -> String { + let mut rng = rand::thread_rng(); + let value: u32 = rng.gen(); + format!("{:08x}", value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_serialization_round_trip() { + let entry = Entry { + name: "GitHub".into(), + url: Some("https://github.com/login".into()), + username: Some("alee".into()), + password: "hunter2".into(), + notes: Some("2FA enabled".into()), + totp_secret: None, + created_at: "2026-04-11T22:30:00Z".into(), + updated_at: "2026-04-11T22:30:00Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let deserialized: Entry = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "GitHub"); + assert_eq!(deserialized.password, "hunter2"); + } + + #[test] + fn manifest_add_and_lookup() { + let mut manifest = Manifest::new(); + manifest.add_entry( + "a1b2c3d4".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + updated_at: "2026-04-11T22:30:00Z".into(), + }, + ); + + assert_eq!(manifest.entries.len(), 1); + assert!(manifest.entries.contains_key("a1b2c3d4")); + } + + #[test] + fn manifest_serialization_round_trip() { + let mut manifest = Manifest::new(); + manifest.add_entry( + "a1b2c3d4".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + updated_at: "2026-04-11T22:30:00Z".into(), + }, + ); + + let json = serde_json::to_string(&manifest).unwrap(); + let deserialized: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.entries.len(), 1); + assert_eq!(deserialized.version, 1); + } + + #[test] + fn generate_entry_id_is_8_hex_chars() { + let id = generate_entry_id(); + assert_eq!(id.len(), 8); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn manifest_search_case_insensitive() { + let mut manifest = Manifest::new(); + manifest.add_entry( + "aaa".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: None, + updated_at: "2026-04-11T00:00:00Z".into(), + }, + ); + manifest.add_entry( + "bbb".into(), + ManifestEntry { + name: "Netflix".into(), + url: Some("https://netflix.com".into()), + username: None, + updated_at: "2026-04-11T00:00:00Z".into(), + }, + ); + + let results = manifest.search("github"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].1.name, "GitHub"); + + let results = manifest.search("flix"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].1.name, "Netflix"); + } +} +``` + +- [ ] **Step 4: Update lib.rs** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod crypto; +pub mod entry; +pub mod error; + +pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams}; +pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; +pub use error::{IdfotoError, Result}; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p idfoto-core entry` +Expected: All 5 entry tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/ +git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde" +``` + +--- + +### Task 6: Vault Operations + +**Files:** +- Create: `crates/idfoto-core/src/vault.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests** + +```rust +// crates/idfoto-core/src/vault.rs + +// ... (implementation in step 3) + +#[cfg(test)] +mod tests { + use super::*; + use crate::entry::{Entry, Manifest, ManifestEntry}; + + #[test] + fn entry_encrypt_decrypt_round_trip() { + let key = [0x42u8; 32]; + let entry = Entry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + password: "secret123".into(), + notes: None, + totp_secret: None, + created_at: "2026-04-11T00:00:00Z".into(), + updated_at: "2026-04-11T00:00:00Z".into(), + }; + + let encrypted = encrypt_entry(&key, &entry).unwrap(); + let decrypted = decrypt_entry(&key, &encrypted).unwrap(); + assert_eq!(decrypted.name, "GitHub"); + assert_eq!(decrypted.password, "secret123"); + } + + #[test] + fn manifest_encrypt_decrypt_round_trip() { + let key = [0x42u8; 32]; + let mut manifest = Manifest::new(); + manifest.add_entry( + "abc123".into(), + ManifestEntry { + name: "Test".into(), + url: None, + username: None, + updated_at: "2026-04-11T00:00:00Z".into(), + }, + ); + + let encrypted = encrypt_manifest(&key, &manifest).unwrap(); + let decrypted = decrypt_manifest(&key, &encrypted).unwrap(); + assert_eq!(decrypted.entries.len(), 1); + assert!(decrypted.entries.contains_key("abc123")); + } + + #[test] + fn entry_wrong_key_fails() { + let key = [0x42u8; 32]; + let wrong_key = [0x43u8; 32]; + let entry = Entry { + name: "Test".into(), + url: None, + username: None, + password: "pass".into(), + notes: None, + totp_secret: None, + created_at: "2026-04-11T00:00:00Z".into(), + updated_at: "2026-04-11T00:00:00Z".into(), + }; + + let encrypted = encrypt_entry(&key, &entry).unwrap(); + assert!(decrypt_entry(&wrong_key, &encrypted).is_err()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core vault` +Expected: FAIL — functions not defined. + +- [ ] **Step 3: Write the implementation** + +```rust +// crates/idfoto-core/src/vault.rs +use crate::crypto; +use crate::entry::{Entry, Manifest}; +use crate::error::Result; + +/// Encrypt an Entry to bytes (JSON serialized, then encrypted). +pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result> { + let json = serde_json::to_vec(entry)?; + crypto::encrypt(master_key, &json) +} + +/// Decrypt bytes back to an Entry. +pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result { + let json = crypto::decrypt(master_key, data)?; + let entry: Entry = serde_json::from_slice(&json)?; + Ok(entry) +} + +/// Encrypt the Manifest to bytes. +pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result> { + let json = serde_json::to_vec(manifest)?; + crypto::encrypt(master_key, &json) +} + +/// Decrypt bytes back to a Manifest. +pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result { + let json = crypto::decrypt(master_key, data)?; + let manifest: Manifest = serde_json::from_slice(&json)?; + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::entry::{Entry, Manifest, ManifestEntry}; + + #[test] + fn entry_encrypt_decrypt_round_trip() { + let key = [0x42u8; 32]; + let entry = Entry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + password: "secret123".into(), + notes: None, + totp_secret: None, + created_at: "2026-04-11T00:00:00Z".into(), + updated_at: "2026-04-11T00:00:00Z".into(), + }; + + let encrypted = encrypt_entry(&key, &entry).unwrap(); + let decrypted = decrypt_entry(&key, &encrypted).unwrap(); + assert_eq!(decrypted.name, "GitHub"); + assert_eq!(decrypted.password, "secret123"); + } + + #[test] + fn manifest_encrypt_decrypt_round_trip() { + let key = [0x42u8; 32]; + let mut manifest = Manifest::new(); + manifest.add_entry( + "abc123".into(), + ManifestEntry { + name: "Test".into(), + url: None, + username: None, + updated_at: "2026-04-11T00:00:00Z".into(), + }, + ); + + let encrypted = encrypt_manifest(&key, &manifest).unwrap(); + let decrypted = decrypt_manifest(&key, &encrypted).unwrap(); + assert_eq!(decrypted.entries.len(), 1); + assert!(decrypted.entries.contains_key("abc123")); + } + + #[test] + fn entry_wrong_key_fails() { + let key = [0x42u8; 32]; + let wrong_key = [0x43u8; 32]; + let entry = Entry { + name: "Test".into(), + url: None, + username: None, + password: "pass".into(), + notes: None, + totp_secret: None, + created_at: "2026-04-11T00:00:00Z".into(), + updated_at: "2026-04-11T00:00:00Z".into(), + }; + + let encrypted = encrypt_entry(&key, &entry).unwrap(); + assert!(decrypt_entry(&wrong_key, &encrypted).is_err()); + } +} +``` + +- [ ] **Step 4: Update lib.rs** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod crypto; +pub mod entry; +pub mod error; +pub mod vault; + +pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams}; +pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; +pub use error::{IdfotoError, Result}; +pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; +``` + +- [ ] **Step 5: Run all tests** + +Run: `cargo test -p idfoto-core` +Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault). + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/ +git commit -m "feat: add vault encrypt/decrypt for entries and manifest" +``` + +--- + +### Task 7: imgsecret — JPEG Decode, Y Channel, Block DCT + +**Files:** +- Create: `crates/idfoto-core/src/imgsecret.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse. + +- [ ] **Step 1: Write tests for DCT round-trip and Y channel extraction** + +```rust +// crates/idfoto-core/src/imgsecret.rs + +// ... (implementation in step 3) + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dct2_idct2_round_trip() { + let block: [f64; 64] = { + let mut b = [0.0; 64]; + for i in 0..64 { + b[i] = (i as f64) * 3.7 - 100.0; + } + b + }; + + let dct = dct2_8x8(&block); + let recovered = idct2_8x8(&dct); + + for i in 0..64 { + assert!( + (block[i] - recovered[i]).abs() < 1e-6, + "mismatch at index {i}: {} vs {}", + block[i], + recovered[i] + ); + } + } + + #[test] + fn extract_y_channel_from_synthetic_jpeg() { + let jpeg_bytes = make_test_jpeg(64, 64); + let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); + assert_eq!(y_channel.width, 64); + assert_eq!(y_channel.height, 64); + assert_eq!(y_channel.data.len(), 64 * 64); + } + + #[test] + fn get_blocks_from_region() { + let jpeg_bytes = make_test_jpeg(80, 80); + let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); + // Central 70% of 80px = 56px. With 15% margin each side = 12px offset. + // 56/8 = 7 blocks per dimension, 49 blocks total in central region. + let region = central_region(&y_channel); + assert!(region.blocks_x > 0); + assert!(region.blocks_y > 0); + } + + /// Create a synthetic JPEG for testing. + fn make_test_jpeg(width: u32, height: u32) -> Vec { + use image::{ImageBuffer, Rgb, ImageEncoder}; + use image::codecs::jpeg::JpegEncoder; + + let img = ImageBuffer::from_fn(width, height, |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 buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image( + img.as_raw(), + width, + height, + image::ExtendedColorType::Rgb8, + ) + .unwrap(); + buf + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core imgsecret` +Expected: FAIL — functions not defined. + +- [ ] **Step 3: Write the implementation** + +```rust +// crates/idfoto-core/src/imgsecret.rs +use crate::error::{IdfotoError, Result}; +use image::io::Reader as ImageReader; +use std::f64::consts::PI; +use std::io::Cursor; + +const BLOCK_SIZE: usize = 8; + +/// Y (luminance) channel data extracted from a JPEG. +pub struct YChannel { + pub data: Vec, + pub width: usize, + pub height: usize, +} + +/// Describes the central embedding region and its block grid. +pub struct EmbedRegion { + pub x_offset: usize, + pub y_offset: usize, + pub region_width: usize, + pub region_height: usize, + pub blocks_x: usize, + pub blocks_y: usize, +} + +/// Extract the Y (luminance) channel from JPEG bytes. +pub fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(jpeg_bytes)) + .with_guessed_format() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + + let img = reader + .decode() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + + let rgb = img.to_rgb8(); + let (width, height) = (rgb.width() as usize, rgb.height() as usize); + + // ITU-R BT.601 luminance conversion + let mut y_data = Vec::with_capacity(width * height); + for pixel in rgb.pixels() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + y_data.push(0.299 * r + 0.587 * g + 0.114 * b); + } + + Ok(YChannel { + data: y_data, + width, + height, + }) +} + +/// Calculate the central 70% embedding region (15% margin on each side). +pub fn central_region(y: &YChannel) -> EmbedRegion { + let margin_x = y.width * 15 / 100; + let margin_y = y.height * 15 / 100; + let x_offset = margin_x; + let y_offset = margin_y; + let region_width = y.width - 2 * margin_x; + let region_height = y.height - 2 * margin_y; + let blocks_x = region_width / BLOCK_SIZE; + let blocks_y = region_height / BLOCK_SIZE; + + EmbedRegion { + x_offset, + y_offset, + region_width, + region_height, + blocks_x, + blocks_y, + } +} + +/// Read an 8×8 block from the Y channel at block coordinates (bx, by) +/// within the given embedding region. +pub fn read_block(y: &YChannel, region: &EmbedRegion, bx: usize, by: usize) -> [f64; 64] { + let mut block = [0.0f64; 64]; + let px_x = region.x_offset + bx * BLOCK_SIZE; + let px_y = region.y_offset + by * BLOCK_SIZE; + + for row in 0..BLOCK_SIZE { + for col in 0..BLOCK_SIZE { + let idx = (px_y + row) * y.width + (px_x + col); + block[row * BLOCK_SIZE + col] = y.data[idx]; + } + } + block +} + +/// Write an 8×8 block back to the Y channel. +pub fn write_block(y: &mut YChannel, region: &EmbedRegion, bx: usize, by: usize, block: &[f64; 64]) { + let px_x = region.x_offset + bx * BLOCK_SIZE; + let px_y = region.y_offset + by * BLOCK_SIZE; + + for row in 0..BLOCK_SIZE { + for col in 0..BLOCK_SIZE { + let idx = (px_y + row) * y.width + (px_x + col); + y.data[idx] = block[row * BLOCK_SIZE + col].clamp(0.0, 255.0); + } + } +} + +/// 2D Type-II DCT on an 8×8 block (separable: rows then columns). +pub fn dct2_8x8(block: &[f64; 64]) -> [f64; 64] { + let mut temp = [0.0f64; 64]; + let mut result = [0.0f64; 64]; + + // DCT on each row + for row in 0..BLOCK_SIZE { + let src_offset = row * BLOCK_SIZE; + let mut row_data = [0.0f64; BLOCK_SIZE]; + row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]); + let dct_row = dct1d_8(&row_data); + temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&dct_row); + } + + // DCT on each column of the result + for col in 0..BLOCK_SIZE { + let mut col_data = [0.0f64; BLOCK_SIZE]; + for row in 0..BLOCK_SIZE { + col_data[row] = temp[row * BLOCK_SIZE + col]; + } + let dct_col = dct1d_8(&col_data); + for row in 0..BLOCK_SIZE { + result[row * BLOCK_SIZE + col] = dct_col[row]; + } + } + + result +} + +/// 2D inverse DCT on an 8×8 block. +pub fn idct2_8x8(block: &[f64; 64]) -> [f64; 64] { + let mut temp = [0.0f64; 64]; + let mut result = [0.0f64; 64]; + + // IDCT on each row + for row in 0..BLOCK_SIZE { + let src_offset = row * BLOCK_SIZE; + let mut row_data = [0.0f64; BLOCK_SIZE]; + row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]); + let idct_row = idct1d_8(&row_data); + temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&idct_row); + } + + // IDCT on each column + for col in 0..BLOCK_SIZE { + let mut col_data = [0.0f64; BLOCK_SIZE]; + for row in 0..BLOCK_SIZE { + col_data[row] = temp[row * BLOCK_SIZE + col]; + } + let idct_col = idct1d_8(&col_data); + for row in 0..BLOCK_SIZE { + result[row * BLOCK_SIZE + col] = idct_col[row]; + } + } + + result +} + +/// 1D Type-II DCT for N=8 (orthonormal). +fn dct1d_8(input: &[f64; 8]) -> [f64; 8] { + let mut output = [0.0f64; 8]; + let n = BLOCK_SIZE as f64; + + for k in 0..BLOCK_SIZE { + let mut sum = 0.0; + for i in 0..BLOCK_SIZE { + sum += input[i] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos(); + } + let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; + output[k] = ck * sum; + } + output +} + +/// 1D inverse DCT for N=8 (orthonormal). +fn idct1d_8(input: &[f64; 8]) -> [f64; 8] { + let mut output = [0.0f64; 8]; + let n = BLOCK_SIZE as f64; + + for i in 0..BLOCK_SIZE { + let mut sum = 0.0; + for k in 0..BLOCK_SIZE { + let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; + sum += ck * input[k] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos(); + } + output[i] = sum; + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dct2_idct2_round_trip() { + let block: [f64; 64] = { + let mut b = [0.0; 64]; + for i in 0..64 { + b[i] = (i as f64) * 3.7 - 100.0; + } + b + }; + + let dct = dct2_8x8(&block); + let recovered = idct2_8x8(&dct); + + for i in 0..64 { + assert!( + (block[i] - recovered[i]).abs() < 1e-6, + "mismatch at index {i}: {} vs {}", + block[i], + recovered[i] + ); + } + } + + #[test] + fn extract_y_channel_from_synthetic_jpeg() { + let jpeg_bytes = make_test_jpeg(64, 64); + let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); + assert_eq!(y_channel.width, 64); + assert_eq!(y_channel.height, 64); + assert_eq!(y_channel.data.len(), 64 * 64); + } + + #[test] + fn get_blocks_from_region() { + let jpeg_bytes = make_test_jpeg(80, 80); + let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); + let region = central_region(&y_channel); + assert!(region.blocks_x > 0); + assert!(region.blocks_y > 0); + } + + #[test] + fn read_write_block_round_trip() { + let jpeg_bytes = make_test_jpeg(80, 80); + let mut y_channel = extract_y_channel(&jpeg_bytes).unwrap(); + let region = central_region(&y_channel); + + let original = read_block(&y_channel, ®ion, 0, 0); + write_block(&mut y_channel, ®ion, 0, 0, &original); + let recovered = read_block(&y_channel, ®ion, 0, 0); + + for i in 0..64 { + assert!((original[i] - recovered[i]).abs() < 1e-10); + } + } + + fn make_test_jpeg(width: u32, height: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ImageBuffer, ImageEncoder, Rgb}; + + let img = ImageBuffer::from_fn(width, height, |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 buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + buf + } +} +``` + +- [ ] **Step 4: Update lib.rs** + +```rust +// crates/idfoto-core/src/lib.rs +pub mod crypto; +pub mod entry; +pub mod error; +pub mod imgsecret; +pub mod vault; + +pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams}; +pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; +pub use error::{IdfotoError, Result}; +pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p idfoto-core imgsecret` +Expected: All 4 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/ +git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DCT" +``` + +--- + +### Task 8: imgsecret — QIM Embedding + Block Selection + +**Files:** +- Modify: `crates/idfoto-core/src/imgsecret.rs` + +This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data. + +- [ ] **Step 1: Write tests for QIM and block selection** + +Add to `mod tests` in `imgsecret.rs`: + +```rust + #[test] + fn qim_embed_extract_single_bit() { + for coef in [-50.0, -10.0, 0.0, 10.0, 50.0, 127.0] { + for bit in [0u8, 1] { + let modified = qim_embed(coef, bit, QUANT_STEP); + let extracted = qim_extract(modified, QUANT_STEP); + assert_eq!(extracted, bit, "failed for coef={coef}, bit={bit}"); + } + } + } + + #[test] + fn qim_survives_small_noise() { + let coef = 42.0; + let modified = qim_embed(coef, 1, QUANT_STEP); + // Add noise smaller than QUANT_STEP/4 + let noisy = modified + 3.0; + let extracted = qim_extract(noisy, QUANT_STEP); + assert_eq!(extracted, 1); + } + + #[test] + fn select_embed_blocks_returns_consistent_pattern() { + let region = EmbedRegion { + x_offset: 12, + y_offset: 9, + region_width: 56, + region_height: 56, + blocks_x: 7, + blocks_y: 7, + }; + + let blocks1 = select_embed_blocks(®ion, 100); + let blocks2 = select_embed_blocks(®ion, 100); + assert_eq!(blocks1, blocks2, "pattern must be deterministic"); + assert!(!blocks1.is_empty()); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core qim` +Expected: FAIL — `qim_embed`, `qim_extract`, `select_embed_blocks`, `QUANT_STEP` not defined. + +- [ ] **Step 3: Write QIM and block selection implementation** + +Add above the `#[cfg(test)]` block in `imgsecret.rs`: + +```rust +/// QIM quantization step. Larger = more robust, more visible. +/// 25 survives JPEG recompression down to ~Q60 on most images. +const QUANT_STEP: f64 = 25.0; + +/// Mid-frequency DCT coefficient positions in zig-zag order. +/// Positions 4-15: robust to recompression, visually undetectable. +/// Index into the 8×8 block as (row, col). +const EMBED_POSITIONS: [(usize, usize); 12] = [ + (0, 3), // zig-zag position 4 + (1, 2), // 5 + (2, 1), // 6 + (3, 0), // 7 + (0, 4), // 8 + (1, 3), // 9 + (2, 2), // 10 + (3, 1), // 11 + (4, 0), // 12 + (0, 5), // 13 + (1, 4), // 14 + (2, 3), // 15 +]; + +/// Embed a single bit into a DCT coefficient using QIM. +/// +/// Grid 0: values quantized to 0, Q, 2Q, 3Q, ... +/// Grid 1: values quantized to Q/2, 3Q/2, 5Q/2, ... +/// To embed bit b, quantize to grid b. +pub fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { + let half_q = q / 2.0; + let offset = if bit == 1 { half_q } else { 0.0 }; + let shifted = coef - offset; + let quantized = (shifted / q).round() * q; + quantized + offset +} + +/// Extract a single bit from a DCT coefficient using QIM. +/// +/// Measures distance to grid 0 and grid 1, returns whichever is closer. +pub fn qim_extract(coef: f64, q: f64) -> u8 { + let half_q = q / 2.0; + let dist0 = (coef - (coef / q).round() * q).abs(); + let shifted = coef - half_q; + let dist1 = (shifted - (shifted / q).round() * q).abs(); + if dist0 <= dist1 { 0 } else { 1 } +} + +/// Select embedding block positions using a fixed geometric pattern. +/// +/// Evenly spaced across the central region. Returns (bx, by) pairs. +/// `target_count` is the desired number of blocks; actual may be less +/// if the region is small. +pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> { + let total_blocks = region.blocks_x * region.blocks_y; + let count = target_count.min(total_blocks); + if count == 0 { + return vec![]; + } + + // Compute stride to evenly space `count` blocks across the grid + let stride = if count >= total_blocks { + 1 + } else { + total_blocks / count + }; + + let mut positions = Vec::with_capacity(count); + for i in (0..total_blocks).step_by(stride) { + let bx = i % region.blocks_x; + let by = i / region.blocks_x; + positions.push((bx, by)); + if positions.len() >= count { + break; + } + } + positions +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p idfoto-core imgsecret` +Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/imgsecret.rs +git commit -m "feat: add QIM bit embedding and fixed-pattern block selection" +``` + +--- + +### Task 9: imgsecret — Full embed() and extract() + +**Files:** +- Modify: `crates/idfoto-core/src/imgsecret.rs` + +This is the main event: the public `embed()` and `extract()` functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10. + +- [ ] **Step 1: Write the failing test for round-trip embed/extract** + +Add to `mod tests`: + +```rust + #[test] + fn embed_extract_round_trip() { + let jpeg_bytes = make_test_jpeg(400, 300); + let secret = [0xDEu8; 32]; + + let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); + let extracted = extract(&stego_jpeg).unwrap(); + assert_eq!(extracted, secret); + } + + #[test] + fn embed_extract_random_secret() { + let jpeg_bytes = make_test_jpeg(400, 300); + let mut secret = [0u8; 32]; + rand::thread_rng().fill(&mut secret); + + let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); + let extracted = extract(&stego_jpeg).unwrap(); + assert_eq!(extracted, secret); + } + + #[test] + fn extract_from_non_embedded_image_fails() { + let jpeg_bytes = make_test_jpeg(400, 300); + let result = extract(&jpeg_bytes); + assert!(result.is_err()); + } + + #[test] + fn image_too_small_fails() { + let jpeg_bytes = make_test_jpeg(32, 32); + let secret = [0xABu8; 32]; + let result = embed(&jpeg_bytes, &secret); + assert!(result.is_err()); + } +``` + +Add `use rand::Fill;` at the top of the test module for the random fill. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p idfoto-core embed_extract` +Expected: FAIL — `embed` and `extract` not defined. + +- [ ] **Step 3: Write embed() implementation** + +Add to `imgsecret.rs`: + +```rust +use image::codecs::jpeg::JpegEncoder; +use image::{ImageBuffer, ImageEncoder, Rgb}; + +/// Number of bits per redundant copy of the secret. +const SECRET_BITS: usize = 256; // 32 bytes +/// Minimum number of redundant copies for reliable extraction. +const MIN_COPIES: usize = 5; +/// Bits per embedding block (number of mid-freq coefficients used). +const BITS_PER_BLOCK: usize = EMBED_POSITIONS.len(); // 12 +/// Blocks needed for one copy of the secret. +const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // ceil(256/12) = 22 +/// Minimum image dimension for embedding. +const MIN_DIMENSION: u32 = 100; + +/// Embed a 256-bit secret into a carrier JPEG. +/// Returns the modified JPEG bytes. +pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { + let mut y = extract_y_channel(carrier_jpeg)?; + let region = central_region(&y); + + // Check minimum size + if y.width < MIN_DIMENSION as usize || y.height < MIN_DIMENSION as usize { + return Err(IdfotoError::ImageTooSmall { + min_width: MIN_DIMENSION, + min_height: MIN_DIMENSION, + actual_width: y.width as u32, + actual_height: y.height as u32, + }); + } + + let total_blocks = region.blocks_x * region.blocks_y; + let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); // cap at 50 copies + + if num_copies < MIN_COPIES { + return Err(IdfotoError::ImgSecret(format!( + "image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}" + ))); + } + + // Convert secret to bits + let secret_bits = bytes_to_bits(secret); + + // Get all embed block positions + let blocks_needed = num_copies * BLOCKS_PER_COPY; + let positions = select_embed_blocks(®ion, blocks_needed); + + // Embed each redundant copy + for copy_idx in 0..num_copies { + let block_start = copy_idx * BLOCKS_PER_COPY; + for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY] + .iter() + .enumerate() + { + let mut block = read_block(&y, ®ion, bx, by); + let mut dct = dct2_8x8(&block); + + // Embed up to BITS_PER_BLOCK bits in this block + for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { + let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx; + if bit_idx < SECRET_BITS { + let coef_idx = row * BLOCK_SIZE + col; + dct[coef_idx] = qim_embed(dct[coef_idx], secret_bits[bit_idx], QUANT_STEP); + } + } + + block = idct2_8x8(&dct); + write_block(&mut y, ®ion, bx, by, &block); + } + } + + // Reconstruct full RGB image with modified Y channel and save as JPEG + reconstruct_jpeg(carrier_jpeg, &y) +} + +/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. +pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + extract_at_offset(jpeg_bytes, 0, 0) +} + +/// Try extraction at a specific pixel offset (for crop recovery). +fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]> { + let y = extract_y_channel(jpeg_bytes)?; + let mut region = central_region(&y); + + // Apply offset for crop recovery + let new_x = region.x_offset as isize + dx; + let new_y = region.y_offset as isize + dy; + if new_x < 0 || new_y < 0 { + return Err(IdfotoError::ExtractionFailed); + } + region.x_offset = new_x as usize; + region.y_offset = new_y as usize; + + // Recalculate blocks that fit + let avail_w = y.width.saturating_sub(region.x_offset); + let avail_h = y.height.saturating_sub(region.y_offset); + region.blocks_x = avail_w / BLOCK_SIZE; + region.blocks_y = avail_h / BLOCK_SIZE; + + let total_blocks = region.blocks_x * region.blocks_y; + let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); + + if num_copies < 1 { + return Err(IdfotoError::ExtractionFailed); + } + + let blocks_needed = num_copies * BLOCKS_PER_COPY; + let positions = select_embed_blocks(®ion, blocks_needed); + + // Extract all copies and majority-vote each bit + let mut bit_votes = vec![[0u32; 2]; SECRET_BITS]; // votes[bit_idx][0 or 1] + + for copy_idx in 0..num_copies { + let block_start = copy_idx * BLOCKS_PER_COPY; + if block_start + BLOCKS_PER_COPY > positions.len() { + break; + } + + for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY] + .iter() + .enumerate() + { + let block = read_block(&y, ®ion, bx, by); + let dct = dct2_8x8(&block); + + for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { + let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx; + if bit_idx < SECRET_BITS { + let coef_idx = row * BLOCK_SIZE + col; + let bit = qim_extract(dct[coef_idx], QUANT_STEP); + bit_votes[bit_idx][bit as usize] += 1; + } + } + } + } + + // Majority vote + let mut secret_bits = vec![0u8; SECRET_BITS]; + let mut confidence = 0u32; + for (i, votes) in bit_votes.iter().enumerate() { + if votes[1] > votes[0] { + secret_bits[i] = 1; + confidence += votes[1]; + } else { + confidence += votes[0]; + } + } + + // Confidence check: if votes are too evenly split, extraction likely failed + let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum(); + let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree + if confidence < min_confidence { + return Err(IdfotoError::ExtractionFailed); + } + + Ok(bits_to_bytes(&secret_bits)) +} + +// ---- Helper functions ---- + +fn bytes_to_bits(bytes: &[u8]) -> Vec { + let mut bits = Vec::with_capacity(bytes.len() * 8); + for &byte in bytes { + for i in (0..8).rev() { + bits.push((byte >> i) & 1); + } + } + bits +} + +fn bits_to_bytes(bits: &[u8]) -> [u8; 32] { + let mut bytes = [0u8; 32]; + for (i, chunk) in bits.chunks(8).enumerate() { + if i >= 32 { + break; + } + let mut byte = 0u8; + for (j, &bit) in chunk.iter().enumerate() { + byte |= bit << (7 - j); + } + bytes[i] = byte; + } + bytes +} + +/// Reconstruct JPEG from original image with modified Y channel. +fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result> { + let reader = ImageReader::new(Cursor::new(original_jpeg)) + .with_guessed_format() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + + let img = reader + .decode() + .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + + let rgb = img.to_rgb8(); + let (width, height) = (rgb.width(), rgb.height()); + + // Reconstruct RGB: replace Y while keeping original Cb/Cr + let mut output: ImageBuffer, Vec> = ImageBuffer::new(width, height); + + for (x, y_pos, pixel) in rgb.enumerate_pixels() { + let idx = y_pos as usize * width as usize + x as usize; + let orig_r = pixel[0] as f64; + let orig_g = pixel[1] as f64; + let orig_b = pixel[2] as f64; + + // Original YCbCr + let orig_y = 0.299 * orig_r + 0.587 * orig_g + 0.114 * orig_b; + let cb = 128.0 - 0.168736 * orig_r - 0.331264 * orig_g + 0.5 * orig_b; + let cr = 128.0 + 0.5 * orig_r - 0.418688 * orig_g - 0.081312 * orig_b; + + // Modified Y + let new_y = y_modified.data[idx]; + + // YCbCr → RGB with new Y + let r = (new_y + 1.402 * (cr - 128.0)).clamp(0.0, 255.0) as u8; + let g = (new_y - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0)).clamp(0.0, 255.0) as u8; + let b = (new_y + 1.772 * (cb - 128.0)).clamp(0.0, 255.0) as u8; + + output.put_pixel(x, y_pos, Rgb([r, g, b])); + } + + // Encode as JPEG at quality 92 + let mut buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?; + Ok(buf) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p idfoto-core imgsecret -- --nocapture` +Expected: All tests PASS including embed/extract round-trip. + +- [ ] **Step 5: Add a JPEG recompression survival test** + +Add to `mod tests`: + +```rust + #[test] + fn embed_extract_survives_recompression_q85() { + let jpeg_bytes = make_test_jpeg(400, 300); + let secret = [0xCAu8; 32]; + + let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); + + // Re-encode at Q85 (simulating social media) + let reader = image::io::Reader::new(Cursor::new(&stego_jpeg)) + .with_guessed_format() + .unwrap(); + let img = reader.decode().unwrap(); + let rgb = img.to_rgb8(); + let (w, h) = (rgb.width(), rgb.height()); + + let mut recompressed = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85); + encoder + .write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8) + .unwrap(); + + let extracted = extract(&recompressed).unwrap(); + assert_eq!(extracted, secret, "secret must survive Q85 recompression"); + } +``` + +- [ ] **Step 6: Run all tests** + +Run: `cargo test -p idfoto-core` +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/idfoto-core/src/imgsecret.rs +git commit -m "feat: add imgsecret embed/extract with redundancy and majority voting" +``` + +--- + +### Task 10: imgsecret — Crop Recovery + +**Files:** +- Modify: `crates/idfoto-core/src/imgsecret.rs` + +- [ ] **Step 1: Write failing crop test** + +Add to `mod tests`: + +```rust + #[test] + fn embed_extract_survives_10pct_crop() { + let jpeg_bytes = make_test_jpeg(400, 300); + let secret = [0xBBu8; 32]; + + let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); + + // Crop 10% from the right edge + let reader = image::io::Reader::new(Cursor::new(&stego_jpeg)) + .with_guessed_format() + .unwrap(); + let img = reader.decode().unwrap(); + let (w, h) = (img.width(), img.height()); + let crop_pixels = w * 10 / 100; + let cropped = img.crop_imm(0, 0, w - crop_pixels, h); + let rgb = cropped.to_rgb8(); + + let mut cropped_jpeg = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92); + encoder + .write_image( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .unwrap(); + + let extracted = extract_with_crop_recovery(&cropped_jpeg).unwrap(); + assert_eq!(extracted, secret, "secret must survive 10% crop"); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p idfoto-core crop` +Expected: FAIL — `extract_with_crop_recovery` not defined. + +- [ ] **Step 3: Write crop recovery implementation** + +Add to `imgsecret.rs`: + +```rust +/// Extract with crop recovery — tries multiple offsets if canonical extraction fails. +pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + // Try canonical alignment first (fast path) + if let Ok(secret) = extract(jpeg_bytes) { + return Ok(secret); + } + + // Determine search range from image dimensions + let y = extract_y_channel(jpeg_bytes)?; + let max_dx = (y.width as isize) * 15 / 100; + let max_dy = (y.height as isize) * 15 / 100; + let step = BLOCK_SIZE as isize; // 8 pixels + + // Search crop offsets + for dy in (-max_dy..=max_dy).step_by(step as usize) { + for dx in (-max_dx..=max_dx).step_by(step as usize) { + if dx == 0 && dy == 0 { + continue; // already tried canonical + } + if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) { + return Ok(secret); + } + } + } + + Err(IdfotoError::ExtractionFailed) +} +``` + +Also update the public `extract()` to call `extract_with_crop_recovery()`: + +Replace the existing `extract` function: + +```rust +/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. +/// Automatically tries crop recovery if canonical extraction fails. +pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + extract_with_crop_recovery(jpeg_bytes) +} + +/// Internal: try extraction at canonical (0,0) offset only. +fn extract_canonical(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + extract_at_offset(jpeg_bytes, 0, 0) +} + +/// Extract with crop recovery — tries multiple offsets if canonical extraction fails. +fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + // Try canonical alignment first (fast path) + if let Ok(secret) = extract_canonical(jpeg_bytes) { + return Ok(secret); + } + + // Determine search range from image dimensions + let y = extract_y_channel(jpeg_bytes)?; + let max_dx = (y.width as isize) * 15 / 100; + let max_dy = (y.height as isize) * 15 / 100; + let step = BLOCK_SIZE as isize; + + for dy in (-max_dy..=max_dy).step_by(step as usize) { + for dx in (-max_dx..=max_dx).step_by(step as usize) { + if dx == 0 && dy == 0 { + continue; + } + if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) { + return Ok(secret); + } + } + } + + Err(IdfotoError::ExtractionFailed) +} +``` + +- [ ] **Step 4: Run all imgsecret tests** + +Run: `cargo test -p idfoto-core imgsecret -- --nocapture` +Expected: All tests PASS including crop recovery. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/imgsecret.rs +git commit -m "feat: add crop recovery with multi-offset extraction search" +``` + +--- + +### Task 11: CLI — Scaffolding, init, generate + +**Files:** +- Modify: `crates/idfoto-cli/src/main.rs` + +- [ ] **Step 1: Write the clap CLI structure** + +```rust +// crates/idfoto-cli/src/main.rs +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use idfoto_core::{ + decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest, + generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Parser)] +#[command(name = "idfoto", version, about = "Git-backed password manager with reference image authentication")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create a new vault + Init { + /// Path to carrier JPEG image + #[arg(long)] + image: PathBuf, + /// Output path for the reference JPEG (with embedded secret) + #[arg(long, default_value = "reference.jpg")] + output: PathBuf, + }, + /// Add a new credential + Add, + /// Get a credential by name + Get { + /// Name or URL to search for + name: String, + }, + /// List all credentials + List, + /// Edit a credential + Edit { + /// Name or URL to search for + name: String, + }, + /// Remove a credential + Rm { + /// Name or URL to search for + name: String, + }, + /// Sync with remote (git pull + push) + Sync, + /// Generate a random password + Generate { + /// Password length + #[arg(short, long, default_value = "20")] + length: usize, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Init { image, output } => cmd_init(&image, &output), + Commands::Add => cmd_add(), + Commands::Get { name } => cmd_get(&name), + Commands::List => cmd_list(), + Commands::Edit { name } => cmd_edit(&name), + Commands::Rm { name } => cmd_rm(&name), + Commands::Sync => cmd_sync(), + Commands::Generate { length } => cmd_generate(length), + } +} + +fn cmd_generate(length: usize) -> Result<()> { + use rand::Rng; + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; + let mut rng = rand::thread_rng(); + let password: String = (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect(); + println!("{password}"); + Ok(()) +} + +// ---- Vault helpers ---- + +/// Path to the vault root (current directory by default). +fn vault_dir() -> PathBuf { + PathBuf::from(".") +} + +fn idfoto_dir() -> PathBuf { + vault_dir().join(".idfoto") +} + +fn read_salt() -> Result<[u8; 32]> { + let bytes = fs::read(idfoto_dir().join("salt")) + .context("failed to read .idfoto/salt — is this a vault directory?")?; + let mut salt = [0u8; 32]; + salt.copy_from_slice(&bytes); + Ok(salt) +} + +fn read_params() -> Result { + let json = fs::read_to_string(idfoto_dir().join("params.json")) + .context("failed to read .idfoto/params.json")?; + Ok(serde_json::from_str(&json)?) +} + +/// Prompt for passphrase and reference image, derive master_key. +fn unlock(image_path: &Path) -> Result<[u8; 32]> { + let passphrase = rpassword::prompt_password("Passphrase: ") + .context("failed to read passphrase")?; + + let jpeg_bytes = fs::read(image_path) + .context("failed to read reference image")?; + + let image_secret = idfoto_core::imgsecret::extract(&jpeg_bytes) + .map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?; + + let salt = read_salt()?; + let params = read_params()?; + + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?; + + Ok(master_key) +} + +/// Get reference image path — from env var IDFOTO_IMAGE or prompt. +fn get_image_path() -> Result { + if let Ok(path) = std::env::var("IDFOTO_IMAGE") { + return Ok(PathBuf::from(path)); + } + eprint!("Reference image path: "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(PathBuf::from(input.trim())) +} + +fn read_manifest(master_key: &[u8; 32]) -> Result { + let data = fs::read(vault_dir().join("manifest.enc")) + .context("failed to read manifest.enc")?; + decrypt_manifest(master_key, &data) + .map_err(|e| anyhow::anyhow!("failed to decrypt manifest: {e}")) +} + +fn write_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<()> { + let data = encrypt_manifest(master_key, manifest) + .map_err(|e| anyhow::anyhow!("failed to encrypt manifest: {e}"))?; + fs::write(vault_dir().join("manifest.enc"), data)?; + Ok(()) +} + +fn git_commit(message: &str) -> Result<()> { + Command::new("git") + .args(["add", "-A"]) + .status() + .context("git add failed")?; + Command::new("git") + .args(["commit", "-m", message]) + .status() + .context("git commit failed")?; + Ok(()) +} + +fn now_iso8601() -> String { + // Simple UTC timestamp without chrono dependency + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + let secs = duration.as_secs(); + // Rough ISO 8601 — good enough for a password manager + format!("{secs}") +} + +// ---- Command implementations ---- + +fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> { + // 1. Read carrier image + let carrier_jpeg = fs::read(image_path) + .context("failed to read carrier image")?; + + // 2. Generate image_secret and embed + let mut image_secret = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret); + + println!("Embedding secret into reference image..."); + let stego_jpeg = idfoto_core::imgsecret::embed(&carrier_jpeg, &image_secret) + .map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?; + fs::write(output_path, &stego_jpeg) + .context("failed to write reference image")?; + println!("Reference image saved to: {}", output_path.display()); + + // 3. Prompt for passphrase + let passphrase = rpassword::prompt_password("Choose a passphrase: ")?; + let passphrase_confirm = rpassword::prompt_password("Confirm passphrase: ")?; + if passphrase != passphrase_confirm { + anyhow::bail!("passphrases do not match"); + } + if passphrase.len() < 8 { + anyhow::bail!("passphrase must be at least 8 characters"); + } + + // 4. Generate salt and derive key + let mut salt = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); + let params = KdfParams::default(); + + println!("Deriving master key (this may take a moment)..."); + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?; + + // 5. Write vault structure + fs::create_dir_all(idfoto_dir())?; + fs::create_dir_all(vault_dir().join("entries"))?; + fs::write(idfoto_dir().join("salt"), salt)?; + fs::write( + idfoto_dir().join("params.json"), + serde_json::to_string_pretty(¶ms)?, + )?; + fs::write(idfoto_dir().join("devices.json"), "[]")?; + + // 6. Write empty manifest + let manifest = Manifest::new(); + write_manifest(&master_key, &manifest)?; + + // 7. Git init + commit + Command::new("git").args(["init"]).status()?; + + // Add .gitignore + fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")?; + + git_commit("feat: initialize idfoto vault")?; + + println!("\nVault initialized successfully!"); + println!("IMPORTANT: Keep your reference image ({}) safe — you need it to unlock the vault.", output_path.display()); + Ok(()) +} + +fn cmd_add() -> Result<()> { + let image_path = get_image_path()?; + let master_key = unlock(&image_path)?; + let mut manifest = read_manifest(&master_key)?; + + // Prompt for entry fields + let name = prompt("Name (e.g., GitHub): ")?; + let url = prompt_optional("URL: ")?; + let username = prompt_optional("Username: ")?; + let password = rpassword::prompt_password("Password (or press Enter to generate): ")?; + let password = if password.is_empty() { + let p = generate_password(20); + println!("Generated: {p}"); + p + } else { + password + }; + let notes = prompt_optional("Notes: ")?; + let totp = prompt_optional("TOTP secret: ")?; + + let now = now_iso8601(); + let entry = Entry { + name: name.clone(), + url: url.clone(), + username: username.clone(), + password, + notes, + totp_secret: totp, + created_at: now.clone(), + updated_at: now.clone(), + }; + + let entry_id = generate_entry_id(); + let encrypted = encrypt_entry(&master_key, &entry) + .map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?; + + fs::write(vault_dir().join("entries").join(format!("{entry_id}.enc")), encrypted)?; + + manifest.add_entry( + entry_id.clone(), + ManifestEntry { + name: name.clone(), + url, + username, + updated_at: now, + }, + ); + write_manifest(&master_key, &manifest)?; + + git_commit(&format!("add: {name}"))?; + println!("Added entry: {name} ({entry_id})"); + Ok(()) +} + +fn cmd_get(query: &str) -> Result<()> { + let image_path = get_image_path()?; + let master_key = unlock(&image_path)?; + let manifest = read_manifest(&master_key)?; + + let results = manifest.search(query); + if results.is_empty() { + anyhow::bail!("no entries matching '{query}'"); + } + + let (id, meta) = if results.len() == 1 { + results[0] + } else { + println!("Multiple matches:"); + for (i, (id, e)) in results.iter().enumerate() { + println!(" {}: {} ({})", i + 1, e.name, id); + } + let choice = prompt("Choose (number): ")?; + let idx: usize = choice.trim().parse().context("invalid number")?; + results.get(idx - 1).context("invalid choice")? + }; + + let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?; + let entry = decrypt_entry(&master_key, &entry_data) + .map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?; + + println!("Name: {}", entry.name); + if let Some(ref url) = entry.url { + println!("URL: {url}"); + } + if let Some(ref user) = entry.username { + println!("Username: {user}"); + } + println!("Password: {}", entry.password); + if let Some(ref notes) = entry.notes { + println!("Notes: {notes}"); + } + + // Copy to clipboard + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if clipboard.set_text(&entry.password).is_ok() { + println!("\n(Password copied to clipboard — clears in 30s)"); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(""); + } + }); + } + } + + Ok(()) +} + +fn cmd_list() -> Result<()> { + let image_path = get_image_path()?; + let master_key = unlock(&image_path)?; + let manifest = read_manifest(&master_key)?; + + if manifest.entries.is_empty() { + println!("Vault is empty."); + return Ok(()); + } + + let mut entries: Vec<_> = manifest.entries.iter().collect(); + entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase())); + + for (id, entry) in entries { + let url = entry.url.as_deref().unwrap_or("-"); + let user = entry.username.as_deref().unwrap_or("-"); + println!("{id} {:<20} {:<30} {user}", entry.name, url); + } + Ok(()) +} + +fn cmd_edit(query: &str) -> Result<()> { + let image_path = get_image_path()?; + let master_key = unlock(&image_path)?; + let mut manifest = read_manifest(&master_key)?; + + let results = manifest.search(query); + if results.is_empty() { + anyhow::bail!("no entries matching '{query}'"); + } + let (id, _) = results[0]; + let id = id.clone(); + + let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?; + let mut entry = decrypt_entry(&master_key, &entry_data) + .map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?; + + println!("Editing: {} (press Enter to keep current value)", entry.name); + entry.name = prompt_with_default("Name", &entry.name)?; + entry.url = Some(prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty()); + entry.username = Some(prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty()); + let new_pass = rpassword::prompt_password("Password (Enter to keep): ")?; + if !new_pass.is_empty() { + entry.password = new_pass; + } + entry.notes = Some(prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty()); + entry.updated_at = now_iso8601(); + + let encrypted = encrypt_entry(&master_key, &entry) + .map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?; + fs::write(vault_dir().join("entries").join(format!("{id}.enc")), encrypted)?; + + // Update manifest + if let Some(me) = manifest.entries.get_mut(&id) { + me.name = entry.name.clone(); + me.url = entry.url.clone(); + me.username = entry.username.clone(); + me.updated_at = entry.updated_at.clone(); + } + write_manifest(&master_key, &manifest)?; + + git_commit(&format!("edit: {}", entry.name))?; + println!("Updated: {}", entry.name); + Ok(()) +} + +fn cmd_rm(query: &str) -> Result<()> { + let image_path = get_image_path()?; + let master_key = unlock(&image_path)?; + let mut manifest = read_manifest(&master_key)?; + + let results = manifest.search(query); + if results.is_empty() { + anyhow::bail!("no entries matching '{query}'"); + } + let (id, meta) = results[0]; + let id = id.clone(); + let name = meta.name.clone(); + + let confirm = prompt(&format!("Delete '{name}'? (y/N): "))?; + if confirm.trim().to_lowercase() != "y" { + println!("Cancelled."); + return Ok(()); + } + + fs::remove_file(vault_dir().join("entries").join(format!("{id}.enc")))?; + manifest.remove_entry(&id); + write_manifest(&master_key, &manifest)?; + + git_commit(&format!("rm: {name}"))?; + println!("Deleted: {name}"); + Ok(()) +} + +fn cmd_sync() -> Result<()> { + println!("Pulling..."); + let pull = Command::new("git") + .args(["pull", "--rebase"]) + .status() + .context("git pull failed")?; + if !pull.success() { + anyhow::bail!("git pull failed"); + } + + println!("Pushing..."); + let push = Command::new("git") + .args(["push"]) + .status() + .context("git push failed")?; + if !push.success() { + anyhow::bail!("git push failed"); + } + + println!("Synced."); + Ok(()) +} + +// ---- Terminal helpers ---- + +fn prompt(message: &str) -> Result { + eprint!("{message}"); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn prompt_optional(message: &str) -> Result> { + let input = prompt(message)?; + Ok(if input.is_empty() { None } else { Some(input) }) +} + +fn prompt_with_default(field: &str, current: &str) -> Result { + let display = if current.is_empty() { + format!("{field}: ") + } else { + format!("{field} [{current}]: ") + }; + let input = prompt(&display)?; + Ok(if input.is_empty() { + current.to_string() + } else { + input + }) +} + +fn generate_password(length: usize) -> String { + use rand::Rng; + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; + let mut rng = rand::thread_rng(); + (0..length) + .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) + .collect() +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cargo build` +Expected: Compiles with no errors. + +- [ ] **Step 3: Test generate command** + +Run: `cargo run -- generate` +Expected: Prints a random 20-character password. + +Run: `cargo run -- generate -l 32` +Expected: Prints a random 32-character password. + +- [ ] **Step 4: Test help output** + +Run: `cargo run -- --help` +Expected: Shows all subcommands with descriptions. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-cli/src/main.rs +git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, generate" +``` + +--- + +### Task 12: CLI — Device Management + +**Files:** +- Modify: `crates/idfoto-cli/src/main.rs` + +- [ ] **Step 1: Add device subcommands to the CLI** + +Add to the `Commands` enum: + +```rust + /// Manage trusted devices + Device { + #[command(subcommand)] + action: DeviceCommands, + }, +``` + +Add the subcommand enum: + +```rust +#[derive(Subcommand)] +enum DeviceCommands { + /// Register this device + Add { + /// Device name + #[arg(long)] + name: String, + }, + /// List authorized devices + List, + /// Revoke a device + Revoke { + /// Device name to revoke + name: String, + }, +} +``` + +Add the match arm in `main()`: + +```rust + Commands::Device { action } => match action { + DeviceCommands::Add { name } => cmd_device_add(&name), + DeviceCommands::List => cmd_device_list(), + DeviceCommands::Revoke { name } => cmd_device_revoke(&name), + }, +``` + +- [ ] **Step 2: Write device management functions** + +Add to `main.rs`: + +```rust +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DeviceEntry { + name: String, + public_key: String, // hex-encoded ed25519 public key +} + +fn read_devices() -> Result> { + let json = fs::read_to_string(idfoto_dir().join("devices.json")) + .context("failed to read devices.json")?; + Ok(serde_json::from_str(&json)?) +} + +fn write_devices(devices: &[DeviceEntry]) -> Result<()> { + let json = serde_json::to_string_pretty(devices)?; + fs::write(idfoto_dir().join("devices.json"), json)?; + Ok(()) +} + +fn cmd_device_add(name: &str) -> Result<()> { + let mut devices = read_devices()?; + + if devices.iter().any(|d| d.name == name) { + anyhow::bail!("device '{name}' already registered"); + } + + // Generate ed25519 keypair + let signing_key = SigningKey::generate(&mut rand::rngs::OsRng); + let verifying_key: VerifyingKey = (&signing_key).into(); + let pubkey_hex = hex::encode(verifying_key.as_bytes()); + + // Save private key to local config + let config_dir = dirs::config_dir() + .context("no config directory")? + .join("idfoto"); + fs::create_dir_all(&config_dir)?; + fs::write( + config_dir.join(format!("{name}.key")), + hex::encode(signing_key.to_bytes()), + )?; + + devices.push(DeviceEntry { + name: name.to_string(), + public_key: pubkey_hex.clone(), + }); + write_devices(&devices)?; + + git_commit(&format!("device: add {name}"))?; + println!("Device '{name}' registered (pubkey: {pubkey_hex})"); + println!("Private key saved to: {}", config_dir.join(format!("{name}.key")).display()); + Ok(()) +} + +fn cmd_device_list() -> Result<()> { + let devices = read_devices()?; + if devices.is_empty() { + println!("No devices registered."); + return Ok(()); + } + for d in &devices { + println!(" {} — {}", d.name, &d.public_key[..16]); + } + Ok(()) +} + +fn cmd_device_revoke(name: &str) -> Result<()> { + let mut devices = read_devices()?; + let before = devices.len(); + devices.retain(|d| d.name != name); + if devices.len() == before { + anyhow::bail!("device '{name}' not found"); + } + write_devices(&devices)?; + git_commit(&format!("device: revoke {name}"))?; + println!("Device '{name}' revoked."); + Ok(()) +} +``` + +- [ ] **Step 3: Add hex dependency** + +Add to `crates/idfoto-cli/Cargo.toml` under `[dependencies]`: + +```toml +hex = "0.4" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +- [ ] **Step 4: Verify build** + +Run: `cargo build` +Expected: Compiles cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-cli/ +git commit -m "feat: add device add/list/revoke commands with ed25519 key management" +``` + +--- + +### Task 13: Integration Test — Full Vault Workflow + +**Files:** +- Create: `crates/idfoto-core/tests/integration.rs` + +This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image. + +- [ ] **Step 1: Write the integration test** + +```rust +// crates/idfoto-core/tests/integration.rs +use idfoto_core::*; +use idfoto_core::imgsecret; + +fn make_test_jpeg(width: u32, height: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ImageBuffer, ImageEncoder, Rgb}; + + let img = ImageBuffer::from_fn(width, height, |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 buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut buf, 92); + encoder + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + buf +} + +#[test] +fn full_vault_workflow() { + // 1. Generate an image_secret and embed it + let carrier = make_test_jpeg(400, 300); + let mut image_secret = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret); + + let reference_jpeg = imgsecret::embed(&carrier, &image_secret).unwrap(); + + // 2. Extract the secret back + let extracted_secret = imgsecret::extract(&reference_jpeg).unwrap(); + assert_eq!(extracted_secret, image_secret); + + // 3. Derive master key + let passphrase = b"correct horse battery staple"; + let mut salt = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); + let params = KdfParams { + argon2_m: 256, + argon2_t: 1, + argon2_p: 1, + }; + let master_key = derive_master_key(passphrase, &extracted_secret, &salt, ¶ms).unwrap(); + + // 4. Create and encrypt an entry + let entry = Entry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + password: "supersecret123!".into(), + notes: None, + totp_secret: None, + created_at: "2026-04-11T00:00:00Z".into(), + updated_at: "2026-04-11T00:00:00Z".into(), + }; + + let encrypted = encrypt_entry(&master_key, &entry).unwrap(); + + // 5. Decrypt and verify + let decrypted = decrypt_entry(&master_key, &encrypted).unwrap(); + assert_eq!(decrypted.name, "GitHub"); + assert_eq!(decrypted.password, "supersecret123!"); + assert_eq!(decrypted.username.as_deref(), Some("alee")); + + // 6. Wrong passphrase must fail + let wrong_key = derive_master_key(b"wrong", &extracted_secret, &salt, ¶ms).unwrap(); + assert!(decrypt_entry(&wrong_key, &encrypted).is_err()); + + // 7. Wrong image_secret must fail + let wrong_secret = [0xFFu8; 32]; + let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap(); + assert!(decrypt_entry(&wrong_key2, &encrypted).is_err()); + + // 8. Manifest round-trip + let mut manifest = Manifest::new(); + manifest.add_entry( + "abc123".into(), + ManifestEntry { + name: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alee".into()), + updated_at: "2026-04-11T00:00:00Z".into(), + }, + ); + + let enc_manifest = encrypt_manifest(&master_key, &manifest).unwrap(); + let dec_manifest = decrypt_manifest(&master_key, &enc_manifest).unwrap(); + assert_eq!(dec_manifest.entries.len(), 1); + assert!(dec_manifest.entries.contains_key("abc123")); +} + +#[test] +fn two_factor_independence() { + // Verifies that changing EITHER factor produces a different key + let carrier = make_test_jpeg(400, 300); + let image_secret = [0xAAu8; 32]; + let salt = [0x01u8; 32]; + let params = KdfParams { + argon2_m: 256, + argon2_t: 1, + argon2_p: 1, + }; + + let key_original = derive_master_key(b"passphrase", &image_secret, &salt, ¶ms).unwrap(); + + // Different passphrase, same image → different key + let key_diff_pass = derive_master_key(b"other", &image_secret, &salt, ¶ms).unwrap(); + assert_ne!(key_original, key_diff_pass); + + // Same passphrase, different image → different key + let key_diff_img = derive_master_key(b"passphrase", &[0xBBu8; 32], &salt, ¶ms).unwrap(); + assert_ne!(key_original, key_diff_img); + + // Both different → different key + let key_both = derive_master_key(b"other", &[0xBBu8; 32], &salt, ¶ms).unwrap(); + assert_ne!(key_original, key_both); + assert_ne!(key_diff_pass, key_both); + assert_ne!(key_diff_img, key_both); +} +``` + +- [ ] **Step 2: Run integration tests** + +Run: `cargo test -p idfoto-core --test integration` +Expected: Both tests PASS. + +- [ ] **Step 3: Run the full test suite** + +Run: `cargo test` +Expected: ALL tests across all crates PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/tests/ +git commit -m "test: add full-workflow integration test and two-factor independence verification" +``` + +--- + +## Plan 2 Preview + +After this plan is complete and passing, Plan 2 covers: +- **idfoto-wasm**: wasm-bindgen wrapper around idfoto-core (compile with `wasm-pack build`) +- **Chrome MV3 extension**: TypeScript popup + content script + service worker, loading the WASM module for inline crypto +- **Extension UX**: passphrase prompt, entry list/search, autofill detection + +Plan 2 will be written as a separate plan document once Plan 1 is fully working. diff --git a/docs/superpowers/specs/2026-04-11-idfoto-design.md b/docs/superpowers/specs/2026-04-11-idfoto-design.md new file mode 100644 index 0000000..5b3bd0d --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-idfoto-design.md @@ -0,0 +1,369 @@ +# idfoto — Design Specification + +A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator. + +## Overview + +idfoto is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault. + +Primary goals: portfolio project for adlee.work, architectural elegance, legibility-as-security (the README should read as the security proof), learning Rust, and fun to tinker with. + +## Threat Model + +### What we protect + +A collection of credentials (usernames, passwords, URLs, TOTP seeds, notes) belonging to a single user or family. + +### Adversaries + +| Adversary | Access | Goal | Defense | +|---|---|---|---| +| Gitea server compromise | Full repo contents | Decrypt vault entries | Server-zero-knowledge: only ciphertext stored. KDF inputs never touch server. | +| Network observer | Git traffic | Read vault in transit | Git over HTTPS/SSH. Vault double-encrypted (TLS + AEAD). | +| Stolen device | Filesystem: reference image, device key, cached vault | Decrypt vault | Attacker has image_secret but not passphrase. Argon2id makes brute-force expensive. | +| Stolen device + weak passphrase | Same + feasible brute-force | Decrypt vault | Enforce minimum passphrase strength at vault creation. Universal worst case. | +| Shoulder surfer | Observed passphrase | Decrypt vault (if they also get image) | Passphrase alone insufficient — still need image_secret. | +| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | idfoto generates unique passwords per site. Breach of site A doesn't compromise site B. | + +### Out of scope + +- Compromised device with active malware (keylogger + screen capture). No software password manager survives this. +- Rubber-hose cryptanalysis. +- Quantum computing (XChaCha20 is symmetric/believed quantum-resistant, not a design goal). + +### Security invariants + +1. **Server-zero-knowledge.** The server never receives, stores, or can derive the passphrase, image_secret, or any vault key. +2. **Two-factor vault key.** Decryption requires both passphrase AND reference image. Compromise of either alone is insufficient. +3. **Forward secrecy on rotation.** When passphrase or reference image is rotated, old vault snapshots in git history remain encrypted with the old key. +4. **Device revocation without KDF rotation.** Removing a device ed25519 key from the manifest prevents commits. Does not require changing passphrase or reference image. + +## Crypto Pipeline + +### Key derivation + +``` +passphrase (user types, UTF-8 encoded) + + image_secret (256 bits, extracted from reference JPEG) + + vault_salt (32 bytes, stored plaintext in repo) + │ + ▼ +Argon2id( + password = passphrase_bytes || image_secret_bytes, // concatenated, 32-byte secret appended + salt = vault_salt, // 32 bytes, from .idfoto/salt + memory = 64 MiB, + iterations = 3, + parallelism = 4, + output = 32 bytes +) + │ + ▼ +master_key (32 bytes, held in memory only, never persisted) +``` + +The Argon2id `password` parameter receives the concatenation of the passphrase (UTF-8 bytes) and the image_secret (32 raw bytes). The `salt` parameter receives the vault_salt. This is the canonical Argon2id API — no custom construction. + +Single master_key encrypts all vault entries and the manifest. No per-entry subkey derivation — unnecessary for the expected vault size (family use, thousands of entries at most). + +### Entropy analysis + +| Scenario | Attacker has | Must crack | Effective entropy | +|---|---|---|---| +| Server breach only | Encrypted vault, salt, params | Passphrase + image_secret | passphrase_bits + 256 (infeasible) | +| Server breach + stolen image | Vault + image_secret | Passphrase only | passphrase_bits through Argon2id | +| Passphrase shoulder-surfed + server breach | Passphrase + vault | image_secret | 256 bits (infeasible) | +| Stolen device | Image + device key + vault | Passphrase only | passphrase_bits through Argon2id | + +With a 4-word diceware passphrase (~51 bits) and Argon2id at 64 MiB, brute-force of the passphrase alone takes ~7 million years on specialized hardware. + +Compared to competitors: +- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only) +- 1Password: server breach exposes password + 128-bit Secret Key +- idfoto: server breach exposes password + 256-bit image_secret + +### Authenticated encryption + +**XChaCha20-Poly1305** (192-bit nonce, 256-bit key, 128-bit auth tag). + +Chosen over AES-256-GCM because: +- 192-bit nonce eliminates birthday-bound collision concerns (96-bit AES-GCM nonce has a ~2^48 bound; XChaCha20's 192-bit nonce has ~2^96) +- Fast in software — critical for WASM (browser extension) and ARM (future mobile) where AES-NI hardware acceleration is unavailable +- Used by age, libsodium, WireGuard — well-studied, modern standard + +Per-file binary format: + +``` +┌─────────┬───────┬────────────┬─────┐ +│ version │ nonce │ ciphertext │ tag │ +│ 1 byte │ 24 B │ variable │ 16B │ +└─────────┴───────┴────────────┴─────┘ +``` + +Nonce is generated fresh (CSPRNG) on every write. Version byte allows future format changes. + +### KDF parameters + +Stored in `.idfoto/params.json` (plaintext, committed). Configurable per-vault: +- Default: `argon2_m=65536` (64 MiB), `argon2_t=3`, `argon2_p=4` +- Users can increase for CLI-only use on powerful hardware +- Enables future parameter upgrades without format changes + +## imgsecret Module + +The DCT-based secret embedding primitive. This is the technically novel component. + +### Public API + +```rust +/// Embed a 256-bit secret into a carrier JPEG. +/// Returns the modified JPEG bytes. +fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> + +/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. +fn extract(jpeg: &[u8]) -> Result<[u8; 32]> +``` + +No passphrase, no KDF, no vault awareness. Pure bytes-in, bytes-out. + +### Embedding process + +1. **Decode JPEG to pixels.** Convert to YCbCr, extract Y (luminance) channel only. Luminance has the most DCT energy and survives re-encoding best. Cb/Cr are often subsampled 4:2:0 by social media. + +2. **Define embedding region.** Central 70% of image (15% margin on all sides). For a 4000x3000 photo: inner 2800x2100 pixels. The margin is a "crumple zone" — 15% crop from any edge doesn't touch embedded data. + +3. **Block-DCT.** Divide the embedding region into 8x8 blocks, apply 2D DCT to each. + +4. **Select embedding blocks.** Fixed geometric pattern — evenly spaced blocks across the central region. The pattern is public (part of the spec). Security comes from the extracted secret being meaningless without the passphrase, not from hiding which blocks carry data. + +5. **Encode with heavy redundancy.** + - 32 bytes secret → Reed-Solomon RS(255, 223) → 64 bytes (can correct 16 byte errors per block) + - Repeat entire RS-encoded payload N times across different block groups (20+ copies for a typical phone photo) + - Extraction uses majority voting across copies + RS correction within each copy — two layers of error correction + +6. **Embed via QIM (Quantization Index Modulation).** Round each selected mid-frequency DCT coefficient (positions 4-15 in zig-zag order) to a quantization grid and nudge to encode 0 or 1. Mid-frequency: low enough to survive re-quantization, high enough to be visually invisible. + +7. **Reconstruct and save.** Inverse DCT, reconstruct image, save as JPEG at quality 90-92. + +### Extraction process (including crop recovery) + +1. Decode JPEG, extract Y channel, block-DCT (computed once for the whole image). + +2. **Try canonical alignment (no crop).** Read fixed geometric pattern from central region, extract bits, RS-decode each redundant copy, majority-vote. If RS succeeds → done. + +3. **If canonical fails, try crop offsets.** Iterate `(dx, dy)` from -15% to +15% of image dimensions, stepping by 8 pixels. For each offset, recompute block positions, attempt extraction + RS decode. First successful RS decode + majority vote = correct alignment. + +4. **Performance.** Worst case ~16,800 offset candidates for a 4000x3000 image. Each candidate reads ~20 blocks (microseconds since DCT is pre-computed). Total worst case: ~50-100ms. Average case (no crop): first try succeeds. + +### EXIF handling + +Caller must normalize EXIF orientation before passing JPEG to embed/extract. EXIF rotation changes the pixel grid, which breaks block alignment. + +### Constraints + +- Carrier must be a JPEG image (the format social media uses) +- Minimum image size: to be determined empirically during implementation, documented in spec +- The module does NOT encrypt anything — it embeds plaintext bits. The secret is random, so it's indistinguishable from noise. + +### Test battery + +- Round-trip: embed → extract, no transformation (must always pass) +- JPEG recompression: embed at Q92, recompress at Q75, Q60, Q50 — find the threshold +- Social media simulation: embed → resize to 1080px wide → recompress at Q80 → extract +- Crop: 5%, 10%, 15% from each edge independently +- Combined: crop 10% + recompress at Q75 +- Negative: extract from an un-embedded JPEG (must fail cleanly, not return garbage) +- Minimum image size: find smallest viable carrier + +## Vault Format & Repo Layout + +``` +idfoto-vault/ +├── manifest.enc # encrypted JSON: entry index, vault metadata +├── entries/ +│ ├── a1b2c3d4.enc # one encrypted entry per file, random hex ID +│ ├── e5f6a7b8.enc +│ └── ... +└── .idfoto/ + ├── salt # 32 bytes, plaintext (prevents precomputation) + ├── params.json # Argon2id parameters, plaintext + └── devices.json # authorized device ed25519 public keys, plaintext +``` + +### manifest.enc + +Encrypted JSON containing the entry index: + +```json +{ + "entries": { + "a1b2c3d4": {"name": "GitHub", "url": "https://github.com/login", "username": "alee", "updated_at": "2026-04-11T22:30:00Z"}, + "e5f6a7b8": {"name": "Netflix", "url": "https://netflix.com", "username": "family@email.com", "updated_at": "2026-04-10T10:00:00Z"} + }, + "version": 1 +} +``` + +Decrypting only the manifest is sufficient for listing/searching entries without decrypting every entry file. + +### Entry files (entries/.enc) + +Each encrypted entry contains: + +```json +{ + "name": "GitHub", + "url": "https://github.com/login", + "username": "alee", + "password": "generated-random-password", + "notes": "2FA enabled, backup codes in safe", + "totp_secret": "JBSWY3DPEHPK3PXP", + "created_at": "2026-04-11T22:30:00Z", + "updated_at": "2026-04-11T22:30:00Z" +} +``` + +Flat schema. No nested objects, no folders, no tags for V1. Entry IDs are random 8-character hex strings (32 bits — sufficient for family vault sizes). + +### Plaintext metadata + +Stored in `.idfoto/` and committed to the repo: +- `salt`: 32 random bytes, generated once at vault creation +- `params.json`: Argon2id tuning knobs (memory, iterations, parallelism, format version) +- `devices.json`: list of authorized device ed25519 public keys, used to verify commit signatures + +### Git history + +Preserved as-is. Every add/edit/rm is a commit. Provides "when was this password last rotated" for free. No history rewriting or squashing. + +### What never leaves the client + +- Passphrase +- Reference image / image_secret +- master_key (derived in memory, never persisted to disk) + +## Crate Layout + +``` +idfoto/ +├── Cargo.toml # workspace root +├── crates/ +│ ├── idfoto-core/ # library: imgsecret, KDF, vault format +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── imgsecret.rs +│ │ ├── kdf.rs +│ │ ├── vault.rs +│ │ └── entry.rs +│ ├── idfoto-cli/ # binary: the `idfoto` CLI +│ │ └── src/ +│ │ └── main.rs +│ └── idfoto-wasm/ # wasm-bindgen wrapper around core +│ └── src/ +│ └── lib.rs +├── extension/ # TypeScript Chrome MV3 extension +│ ├── src/ +│ ├── manifest.json +│ └── package.json +├── docs/ +│ └── spec/ # vault format spec + test vectors +└── README.md +``` + +### Design principles + +- **`idfoto-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge). +- **`idfoto-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O. +- **`idfoto-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript. +- **`extension/`** is TypeScript/MV3. Loads the WASM module, runs crypto inline (no native messaging bridge). + +### Rust crate dependencies (expected) + +**idfoto-core:** +- `argon2` — Argon2id KDF +- `chacha20poly1305` — XChaCha20-Poly1305 AEAD +- `sha2` — SHA-256 for hashing +- `rand` — CSPRNG for nonces, salts, entry IDs, image_secret generation +- `image` — JPEG decode/encode +- `rustdct` — DCT computation (or inline 8x8 implementation) +- `reed-solomon-erasure` — RS error correction for imgsecret +- `serde`, `serde_json` — entry/manifest serialization +- `ed25519-dalek` — device key signing (used by CLI, exposed via core) +- `thiserror` — error types + +**idfoto-cli:** +- `clap` (derive) — argument parsing +- `anyhow` — CLI error handling +- `rpassword` — passphrase prompt without echo +- `arboard` or `cli-clipboard` — clipboard access +- `dirs` — platform config/data directories + +**idfoto-wasm:** +- `wasm-bindgen` — JS interop +- `js-sys`, `web-sys` — browser APIs + +## CLI Commands + +``` +idfoto init # Create vault: generate salt, prompt for passphrase, + # prompt for carrier image, embed image_secret, + # output reference JPEG, git init + first commit + +idfoto add # Prompt for entry fields, encrypt, commit +idfoto get # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL) +idfoto list # Decrypt manifest, print entry names/URLs +idfoto edit # Decrypt entry, prompt for changes, re-encrypt, commit +idfoto rm # Remove entry file, update manifest, commit +idfoto sync # git pull --rebase && git push +idfoto generate # Generate a random password (utility, no vault interaction) + +idfoto device add # Generate ed25519 keypair, add pubkey to devices.json, commit +idfoto device list # List authorized devices +idfoto device revoke # Remove device from devices.json, commit +``` + +Unlock flow: on any command that needs the vault, the CLI prompts for the passphrase and the reference image path (or uses a configured default path). Derives master_key, holds it in memory for the duration of the command, then drops it. No persistent daemon for V1 — each invocation re-derives. + +Future: `idfoto unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt. + +## Chrome Extension Architecture + +The Chrome MV3 extension loads `idfoto-wasm` directly — no native messaging bridge. + +- **Service worker:** initializes the WASM module, holds the master_key in memory after unlock, handles vault operations +- **Popup:** passphrase prompt, entry list/search, entry detail view +- **Content script:** detects login forms, communicates with service worker for autofill + +The master_key lives in the service worker's memory. Chrome may terminate idle service workers (MV3 behavior), which would require re-unlock. This is acceptable — it's a natural session timeout. + +Reference image: stored in extension local storage (chrome.storage.local) after first setup. The image bytes never leave the device. + +Extension design details (popup UI, content script heuristics, autofill flow) are deferred to implementation planning. + +## Recovery (Post-V1) + +Not in V1 scope. Planned approach: + +- `idfoto export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation) +- User stores this file offline (USB drive, printed QR, safe deposit box) +- Recovery: `idfoto recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git +- This is a second backup path alongside the "dead drop" reference JPEG (which can live on social media, personal website, etc.) + +## Post-V1 Ideas + +- **Secure notes:** free-form encrypted text entries (no URL/username/password schema, just a title + body). Same encryption, same repo layout — just a different entry type field. +- **Secure document storage:** encrypted file attachments up to 5-10 MB per entry. Stored as separate `.enc` blobs in an `attachments/` directory, referenced by entry ID. Git handles large binary blobs tolerably at this scale; git-lfs is an option if vaults grow beyond ~100 MB total. +- **`idfoto unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase. +- **Mobile clients (Android/iOS):** Rust core compiles to ARM. Thin native wrappers (Kotlin/Swift) deferred. +- **Import from LastPass/Bitwarden/1Password** +- **Firefox/Safari extensions** +- **TOTP code generation in the extension** (codes stored in V1, auto-generation deferred) +- **Password strength auditing / breach checking** +- **Shared vaults / multi-user access control** (V1: single vault, shared passphrase + image for family) + +## Non-Goals for V1 + +- Mobile clients +- Multi-user access control +- Browser extensions beyond Chrome +- Import/export from other password managers (except the recovery export) +- Password strength auditing / breach checking