Watch
1
0
Fork
You've already forked souveraine
0

secrets: at-rest encryption, passphrase wrap, phone packaging

Seal items AES-256-GCM under a random store key (item id as AAD).
Machine wrap: HKDF over a deterministic machined signature (domain
secrets-store-key, socket /run/souveraine/machined.sock; legacy
seed-id fallback, same framing) — private key never enters the daemon.
Argon2id passphrase wrap slot (64MiB/3, HKDF-mixed with the machine
signature) managed via org.souveraine.Secrets.Manage: SetPassphrase /
VerifyPassphrase / HasPassphrase; rotation re-wraps only.

Client-found spec fixes: DH prime was the 2048-bit group-14 constant,
not the 1024-bit Second Oakley group libsecret uses (pinned by test);
item ids now simple UUIDs (hyphens illegal in object paths); collection
also served at /aliases/default; interfaces registered before name
claim. Added spec signals, real timestamps, content types, atomic 0600
store writes.

packaging: systemd user unit, D-Bus activation shadow, idempotent
phone deploy script (masks gnome-keyring, verifies round-trip).
This commit is contained in:
Fimeg 2026-07-20 21:16:32 -04:00
commit d824ca7f81
13 changed files with 1088 additions and 478 deletions

429
Cargo.lock generated
View file

@ -378,6 +378,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@ -613,7 +625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib 0.18.5",
"glib",
"libc",
]
@ -623,10 +635,10 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -653,12 +665,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_refcell"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4"
[[package]]
name = "atty"
version = "0.2.14"
@ -1004,6 +1010,15 @@ dependencies = [
"wyz",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "blake3"
version = "1.8.5"
@ -1201,7 +1216,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.1",
"cairo-sys-rs",
"glib 0.18.5",
"glib",
"libc",
"once_cell",
"thiserror 1.0.69",
@ -1213,9 +1228,9 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys 0.18.1",
"glib-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -1355,17 +1370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon 0.12.16",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon 0.13.5",
"target-lexicon",
]
[[package]]
@ -3371,7 +3376,7 @@ dependencies = [
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib 0.18.5",
"glib",
"libc",
"pango",
]
@ -3384,7 +3389,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib 0.18.5",
"glib",
"libc",
"once_cell",
]
@ -3395,11 +3400,11 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -3410,13 +3415,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -3426,11 +3431,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
dependencies = [
"gdk-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"glib-sys",
"gobject-sys",
"libc",
"pkg-config",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -3442,7 +3447,7 @@ dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib 0.18.5",
"glib",
"libc",
"x11",
]
@ -3454,9 +3459,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
dependencies = [
"gdk-sys",
"glib-sys 0.18.1",
"glib-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
"x11",
]
@ -3596,8 +3601,8 @@ dependencies = [
"futures-core",
"futures-io",
"futures-util",
"gio-sys 0.18.1",
"glib 0.18.5",
"gio-sys",
"glib",
"libc",
"once_cell",
"pin-project-lite",
@ -3611,26 +3616,13 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
"winapi",
]
[[package]]
name = "gio-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
dependencies = [
"glib-sys 0.22.8",
"gobject-sys 0.22.6",
"libc",
"system-deps 7.0.8",
"windows-sys 0.61.2",
]
[[package]]
name = "git2"
version = "0.19.0"
@ -3667,10 +3659,10 @@ dependencies = [
"futures-executor",
"futures-task",
"futures-util",
"gio-sys 0.18.1",
"glib-macros 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"once_cell",
@ -3678,27 +3670,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "glib"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
dependencies = [
"bitflags 2.11.1",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys 0.22.8",
"glib-macros 0.22.6",
"glib-sys 0.22.8",
"gobject-sys 0.22.6",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
@ -3713,18 +3684,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
@ -3732,17 +3691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps 6.2.2",
]
[[package]]
name = "glib-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [
"libc",
"system-deps 7.0.8",
"system-deps",
]
[[package]]
@ -3790,20 +3739,9 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys 0.18.1",
"glib-sys",
"libc",
"system-deps 6.2.2",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys 0.22.8",
"libc",
"system-deps 7.0.8",
"system-deps",
]
[[package]]
@ -3869,99 +3807,6 @@ dependencies = [
"xxhash-rust",
]
[[package]]
name = "gstreamer"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab4527e1b9bae8d29ce137bde5b8eec8ae8f78f13ad00fc6e70cbe227d6ad027"
dependencies = [
"cfg-if",
"futures-channel",
"futures-core",
"futures-util",
"glib 0.22.8",
"gstreamer-sys",
"itertools 0.15.0",
"kstring",
"libc",
"muldiv",
"num-integer",
"num-rational",
"option-operations",
"pastey 0.2.2",
"pin-project-lite",
"smallvec",
"thiserror 2.0.18",
]
[[package]]
name = "gstreamer-app"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97f8ae9238c2352398dcc084de28df3f7099af216ac6c160b52318d23f25c010"
dependencies = [
"futures-core",
"futures-sink",
"glib 0.22.8",
"gstreamer",
"gstreamer-app-sys",
"gstreamer-base",
"libc",
]
[[package]]
name = "gstreamer-app-sys"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a74a8211e5d7df2f45b612c284ddf56b92bdf4e879e8ed72e7c46dd0842e158"
dependencies = [
"glib-sys 0.22.8",
"gstreamer-base-sys",
"gstreamer-sys",
"libc",
"system-deps 7.0.8",
]
[[package]]
name = "gstreamer-base"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91c94a4d3047d05dd6e1f6d91c74f61f56384c7ea1c9d0c1051572eeeb0138d"
dependencies = [
"atomic_refcell",
"cfg-if",
"glib 0.22.8",
"gstreamer",
"gstreamer-base-sys",
"libc",
]
[[package]]
name = "gstreamer-base-sys"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fbbc623dc066908ba10c43d629c21096508dea04796592a206c4edd864e37"
dependencies = [
"glib-sys 0.22.8",
"gobject-sys 0.22.6",
"gstreamer-sys",
"libc",
"system-deps 7.0.8",
]
[[package]]
name = "gstreamer-sys"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533fa8d28fc830eafccbcfcfddb390563ea5d3a351af2c3aab99e197e5f5b1ba"
dependencies = [
"cfg-if",
"glib-sys 0.22.8",
"gobject-sys 0.22.6",
"libc",
"system-deps 7.0.8",
]
[[package]]
name = "gtk"
version = "0.18.2"
@ -3975,7 +3820,7 @@ dependencies = [
"gdk",
"gdk-pixbuf",
"gio",
"glib 0.18.5",
"glib",
"gtk-sys",
"gtk3-macros",
"libc",
@ -3993,12 +3838,12 @@ dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -4756,15 +4601,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -4778,7 +4614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
dependencies = [
"bitflags 1.3.2",
"glib 0.18.5",
"glib",
"javascriptcore-rs-sys",
]
@ -4788,10 +4624,10 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
dependencies = [
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -5034,15 +4870,6 @@ dependencies = [
"libc",
]
[[package]]
name = "kstring"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e"
dependencies = [
"static_assertions",
]
[[package]]
name = "lab"
version = "0.11.0"
@ -5094,7 +4921,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib 0.18.5",
"glib",
"gtk",
"gtk-sys",
"libappindicator-sys",
@ -5302,32 +5129,6 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "log"
version = "0.4.29"
@ -5785,12 +5586,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "muldiv"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0"
[[package]]
name = "murmurhash32"
version = "0.3.1"
@ -6597,15 +6392,6 @@ dependencies = [
"cc",
]
[[package]]
name = "ogg_pager"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -6701,15 +6487,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "option-operations"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca39cf52b03268400c16eeb9b56382ea3c3353409309b63f5c8f0b1faf42754"
dependencies = [
"pastey 0.2.2",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@ -6834,7 +6611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib 0.18.5",
"glib",
"libc",
"once_cell",
"pango-sys",
@ -6846,10 +6623,10 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -6881,6 +6658,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -9284,7 +9072,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib 0.18.5",
"glib",
"libc",
"soup3-sys",
]
@ -9295,11 +9083,11 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
dependencies = [
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps 6.2.2",
"system-deps",
]
[[package]]
@ -9307,8 +9095,10 @@ name = "souveraine"
version = "1.0.0"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"arboard",
"argon2",
"async-trait",
"axis2d",
"axum",
@ -9331,10 +9121,7 @@ dependencies = [
"figlet-rs",
"futures",
"git2",
"glib 0.22.8",
"glob",
"gstreamer",
"gstreamer-app",
"hex",
"hkdf",
"hound",
@ -9342,7 +9129,6 @@ dependencies = [
"keyring",
"keyring-core",
"libc",
"lofty",
"matrix-sdk",
"notify",
"num-bigint",
@ -9918,26 +9704,13 @@ version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr 0.15.8",
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr 0.20.8",
"heck 0.5.0",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tantivy"
version = "0.26.1"
@ -10149,12 +9922,6 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.11.1"
@ -12143,10 +11910,10 @@ dependencies = [
"gdk",
"gdk-sys",
"gio",
"gio-sys 0.18.1",
"glib 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib",
"glib-sys",
"gobject-sys",
"gtk",
"gtk-sys",
"javascriptcore-rs",
@ -12165,15 +11932,15 @@ dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
"gdk-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk-sys",
"javascriptcore-rs-sys",
"libc",
"pkg-config",
"soup3-sys",
"system-deps 6.2.2",
"system-deps",
]
[[package]]

View file

@ -156,6 +156,12 @@ cipher = { version = "0.4", optional = true }
hkdf = { version = "0.12", optional = true }
num-bigint = { version = "0.4", optional = true }
num-traits = { version = "0.2", optional = true }
# At-rest sealing per STORAGE-ENCRYPTION.md: AES-256-GCM for the item store
# and key wraps (the AEAD the tree previously lacked), Argon2id for the
# passphrase KEK — the slot that puts the user's real lockscreen passphrase
# into the at-rest hierarchy.
aes-gcm = { version = "0.10", optional = true }
argon2 = { version = "0.5", optional = true }
# Session authority daemon (souveraine-sessiond) — ext-session-lock client.
# Pure-Rust wayland stack; PAM is hand-rolled FFI against libpam (no new dep,
@ -174,6 +180,7 @@ wayland-protocols = { version = "0.32", optional = true, features = ["client", "
[dev-dependencies]
tokio-test = "0.4"
colored = "2"
tempfile = "3"
[[example]]
name = "demo"
@ -218,6 +225,6 @@ matrix = ["dep:matrix-sdk"]
# color-scheme / font-size controls in theme.rs and global_chords.rs.
gui = ["tuie/gui"]
# Secret Service daemon (org.freedesktop.secrets), backed by the existing SeedId.
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits"]
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits", "dep:aes-gcm", "dep:argon2"]
# Session authority daemon — holds ext-session-lock before/under the shell.
sessiond = ["dep:wayland-client", "dep:wayland-protocols"]

108
packaging/deploy-secrets-phone.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# deploy-secrets-phone.sh — make souveraine-secrets the Pixel 3's Secret
# Service, replacing gnome-keyring's secrets component.
#
# Idempotent: every step converges to the same state fresh or re-run. The
# binary is cross-built on archdev (never this laptop) by
# SOUVERAINE_AARCH64_SYSROOT=~/aarch64-sysroot ./scripts/build-cross.sh \
# --features secrets --bin souveraine-secrets
# and pulled from there unless SECRETS_BIN points at a local copy.
#
# What "replace" means here, precisely:
# * our binary in /usr/local/bin, our user unit in /etc/systemd/user,
# enabled at default.target — the name is claimed at login, before any
# client asks;
# * a D-Bus activation file in /usr/local/share/dbus-1/services shadows
# gnome-keyring's copy in /usr/share (XDG_DATA_DIRS order, verified on
# the phone) for on-demand activation;
# * gnome-keyring-daemon.{service,socket} masked, its autostart entries
# hidden per-user — pacman-owned files in /usr/share are never touched,
# so a gnome-keyring package upgrade cannot resurrect it.
#
# Verified at the end with a live secret-tool round-trip against the running
# provider; the script fails loudly if the name is owned by anything else.
set -euo pipefail
cd "$(dirname "$0")/.."
SSH=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o BatchMode=yes -o ConnectTimeout=5)
SCP=(scp -F /dev/null -i "$HOME/.ssh/ani" -o BatchMode=yes)
USB_IP=172.16.42.1
WIFI_IP=10.10.20.154
ARCHDEV="${ARCHDEV:-casey@10.10.20.123}"
ARCHDEV_BIN="Projects/souveraine/target/aarch64-unknown-linux-gnu/release/souveraine-secrets"
LOCAL_CACHE="target/phone/souveraine-secrets"
# --- binary: pull the archdev cross-build unless one was handed to us ---
BIN="${SECRETS_BIN:-}"
if [[ -z "$BIN" ]]; then
mkdir -p "$(dirname "$LOCAL_CACHE")"
echo "== pulling cross-built binary from $ARCHDEV =="
"${SCP[@]}" "$ARCHDEV:$ARCHDEV_BIN" "$LOCAL_CACHE"
BIN="$LOCAL_CACHE"
fi
file "$BIN" | grep -q aarch64 || { echo "$BIN is not an aarch64 binary" >&2; exit 1; }
# --- reach the phone ---
ip=""
for cand in "$USB_IP" "$WIFI_IP"; do
if ping -c1 -W1 "$cand" >/dev/null 2>&1; then ip="$cand"; break; fi
done
[[ -n "$ip" ]] || { echo "phone unreachable (tried $USB_IP, $WIFI_IP)" >&2; exit 1; }
PHONE="casey@$ip"
echo "== phone: $ip =="
# --- ship binary + units ---
"${SCP[@]}" "$BIN" "$PHONE:/tmp/souveraine-secrets-xdev"
"${SCP[@]}" packaging/souveraine-secrets.service "$PHONE:/tmp/souveraine-secrets.service"
"${SCP[@]}" packaging/org.freedesktop.secrets.service "$PHONE:/tmp/org.freedesktop.secrets.service"
"${SSH[@]}" "$PHONE" 'bash -s' <<'EOF'
set -euo pipefail
sudo install -Dm755 /tmp/souveraine-secrets-xdev /usr/local/bin/souveraine-secrets
sudo install -Dm644 /tmp/souveraine-secrets.service /etc/systemd/user/souveraine-secrets.service
sudo install -Dm644 /tmp/org.freedesktop.secrets.service /usr/local/share/dbus-1/services/org.freedesktop.secrets.service
rm -f /tmp/souveraine-secrets-xdev /tmp/souveraine-secrets.service /tmp/org.freedesktop.secrets.service
# Retire gnome-keyring's secrets component: mask its units, hide its
# autostart entries per-user. /usr/share stays pacman's.
systemctl --user mask --now gnome-keyring-daemon.service gnome-keyring-daemon.socket >/dev/null 2>&1 || true
mkdir -p ~/.config/autostart
for entry in gnome-keyring-secrets gnome-keyring-pkcs11; do
printf '[Desktop Entry]\nType=Application\nName=%s (disabled: souveraine-secrets is the provider)\nHidden=true\n' \
"$entry" > ~/.config/autostart/$entry.desktop
done
# Stop any live gnome-keyring instances (dbus-activated transients included).
systemctl --user stop 'dbus-:*org.freedesktop.secrets*' >/dev/null 2>&1 || true
pkill -u "$(id -u)" -f gnome-keyring-daemon >/dev/null 2>&1 || true
systemctl --user daemon-reload
systemctl --user enable souveraine-secrets.service >/dev/null
systemctl --user restart souveraine-secrets.service
# --- verify: name ownership ---
sleep 1
owner_pid=$(busctl --user status org.freedesktop.secrets 2>/dev/null | awk -F= '/^PID=/{print $2}')
# /proc/<pid>/comm truncates to 15 chars → "souveraine-secr"
owner_comm=$(cat /proc/"$owner_pid"/comm 2>/dev/null || echo unknown)
if [[ "$owner_comm" != souveraine-secr* ]]; then
echo "FAIL: org.freedesktop.secrets is owned by '$owner_comm' (pid $owner_pid), not souveraine-secrets" >&2
systemctl --user status souveraine-secrets.service --no-pager | tail -20 >&2
exit 1
fi
echo "== org.freedesktop.secrets owned by $owner_comm (pid $owner_pid) =="
# --- verify: live round-trip through the standard client ---
printf 'deploy-probe' | secret-tool store --label='souveraine deploy probe' app souveraine-deploy probe roundtrip
got=$(secret-tool lookup app souveraine-deploy probe roundtrip)
if [[ "$got" != "deploy-probe" ]]; then
echo "FAIL: secret-tool round-trip returned '$got'" >&2
exit 1
fi
secret-tool clear app souveraine-deploy probe roundtrip
echo "== secret-tool round-trip OK =="
EOF
echo "== deployed: souveraine-secrets is the phone's Secret Service =="

View file

@ -0,0 +1,4 @@
[D-BUS Service]
Name=org.freedesktop.secrets
Exec=/usr/local/bin/souveraine-secrets
SystemdService=souveraine-secrets.service

View file

@ -0,0 +1,22 @@
# Souveraine Secret Service — the machine's org.freedesktop.secrets provider.
# Claims the well-known name at session start (WantedBy=default.target) so no
# client ever D-Bus-activates a different provider; the paired activation file
# (org.freedesktop.secrets.service → SystemdService=) routes on-demand starts
# here too. Requires souveraine-machined (system tier) or a legacy user seed;
# refuses to start without a machine identity, by design.
[Unit]
Description=Souveraine Secret Service (org.freedesktop.secrets)
Documentation=file:///usr/local/share/doc/souveraine/STORAGE-ENCRYPTION.md
After=dbus.socket
Requires=dbus.socket
[Service]
Type=dbus
BusName=org.freedesktop.secrets
ExecStart=/usr/local/bin/souveraine-secrets
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info
[Install]
WantedBy=default.target

View file

@ -1,17 +1,32 @@
//! Secret Service daemon (`org.freedesktop.secrets`), rooted in the
//! machine's existing Ed25519 `SeedId` instead of a passphrase-unlocked
//! keyring. Backs libsecret clients (Chatty/libcmatrix, and our own
//! machine identity instead of a passphrase-unlocked keyring. Backs any
//! libsecret client (Chatty/libcmatrix, souveraine-player, culver, our own
//! `keyring-core` credential store) without gnome-keyring or KWallet.
//!
//! The seed is a precondition, never something this daemon creates. If no
//! seed exists at `~/.souveraine/seed-id`, startup fails loudly — identity
//! creation is a deliberate, guarded action that lives in `souveraine
//! identity generate`, not something a background service does on your
//! behalf. See `SeedId::load` in `core/identity/seed.rs`.
//! Identity is a precondition, never something this daemon creates. The
//! storage key derives from a deterministic machine signature — system tier
//! (souveraine-machined) first, the legacy `~/.souveraine/seed-id` as a loud
//! transitional fallback. If neither exists, startup fails with provisioning
//! instructions (`sudo souveraine machine init --fresh`); identity creation
//! is a deliberate, guarded action, not something a background service does
//! on your behalf.
//!
//! Lock semantics — a deliberate decision, not an omission: the store
//! unlocks with the *user session*, exactly as long as this daemon runs in
//! it. The screen lock does not lock the store — background clients (music
//! streaming, incoming Matrix messages) must keep their credentials while
//! the display is off, the same stance gnome-keyring takes. Per the session
//! authority doctrine, lock state belongs to the session authority; a
//! second hand-tracked copy here would be a shadow that can disagree with
//! it. `Unlock` therefore always succeeds promptless, and `Lock` refuses
//! rather than pretends.
#[path = "../core/identity/seed.rs"]
mod identity;
#[path = "../machined/protocol.rs"]
mod machined_protocol;
#[path = "../secrets/dh.rs"]
mod dh;
#[path = "../secrets/transport.rs"]
@ -20,6 +35,8 @@ mod transport;
mod session;
#[path = "../secrets/session_object.rs"]
mod session_object;
#[path = "../secrets/storage_key.rs"]
mod storage_key;
#[path = "../secrets/store.rs"]
mod store;
#[path = "../secrets/types.rs"]
@ -32,6 +49,8 @@ mod service;
mod collection;
#[path = "../secrets/item.rs"]
mod item;
#[path = "../secrets/manage.rs"]
mod manage;
use std::sync::{Arc, Mutex};
@ -39,7 +58,6 @@ use anyhow::{Context, Result};
use tracing::info;
use collection::Collection;
use identity::SeedId;
use item::Item;
use service::SecretService;
use session::Sessions;
@ -54,54 +72,61 @@ async fn main() -> Result<()> {
let base = dirs::home_dir()
.context("resolving home directory")?
.join(".souveraine");
let seed_dir = SeedId::default_dir(&base);
let seed = SeedId::load(&seed_dir).context(
"no seed identity — refusing to start. Provision one with `souveraine identity generate` first.",
)?;
info!(pubkey = %seed.public_key_hex(), "loaded existing seed identity, this daemon will not create one");
let (machine_ikm, source) = storage_key::resolve(&base)
.context("resolving the machine-rooted key material — refusing to start")?;
info!(?source, "machine key material resolved; the machine identity is the trust root");
let store_path = store::default_store_path(&base);
let secret_store = Arc::new(Mutex::new(
SecretStore::open(&seed, store_path).context("opening secrets store")?,
SecretStore::open(machine_ikm, store_path).context("opening secrets store")?,
));
let sessions = Arc::new(Sessions::new());
// Interfaces are registered via the builder BEFORE the name is claimed —
// a client whose call races our startup finds the objects already there.
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.secrets")?
.build()
.await
.context("connecting to session bus / claiming org.freedesktop.secrets")?;
connection
.object_server()
.at(
.serve_at(
"/org/freedesktop/secrets",
SecretService {
store: secret_store.clone(),
sessions: sessions.clone(),
},
)
.await?;
connection
.object_server()
.at(
"/org/freedesktop/secrets/collection/souveraine",
)?
.serve_at(
collection::COLLECTION_PATH,
Collection {
store: secret_store.clone(),
sessions: sessions.clone(),
},
)
.await?;
)?
.serve_at(
collection::DEFAULT_ALIAS_PATH,
Collection {
store: secret_store.clone(),
sessions: sessions.clone(),
},
)?
.serve_at(
manage::MANAGE_PATH,
manage::Manage {
store: secret_store.clone(),
},
)?
.name("org.freedesktop.secrets")?
.build()
.await
.context(
"connecting to session bus / claiming org.freedesktop.secrets — is another \
provider (gnome-keyring, ksecretd) still holding the name?",
)?;
let existing_ids = secret_store.lock().unwrap().all_ids();
for id in existing_ids {
let path = format!("/org/freedesktop/secrets/collection/souveraine/{id}");
connection
.object_server()
.at(
path,
collection::item_path(&id),
Item {
id,
store: secret_store.clone(),

View file

@ -3,14 +3,14 @@
//!
//! Real secret-service daemons support multiple named collections
//! (keyrings). We don't: every secret libsecret clients store goes into one
//! collection backed by `SecretStore`, encrypted under the seed-derived key.
//! Adding real multi-collection support is meaningful added complexity for a
//! feature no caller in this codebase (Chatty, keyring-core) needs — this
//! can grow a real implementation later if something actually requires it.
//! collection backed by `SecretStore`, encrypted under the machine-derived
//! key. Adding real multi-collection support is meaningful added complexity
//! for a feature no caller in this codebase (Chatty, keyring-core, the
//! player, culver) needs — this can grow a real implementation later if
//! something actually requires it.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use zbus::interface;
@ -23,7 +23,11 @@ use crate::session::Sessions;
use crate::store::SecretStore;
use crate::types::Secret;
const COLLECTION_PATH: &str = "/org/freedesktop/secrets/collection/souveraine";
pub const COLLECTION_PATH: &str = "/org/freedesktop/secrets/collection/souveraine";
/// The spec's well-known alias object path — libsecret talks to the default
/// collection through this path directly, so the same interface is served
/// there too.
pub const DEFAULT_ALIAS_PATH: &str = "/org/freedesktop/secrets/aliases/default";
const NULL_PATH: &str = "/";
pub struct Collection {
@ -83,12 +87,14 @@ impl Collection {
None
};
let is_new = existing_id.is_none();
let id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string());
// Hyphen-less: the id is a D-Bus object path segment, where `-` is
// an invalid character.
let id = existing_id.unwrap_or_else(|| Uuid::new_v4().simple().to_string());
{
let mut store = self.store.lock().unwrap();
store
.set(id.clone(), label, attributes, &plaintext)
.set(id.clone(), label, attributes, secret.content_type.clone(), &plaintext)
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
}
@ -110,10 +116,34 @@ impl Collection {
}
let item: OwnedObjectPath = path.try_into().unwrap();
if is_new {
let _ = Self::item_created(&emitter, item.clone()).await;
} else {
let _ = Self::item_changed(&emitter, item.clone()).await;
}
let prompt: OwnedObjectPath = NULL_PATH.try_into().unwrap();
Ok((item, prompt))
}
#[zbus(signal)]
pub async fn item_created(
emitter: &SignalEmitter<'_>,
item: OwnedObjectPath,
) -> zbus::Result<()>;
#[zbus(signal)]
pub async fn item_deleted(
emitter: &SignalEmitter<'_>,
item: OwnedObjectPath,
) -> zbus::Result<()>;
#[zbus(signal)]
pub async fn item_changed(
emitter: &SignalEmitter<'_>,
item: OwnedObjectPath,
) -> zbus::Result<()>;
#[zbus(property)]
async fn items(&self) -> Vec<OwnedObjectPath> {
let store = self.store.lock().unwrap();
@ -143,22 +173,15 @@ impl Collection {
#[zbus(property)]
async fn created(&self) -> u64 {
now_unix()
self.store.lock().unwrap().collection_created()
}
#[zbus(property)]
async fn modified(&self) -> u64 {
now_unix()
self.store.lock().unwrap().collection_modified()
}
}
fn item_path(id: &str) -> String {
pub fn item_path(id: &str) -> String {
format!("{COLLECTION_PATH}/{id}")
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}

View file

@ -17,18 +17,19 @@ use num_traits::Num;
use sha2::Sha256;
/// RFC 2409 Second Oakley Group: 1024-bit MODP prime, generator 2.
///
/// This must be the *1024-bit* group (libsecret's `dh_group_1024_prime`,
/// 128 bytes). The daemon previously carried the RFC 3526 2048-bit group-14
/// prime under this name — both sides then computed internally-consistent
/// but mutually-worthless keys, and every negotiated session failed its
/// first decrypt. Verified byte-for-byte against libsecret `egg/egg-dh.c`.
const MODP_1024_PRIME_HEX: &str = concat!(
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD",
"129024E088A67CC74020BBEA63B139B22514A08798E3404",
"DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C",
"245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406",
"B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE",
"45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD",
"24CF5F83655D23DCA3AD961C62F356208552BB9ED529077",
"096966D670C354E4ABC9804F1746C08CA18217C32905E46",
"2E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF",
"06F4C52C9DE2BCBF6955817183995497CEA956AE515D226",
"1898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF",
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1",
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD",
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245",
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED",
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381",
"FFFFFFFFFFFFFFFF",
);
const GENERATOR: u64 = 2;
@ -104,4 +105,19 @@ mod tests {
assert_eq!(alice_key, bob_key);
}
/// Pin the group to the real 1024-bit Second Oakley prime. The regression
/// this guards: the 2048-bit group-14 prime living under this name, which
/// broke every libsecret client's first decrypt.
#[test]
fn prime_is_the_1024_bit_oakley_group() {
let prime = BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16).unwrap();
assert_eq!(prime.bits(), 1024);
let bytes = prime.to_bytes_be();
assert_eq!(bytes.len(), 128);
// libsecret dh_group_1024_prime starts FF×8, C9 0F DA A2 … and ends
// … EC E6 53 81, FF×8.
assert_eq!(&bytes[..10], &[0xFF; 8].iter().chain([0xC9, 0x0F].iter()).copied().collect::<Vec<u8>>()[..]);
assert_eq!(&bytes[116..], &[0xEC, 0xE6, 0x53, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
}
}

View file

@ -3,12 +3,12 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use zbus::interface;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::OwnedObjectPath;
use crate::collection::{item_path, Collection, COLLECTION_PATH};
use crate::error::SecretError;
use crate::session::Sessions;
use crate::store::SecretStore;
@ -20,6 +20,27 @@ pub struct Item {
pub sessions: Arc<Sessions>,
}
/// Emit a Collection signal for this item. Item methods run with the item's
/// own signal emitter; the spec's ItemDeleted/ItemChanged live on the
/// Collection interface, so route through its registered emitter.
async fn emit_on_collection(
emitter: &SignalEmitter<'_>,
item_object_path: &str,
deleted: bool,
) -> zbus::Result<()> {
let collection = emitter
.connection()
.object_server()
.interface::<_, Collection>(COLLECTION_PATH)
.await?;
let path = OwnedObjectPath::try_from(item_object_path)?;
if deleted {
Collection::item_deleted(collection.signal_emitter(), path).await
} else {
Collection::item_changed(collection.signal_emitter(), path).await
}
}
#[interface(name = "org.freedesktop.Secret.Item")]
impl Item {
async fn delete(
@ -32,13 +53,14 @@ impl Item {
.delete(&self.id)
.map_err(|e| SecretError::failed(format!("failed to delete item: {e}")))?;
}
let path = format!("/org/freedesktop/secrets/collection/souveraine/{}", self.id);
let path = item_path(&self.id);
let _: bool = emitter
.connection()
.object_server()
.remove::<Item, _>(path)
.remove::<Item, _>(path.as_str())
.await
.map_err(|e| SecretError::failed(format!("failed to unregister item object: {e}")))?;
let _ = emit_on_collection(&emitter, &path, true).await;
Ok("/".try_into().unwrap())
}
@ -48,6 +70,9 @@ impl Item {
.get(&self.id)
.map_err(|e| SecretError::failed(format!("failed to read item: {e}")))?
.ok_or_else(|| SecretError::NoSuchObject(self.id.clone()))?;
let content_type = store
.content_type(&self.id)
.unwrap_or_else(|| "text/plain".to_string());
let (parameters, value) = self
.sessions
@ -58,23 +83,36 @@ impl Item {
session,
parameters,
value,
content_type: "text/plain".to_string(),
content_type,
})
}
async fn set_secret(&self, secret: Secret) -> Result<(), SecretError> {
async fn set_secret(
&self,
secret: Secret,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> Result<(), SecretError> {
let plaintext = self
.sessions
.unseal(&secret.session, &secret.parameters, &secret.value)
.ok_or_else(|| SecretError::NoSession(format!("no such session: {}", secret.session)))?
.map_err(|e| SecretError::failed(format!("failed to unseal secret: {e}")))?;
let mut store = self.store.lock().unwrap();
let label = store.label(&self.id).unwrap_or_default();
let attributes = store.attributes(&self.id).unwrap_or_default();
store
.set(self.id.clone(), label, attributes, &plaintext)
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
{
let mut store = self.store.lock().unwrap();
let label = store.label(&self.id).unwrap_or_default();
let attributes = store.attributes(&self.id).unwrap_or_default();
store
.set(
self.id.clone(),
label,
attributes,
secret.content_type.clone(),
&plaintext,
)
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
}
let _ = emit_on_collection(&emitter, &item_path(&self.id), false).await;
Ok(())
}
@ -92,8 +130,11 @@ impl Item {
async fn set_attributes(&self, attributes: HashMap<String, String>) -> zbus::Result<()> {
let mut store = self.store.lock().unwrap();
let label = store.label(&self.id).unwrap_or_default();
let content_type = store
.content_type(&self.id)
.unwrap_or_else(|| "text/plain".to_string());
if let Ok(Some(plaintext)) = store.get(&self.id) {
let _ = store.set(self.id.clone(), label, attributes, &plaintext);
let _ = store.set(self.id.clone(), label, attributes, content_type, &plaintext);
}
Ok(())
}
@ -107,26 +148,22 @@ impl Item {
async fn set_label(&self, value: String) -> zbus::Result<()> {
let mut store = self.store.lock().unwrap();
let attributes = store.attributes(&self.id).unwrap_or_default();
let content_type = store
.content_type(&self.id)
.unwrap_or_else(|| "text/plain".to_string());
if let Ok(Some(plaintext)) = store.get(&self.id) {
let _ = store.set(self.id.clone(), value, attributes, &plaintext);
let _ = store.set(self.id.clone(), value, attributes, content_type, &plaintext);
}
Ok(())
}
#[zbus(property)]
async fn created(&self) -> u64 {
now_unix()
self.store.lock().unwrap().created(&self.id).unwrap_or(0)
}
#[zbus(property)]
async fn modified(&self) -> u64 {
now_unix()
self.store.lock().unwrap().modified(&self.id).unwrap_or(0)
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}

58
src/secrets/manage.rs Normal file
View file

@ -0,0 +1,58 @@
//! `org.souveraine.Secrets.Manage` — the non-spec management face at
//! `/org/souveraine/secrets`.
//!
//! This is where the storage-encryption design of record plugs the user's
//! *real* lockscreen passphrase into the at-rest hierarchy: the PAM/stepUp
//! side (sessiond or the lock surface — the ingress choice is deliberately
//! still open in `STORAGE-ENCRYPTION.md`) calls `SetPassphrase` after a
//! successful PAM conversation, enrolling or rotating the passphrase wrap of
//! the store key. Rotation re-wraps only; items are never re-encrypted.
//!
//! Same-user session bus only — like the Secret Service itself, the trust
//! boundary is the UID. The passphrase crosses the bus exactly as secrets do
//! under the `plain` transport; callers that hold a negotiated session
//! should prefer it, but the wrap this enrolls is what protects the *disk*,
//! not the bus.
use std::sync::{Arc, Mutex};
use zbus::interface;
use crate::store::SecretStore;
pub const MANAGE_PATH: &str = "/org/souveraine/secrets";
pub struct Manage {
pub store: Arc<Mutex<SecretStore>>,
}
#[interface(name = "org.souveraine.Secrets.Manage")]
impl Manage {
/// Enroll or rotate the passphrase wrap. The caller is expected to have
/// PAM-verified this passphrase — this daemon wraps, it does not
/// authenticate.
async fn set_passphrase(&self, passphrase: String) -> zbus::fdo::Result<()> {
let mut store = self.store.lock().unwrap();
store
.set_passphrase(&passphrase)
.map_err(|e| zbus::fdo::Error::Failed(format!("enrolling passphrase wrap: {e}")))
}
/// Whether the passphrase wrap is enrolled — the stepUp side uses this
/// to know the store still carries a real-password wrap after a
/// lockscreen password change.
#[zbus(property)]
async fn has_passphrase(&self) -> bool {
self.store.lock().unwrap().has_passphrase()
}
/// Verify a candidate passphrase against the enrolled wrap. Lets the
/// stepUp flow detect drift between the lockscreen credential and the
/// store wrap (e.g. after a PAM password change) and re-enroll.
async fn verify_passphrase(&self, passphrase: String) -> zbus::fdo::Result<bool> {
let store = self.store.lock().unwrap();
store
.verify_passphrase(&passphrase)
.map_err(|e| zbus::fdo::Error::Failed(format!("verifying passphrase wrap: {e}")))
}
}

View file

@ -145,6 +145,9 @@ impl SecretService {
let Ok(Some(plaintext)) = store.get(id) else {
continue;
};
let content_type = store
.content_type(id)
.unwrap_or_else(|| "text/plain".to_string());
let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else {
return Err(SecretError::NoSession(format!("no such session: {session}")));
};
@ -154,7 +157,7 @@ impl SecretService {
session: session.clone(),
parameters,
value,
content_type: "text/plain".to_string(),
content_type,
},
);
}
@ -162,6 +165,26 @@ impl SecretService {
Ok(out)
}
// The spec's collection lifecycle signals. With one fixed collection they
// never fire, but clients that introspect for them must find them.
#[zbus(signal)]
pub async fn collection_created(
emitter: &SignalEmitter<'_>,
collection: OwnedObjectPath,
) -> zbus::Result<()>;
#[zbus(signal)]
pub async fn collection_deleted(
emitter: &SignalEmitter<'_>,
collection: OwnedObjectPath,
) -> zbus::Result<()>;
#[zbus(signal)]
pub async fn collection_changed(
emitter: &SignalEmitter<'_>,
collection: OwnedObjectPath,
) -> zbus::Result<()>;
async fn read_alias(&self, name: String) -> zbus::fdo::Result<OwnedObjectPath> {
if name == "default" {
Ok(COLLECTION_PATH.try_into().unwrap())

141
src/secrets/storage_key.rs Normal file
View file

@ -0,0 +1,141 @@
//! Storage-key resolution — how the daemon derives its AES-256 store key
//! from the *machine* identity without ever holding the machine private key.
//!
//! System tier first: ask souveraine-machined to sign the fixed key-derivation
//! payload over its socket. Ed25519 is deterministic, so the same machine key
//! yields the same signature — and therefore the same storage key — on every
//! boot. The private key never enters this process.
//!
//! Legacy fallback: machines from before the system tier carry their seed at
//! `~/.souveraine/seed-id`. Load it, sign the *identical* framed bytes
//! (`machined_protocol::signing_bytes`), and drop the key immediately after
//! derivation. Loud at resolve time, byte-identical output — a later
//! `souveraine machine init --migrate-from` keeps the store decryptable.
//!
//! Neither path generates identity. A machine with no seed is an error with
//! provisioning instructions, not a fresh key.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::identity::SeedId;
use crate::machined_protocol as protocol;
/// Domain under which machined signs the key-derivation payload. Domain
/// separation means this signature can never be replayed as a federation
/// envelope or any other machined-signed artifact.
const KEY_DOMAIN: &str = "secrets-store-key";
/// Versioned payload — bump only with a deliberate store migration.
const KEY_PAYLOAD: &[u8] = b"v1";
/// Which tier answered — callers report this so an audit can tell whether the
/// box has moved to the system tier yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
Machined,
LegacyUserSeed,
}
/// Resolve the machine key material — the deterministic Ed25519 signature
/// over the fixed derivation payload. The store HKDF-expands this into its
/// KEKs; the machine private key itself never reaches the caller. Machined
/// socket first, legacy user-tier seed as a loud transitional fallback.
pub fn resolve(base: &Path) -> Result<(Vec<u8>, KeySource)> {
match machined_sign(KEY_DOMAIN, KEY_PAYLOAD) {
Ok(signature) => {
info!("storage key rooted in the system-tier machine identity (souveraine-machined)");
return Ok((signature, KeySource::Machined));
}
Err(e) => {
warn!(
"souveraine-machined unavailable ({e:#}); falling back to the legacy \
user-tier machine seed provision the system tier with \
`sudo souveraine machine init`"
);
}
}
let legacy_dir = SeedId::default_dir(base);
let seed = SeedId::load(&legacy_dir).with_context(|| {
format!(
"no machine identity: souveraine-machined is not running and no legacy seed \
exists at {} provision one with `sudo souveraine machine init --fresh` \
(or `--migrate-from <dir>` to carry an existing identity over)",
legacy_dir.display()
)
})?;
let framed = protocol::signing_bytes(KEY_DOMAIN, KEY_PAYLOAD);
let signature = seed.sign(&framed).to_bytes().to_vec();
Ok((signature, KeySource::LegacyUserSeed))
}
/// One `Sign` round-trip against the machined socket. Mirrors
/// `machined::client` (which the bin cannot reuse — its module paths assume
/// the main crate root); the wire format is the shared `machined_protocol`.
fn machined_sign(domain: &str, payload: &[u8]) -> Result<Vec<u8>> {
let path = std::env::var_os("SOUVERAINE_MACHINED_SOCKET")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(protocol::DEFAULT_SOCKET_PATH));
let stream = UnixStream::connect(&path)
.with_context(|| format!("connecting to souveraine-machined at {}", path.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let request = protocol::Request::Sign {
domain: domain.to_string(),
payload_hex: hex::encode(payload),
};
let mut writer = stream.try_clone()?;
writer.write_all(serde_json::to_string(&request)?.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.context("reading souveraine-machined response")?;
let value: serde_json::Value =
serde_json::from_str(line.trim()).context("parsing souveraine-machined response")?;
if value.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let reason = value
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unspecified refusal");
anyhow::bail!("souveraine-machined refused: {reason}");
}
let signature_hex = value
.get("signature")
.and_then(|v| v.as_str())
.context("response missing signature")?;
hex::decode(signature_hex).context("decoding signature hex")
}
#[cfg(test)]
mod tests {
use super::*;
/// The legacy path must frame exactly like machined does, or the same
/// machine key would produce two different key materials across the tier
/// migration and silently orphan every stored secret.
#[test]
fn legacy_derivation_uses_machined_framing() {
let seed = SeedId::generate();
let framed = protocol::signing_bytes(KEY_DOMAIN, KEY_PAYLOAD);
assert_eq!(
framed,
b"souveraine-machined:v1:secrets-store-key:v1".to_vec()
);
// Ed25519 is deterministic — same seed, same payload, same material.
assert_eq!(
seed.sign(&framed).to_bytes(),
seed.sign(&framed).to_bytes()
);
}
}

View file

@ -1,113 +1,296 @@
//! On-disk encrypted item storage, keyed from the machine's existing `SeedId`.
//! On-disk encrypted item storage — the Secret Service face of the storage
//! encryption design of record (`SouveraineOS/docs/STORAGE-ENCRYPTION.md`).
//!
//! The seed never leaves the process and is never used directly as the
//! storage key — instead we sign a fixed domain-separation string and run
//! the (deterministic) Ed25519 signature through HKDF-SHA256 to derive a
//! dedicated AES-256 key. This keeps "sign federation events" and "encrypt
//! local secrets" as separate derived keys from one root, rather than
//! reusing the same key material for two purposes.
//! Key hierarchy, per that doc:
//!
//! ```text
//! random 32-byte store key ── AES-256-GCM seals every item (id as AAD)
//! ├─ wrapped by the machine KEK (HKDF of a deterministic machined
//! │ signature — boot-available, no prompt)
//! └─ wrapped by the passphrase KEK (Argon2id over the user's real
//! lockscreen passphrase, salted, then
//! HKDF-mixed with the machine signature
//! so off-device brute force needs the
//! machine seed too)
//! ```
//!
//! A passphrase change re-wraps the store key; it never re-encrypts items
//! and never orphans them. The machine wrap keeps secrets available from
//! session start without prompting (TASK-03); the passphrase wrap is the
//! design-of-record slot that makes the user's real password part of the
//! at-rest story — the eviction/Personal-class arc drives it once the
//! lock-signal ingress decision (deferred in the doc) is made.
//!
//! Persistence is atomic: a temp file created 0600 in the same directory,
//! fully written, then renamed over the store. A crash mid-write leaves the
//! previous store intact instead of a truncated JSON that would silently
//! orphan every secret on the machine.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use aes::Aes256;
use aes_gcm::aead::{Aead, Payload};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use anyhow::{Context, Result};
use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use argon2::Argon2;
use hkdf::Hkdf;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::identity::SeedId;
/// Argon2id parameters, stored in the header so they can be raised later
/// without breaking existing stores. 64 MiB / 3 passes is aggressive for a
/// phone-class device while staying interactive at unlock.
const ARGON2_M_COST_KIB: u32 = 64 * 1024;
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const DOMAIN_SEPARATION: &[u8] = b"souveraine-secrets/store-key/v1";
const HKDF_INFO_MACHINE_KEK: &[u8] = b"souveraine-secrets/machine-kek/v2";
const HKDF_INFO_PASSPHRASE_KEK: &[u8] = b"souveraine-secrets/passphrase-kek/v2";
type Encryptor = cbc::Encryptor<Aes256>;
type Decryptor = cbc::Decryptor<Aes256>;
fn default_content_type() -> String {
"text/plain".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredItem {
pub label: String,
pub attributes: HashMap<String, String>,
/// 16-byte random IV, prepended to the AES-256-CBC/PKCS7 ciphertext.
/// MIME type the client stored alongside the secret — echoed back verbatim.
#[serde(default = "default_content_type")]
pub content_type: String,
/// Unix seconds, set once at first store.
#[serde(default)]
pub created: u64,
/// Unix seconds, updated on every secret/label/attribute write.
#[serde(default)]
pub modified: u64,
/// 12-byte GCM nonce followed by the AES-256-GCM ciphertext+tag; the
/// item id is the AAD, so ciphertext cannot be swapped between items.
pub sealed: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct KdfParams {
salt: Vec<u8>,
m_cost_kib: u32,
t_cost: u32,
p_cost: u32,
}
/// The wrap slots: the store key encrypted under each KEK. `machine` always
/// exists; `passphrase` exists once the user's passphrase has been enrolled.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct Wraps {
#[serde(default)]
machine: Option<Vec<u8>>,
#[serde(default)]
passphrase: Option<Vec<u8>>,
}
/// The on-disk shape.
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoreFile {
#[serde(default)]
version: u32,
#[serde(default)]
created: u64,
#[serde(default)]
kdf: Option<KdfParams>,
#[serde(default)]
wraps: Wraps,
#[serde(default)]
items: HashMap<String, StoredItem>,
}
pub struct SecretStore {
path: PathBuf,
storage_key: [u8; 32],
items: HashMap<String, StoredItem>,
/// The unwrapped store key. Present for the daemon's life today (machine
/// wrap, session-lifetime availability); the eviction arc zeroes it.
store_key: [u8; 32],
/// Machine key material (the deterministic machined signature) — kept to
/// derive KEKs when enrolling or verifying a passphrase wrap.
machine_ikm: Vec<u8>,
file: StoreFile,
}
impl SecretStore {
/// Derive the storage key from `seed` and load any existing items from
/// `path`. Does not create or touch the seed itself.
pub fn open(seed: &SeedId, path: PathBuf) -> Result<Self> {
let storage_key = derive_storage_key(seed);
let items = if path.exists() {
/// Open (or create) the store. `machine_ikm` is the deterministic
/// machine signature from `storage_key::resolve` — never the machine
/// private key itself.
pub fn open(machine_ikm: Vec<u8>, path: PathBuf) -> Result<Self> {
let mut file: StoreFile = if path.exists() {
let raw = std::fs::read(&path).context("reading secrets store file")?;
if raw.is_empty() {
HashMap::new()
StoreFile::default()
} else {
serde_json::from_slice(&raw).context("parsing secrets store file")?
}
} else {
HashMap::new()
StoreFile::default()
};
Ok(Self { path, storage_key, items })
let machine_kek = hkdf_expand(&machine_ikm, None, HKDF_INFO_MACHINE_KEK);
let store_key = match &file.wraps.machine {
Some(wrapped) => unwrap_key(&machine_kek, wrapped, b"machine")
.context("unwrapping store key — wrong machine identity or corrupt store")?,
None => {
// Fresh store: mint the random store key and the machine wrap.
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
file.version = 2;
file.created = now_unix();
file.wraps.machine = Some(wrap_key(&machine_kek, &key, b"machine"));
key
}
};
let store = Self {
path,
store_key,
machine_ikm,
file,
};
// Persist a fresh store immediately so the wrap exists on disk even
// before the first item does.
if store.file.version == 2 && store.file.items.is_empty() && !store.path.exists() {
store.persist()?;
}
Ok(store)
}
/// Enroll or rotate the passphrase wrap — the design-of-record slot that
/// puts the user's real lockscreen passphrase into the at-rest hierarchy.
/// Re-wraps the store key only; items are untouched.
pub fn set_passphrase(&mut self, passphrase: &str) -> Result<()> {
let kdf = match &self.file.kdf {
Some(kdf) => kdf.clone(),
None => {
let mut salt = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
KdfParams {
salt,
m_cost_kib: ARGON2_M_COST_KIB,
t_cost: ARGON2_T_COST,
p_cost: ARGON2_P_COST,
}
}
};
let kek = self.passphrase_kek(passphrase, &kdf)?;
self.file.wraps.passphrase = Some(wrap_key(&kek, &self.store_key, b"passphrase"));
self.file.kdf = Some(kdf);
self.persist()
}
/// Whether the passphrase wrap slot is enrolled.
pub fn has_passphrase(&self) -> bool {
self.file.wraps.passphrase.is_some()
}
/// Verify a passphrase against the enrolled wrap (unwraps the store key
/// and compares). The PAM/stepUp side uses this to keep the wrap honest.
pub fn verify_passphrase(&self, passphrase: &str) -> Result<bool> {
let (Some(wrapped), Some(kdf)) = (&self.file.wraps.passphrase, &self.file.kdf) else {
return Ok(false);
};
let kek = self.passphrase_kek(passphrase, kdf)?;
Ok(match unwrap_key(&kek, wrapped, b"passphrase") {
Ok(key) => key == self.store_key,
Err(_) => false,
})
}
/// Argon2id over the passphrase, then HKDF-mixed with the machine
/// signature — off-device brute force of the passphrase wrap therefore
/// also requires the machine seed.
fn passphrase_kek(&self, passphrase: &str, kdf: &KdfParams) -> Result<[u8; 32]> {
let params = argon2::Params::new(kdf.m_cost_kib, kdf.t_cost, kdf.p_cost, Some(32))
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
let argon = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut derived = [0u8; 32];
argon
.hash_password_into(passphrase.as_bytes(), &kdf.salt, &mut derived)
.map_err(|e| anyhow::anyhow!("argon2 derive: {e}"))?;
Ok(hkdf_expand(
&derived,
Some(&self.machine_ikm),
HKDF_INFO_PASSPHRASE_KEK,
))
}
/// Atomic write: temp file born 0600 next to the store, then renamed over
/// it. The store never exists half-written or world-readable.
fn persist(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let raw = serde_json::to_vec(&self.items)?;
std::fs::write(&self.path, raw)?;
#[cfg(unix)]
let parent = self
.path
.parent()
.context("secrets store path has no parent directory")?;
std::fs::create_dir_all(parent)?;
let raw = serde_json::to_vec(&self.file)?;
let tmp = self.path.with_extension("json.tmp");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))?;
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut f = options.open(&tmp).context("creating secrets store temp file")?;
f.write_all(&raw)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &self.path).context("committing secrets store")?;
Ok(())
}
pub fn set(&mut self, id: String, label: String, attributes: HashMap<String, String>, value: &[u8]) -> Result<()> {
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
let ciphertext = Encryptor::new(self.storage_key.as_slice().into(), &iv.into())
.encrypt_padded_vec_mut::<cbc::cipher::block_padding::Pkcs7>(value);
let mut sealed = iv.to_vec();
sealed.extend(ciphertext);
self.items.insert(id, StoredItem { label, attributes, sealed });
pub fn set(
&mut self,
id: String,
label: String,
attributes: HashMap<String, String>,
content_type: String,
value: &[u8],
) -> Result<()> {
let sealed = seal(&self.store_key, id.as_bytes(), value)?;
let now = now_unix();
let created = self.file.items.get(&id).map(|i| i.created).unwrap_or(now);
self.file.items.insert(
id,
StoredItem {
label,
attributes,
content_type,
created,
modified: now,
sealed,
},
);
self.persist()
}
pub fn get(&self, id: &str) -> Result<Option<Vec<u8>>> {
let Some(item) = self.items.get(id) else {
let Some(item) = self.file.items.get(id) else {
return Ok(None);
};
anyhow::ensure!(item.sealed.len() > 16, "stored item ciphertext too short");
let (iv, ciphertext) = item.sealed.split_at(16);
let iv_arr: [u8; 16] = iv.try_into().unwrap();
let plaintext = Decryptor::new(self.storage_key.as_slice().into(), &iv_arr.into())
.decrypt_padded_vec_mut::<cbc::cipher::block_padding::Pkcs7>(ciphertext)
.context("decrypting stored item — wrong seed or corrupt store")?;
let plaintext = open_sealed(&self.store_key, id.as_bytes(), &item.sealed)
.context("decrypting stored item — wrong machine key or corrupt store")?;
Ok(Some(plaintext))
}
pub fn delete(&mut self, id: &str) -> Result<()> {
self.items.remove(id);
self.file.items.remove(id);
self.persist()
}
pub fn search(&self, attributes: &HashMap<String, String>) -> Vec<String> {
self.items
self.file
.items
.iter()
.filter(|(_, item)| {
attributes.iter().all(|(k, v)| item.attributes.get(k) == Some(v))
@ -117,27 +300,223 @@ impl SecretStore {
}
pub fn label(&self, id: &str) -> Option<String> {
self.items.get(id).map(|i| i.label.clone())
self.file.items.get(id).map(|i| i.label.clone())
}
pub fn attributes(&self, id: &str) -> Option<HashMap<String, String>> {
self.items.get(id).map(|i| i.attributes.clone())
self.file.items.get(id).map(|i| i.attributes.clone())
}
pub fn content_type(&self, id: &str) -> Option<String> {
self.file.items.get(id).map(|i| i.content_type.clone())
}
pub fn created(&self, id: &str) -> Option<u64> {
self.file.items.get(id).map(|i| i.created)
}
pub fn modified(&self, id: &str) -> Option<u64> {
self.file.items.get(id).map(|i| i.modified)
}
/// The collection's birth timestamp.
pub fn collection_created(&self) -> u64 {
self.file.created
}
/// The collection's last-modified: the newest item write, or the birth
/// timestamp for an empty store.
pub fn collection_modified(&self) -> u64 {
self.file
.items
.values()
.map(|i| i.modified)
.max()
.unwrap_or(self.file.created)
}
pub fn all_ids(&self) -> Vec<String> {
self.items.keys().cloned().collect()
self.file.items.keys().cloned().collect()
}
}
fn derive_storage_key(seed: &SeedId) -> [u8; 32] {
let signature = seed.sign(DOMAIN_SEPARATION);
let hk = Hkdf::<Sha256>::new(None, &signature.to_bytes());
// ---- sealing primitives --------------------------------------------------
/// AES-256-GCM: 12-byte random nonce prepended, AAD binds the context so a
/// wrap or item ciphertext cannot be replayed in another slot.
fn seal(key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new(key.into());
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), Payload { msg: plaintext, aad })
.map_err(|_| anyhow::anyhow!("AEAD seal failed"))?;
let mut out = nonce.to_vec();
out.extend(ciphertext);
Ok(out)
}
fn open_sealed(key: &[u8; 32], aad: &[u8], sealed: &[u8]) -> Result<Vec<u8>> {
anyhow::ensure!(sealed.len() > 12 + 16, "sealed blob too short");
let (nonce, ciphertext) = sealed.split_at(12);
let cipher = Aes256Gcm::new(key.into());
cipher
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad })
.map_err(|_| anyhow::anyhow!("AEAD open failed — wrong key or tampered data"))
}
fn wrap_key(kek: &[u8; 32], key: &[u8; 32], slot: &[u8]) -> Vec<u8> {
seal(kek, slot, key).expect("wrapping a 32-byte key cannot fail")
}
fn unwrap_key(kek: &[u8; 32], wrapped: &[u8], slot: &[u8]) -> Result<[u8; 32]> {
let bytes = open_sealed(kek, slot, wrapped)?;
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("unwrapped key has wrong length"))
}
fn hkdf_expand(ikm: &[u8], salt: Option<&[u8]>, info: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(salt, ikm);
let mut key = [0u8; 32];
hk.expand(b"aes-256-store-key", &mut key)
hk.expand(info, &mut key)
.expect("HKDF expand with fixed 32-byte output cannot fail");
key
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn default_store_path(base_path: &Path) -> PathBuf {
base_path.join("secrets").join("store.json")
}
#[cfg(test)]
mod tests {
use super::*;
fn ikm() -> Vec<u8> {
vec![7u8; 64]
}
#[test]
fn round_trips_and_persists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
store
.set(
"id1".into(),
"label".into(),
HashMap::from([("app".to_string(), "test".to_string())]),
"text/plain".into(),
b"hunter2",
)
.unwrap();
// Reopen from disk under the same machine material — the item survives.
let store2 = SecretStore::open(ikm(), path).unwrap();
assert_eq!(store2.get("id1").unwrap().unwrap(), b"hunter2");
assert_eq!(store2.content_type("id1").unwrap(), "text/plain");
assert!(store2.created("id1").unwrap() > 0);
}
#[test]
fn wrong_machine_identity_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
.unwrap();
drop(store);
assert!(SecretStore::open(vec![9u8; 64], path).is_err());
}
#[test]
fn item_ciphertext_is_bound_to_its_id() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap();
store
.set("a".into(), "l".into(), HashMap::new(), "text/plain".into(), b"secret-a")
.unwrap();
// Grafting a's ciphertext onto id b must fail the AAD check.
let sealed = store.file.items.get("a").unwrap().sealed.clone();
assert!(open_sealed(&store.store_key, b"b", &sealed).is_err());
}
#[test]
fn passphrase_wrap_enrolls_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
assert!(!store.has_passphrase());
store.set_passphrase("correct horse battery staple").unwrap();
assert!(store.has_passphrase());
assert!(store.verify_passphrase("correct horse battery staple").unwrap());
assert!(!store.verify_passphrase("wrong").unwrap());
// The wrap survives a reload and still verifies.
let store2 = SecretStore::open(ikm(), path).unwrap();
assert!(store2.verify_passphrase("correct horse battery staple").unwrap());
}
#[test]
fn passphrase_rotation_rewraps_without_touching_items() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path).unwrap();
store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
.unwrap();
let sealed_before = store.file.items.get("id").unwrap().sealed.clone();
store.set_passphrase("first").unwrap();
store.set_passphrase("second").unwrap();
assert!(!store.verify_passphrase("first").unwrap());
assert!(store.verify_passphrase("second").unwrap());
// Rotation re-wraps the store key only; the item bytes are untouched.
assert_eq!(store.file.items.get("id").unwrap().sealed, sealed_before);
assert_eq!(store.get("id").unwrap().unwrap(), b"s");
}
#[test]
fn created_survives_rewrite_and_modified_advances() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::open(ikm(), dir.path().join("store.json")).unwrap();
store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"a")
.unwrap();
let created = store.created("id").unwrap();
store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"b")
.unwrap();
assert_eq!(store.created("id").unwrap(), created);
assert!(store.modified("id").unwrap() >= created);
}
#[test]
fn store_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
let mut store = SecretStore::open(ikm(), path.clone()).unwrap();
store
.set("id".into(), "l".into(), HashMap::new(), "text/plain".into(), b"s")
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
}