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:
parent
d027251024
commit
d824ca7f81
13 changed files with 1088 additions and 478 deletions
429
Cargo.lock
generated
429
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
108
packaging/deploy-secrets-phone.sh
Executable 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 =="
|
||||
4
packaging/org.freedesktop.secrets.service
Normal file
4
packaging/org.freedesktop.secrets.service
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[D-BUS Service]
|
||||
Name=org.freedesktop.secrets
|
||||
Exec=/usr/local/bin/souveraine-secrets
|
||||
SystemdService=souveraine-secrets.service
|
||||
22
packaging/souveraine-secrets.service
Normal file
22
packaging/souveraine-secrets.service
Normal 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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
58
src/secrets/manage.rs
Normal 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}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
141
src/secrets/storage_key.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue