Watch
1
0
Fork
You've already forked souveraine
0

secrets: org.freedesktop.secrets daemon rooted in the machine SeedId

souveraine-secrets (feature 'secrets', own bin target) backs libsecret
clients — Chatty/libcmatrix and our keyring-core store — without
gnome-keyring or KWallet. Implements the dh-ietf1024-sha256-aes128-cbc-pkcs7
session transport (zbus/hkdf/aes-cbc, MODP-1024 via num-bigint) plus
collection/item/session objects and the store. SeedId::load() is the strict
no-generate counterpart to load_or_generate: the daemon treats identity as a
precondition and fails loudly if none exists.
This commit is contained in:
Fimeg 2026-07-16 08:06:20 -04:00
commit 04a8390e8d
14 changed files with 1111 additions and 0 deletions

10
Cargo.lock generated
View file

@ -9062,14 +9062,17 @@ dependencies = [
name = "souveraine"
version = "1.0.0"
dependencies = [
"aes",
"anyhow",
"arboard",
"async-trait",
"axis2d",
"axum",
"base64 0.22.1",
"cbc",
"chord_macro 0.1.0",
"chrono",
"cipher",
"clap 4.6.1",
"clap_complete",
"colored",
@ -9086,12 +9089,15 @@ dependencies = [
"git2",
"glob",
"hex",
"hkdf",
"hound",
"image",
"keyring",
"keyring-core",
"matrix-sdk",
"notify",
"num-bigint",
"num-traits",
"once_cell",
"pulldown-cmark",
"rand 0.8.6",
@ -9130,6 +9136,8 @@ dependencies = [
"unicode-width 0.1.14",
"uuid",
"walkdir",
"zbus",
"zvariant",
]
[[package]]
@ -10403,6 +10411,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@ -13206,6 +13215,7 @@ dependencies = [
"rustix 1.1.4",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",

View file

@ -138,6 +138,22 @@ glob = "0.3"
# in-memory store; e2ee + sqlite store return in Phase 6.
matrix-sdk = { version = "0.17", optional = true, default-features = false }
# Secret Service daemon (org.freedesktop.secrets) — optional binary target.
# Backs libsecret clients (Chatty/libcmatrix, and our own keyring-core above)
# from the existing SeedId, so the machine identity is the trust root instead
# of gnome-keyring/KWallet. zbus = session bus; aes/cbc = AES-128-CBC + PKCS7
# (the OpenSession "dh-ietf1024-sha256-aes128-cbc-pkcs7" transport); hkdf =
# session key derivation (sha2/rand already plain deps above); num-bigint =
# RFC 2409 MODP-1024 group math for the OpenSession DH exchange itself.
zbus = { version = "5", optional = true, default-features = false, features = ["tokio"] }
zvariant = { version = "5", optional = true }
aes = { version = "0.8", optional = true }
cbc = { version = "0.1", optional = true, features = ["alloc"] }
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 }
@ -158,6 +174,11 @@ path = "examples/demo.rs"
name = "souveraine"
path = "src/main.rs"
[[bin]]
name = "souveraine-secrets"
path = "src/bin/souveraine-secrets.rs"
required-features = ["secrets"]
[profile.release]
opt-level = 3
lto = true
@ -176,3 +197,5 @@ matrix = ["dep:matrix-sdk"]
# GUI render mode — forwards to tuie's windowed backend (winit/wgpu). Gates the
# 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"]

View file

@ -0,0 +1,117 @@
//! 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
//! `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`.
#[path = "../core/identity/seed.rs"]
mod identity;
#[path = "../secrets/dh.rs"]
mod dh;
#[path = "../secrets/transport.rs"]
mod transport;
#[path = "../secrets/session.rs"]
mod session;
#[path = "../secrets/session_object.rs"]
mod session_object;
#[path = "../secrets/store.rs"]
mod store;
#[path = "../secrets/types.rs"]
mod types;
#[path = "../secrets/error.rs"]
mod error;
#[path = "../secrets/service.rs"]
mod service;
#[path = "../secrets/collection.rs"]
mod collection;
#[path = "../secrets/item.rs"]
mod item;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use tracing::info;
use collection::Collection;
use identity::SeedId;
use item::Item;
use service::SecretService;
use session::Sessions;
use store::SecretStore;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
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 store_path = store::default_store_path(&base);
let secret_store = Arc::new(Mutex::new(
SecretStore::open(&seed, store_path).context("opening secrets store")?,
));
let sessions = Arc::new(Sessions::new());
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(
"/org/freedesktop/secrets",
SecretService {
store: secret_store.clone(),
sessions: sessions.clone(),
},
)
.await?;
connection
.object_server()
.at(
"/org/freedesktop/secrets/collection/souveraine",
Collection {
store: secret_store.clone(),
sessions: sessions.clone(),
},
)
.await?;
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,
Item {
id,
store: secret_store.clone(),
sessions: sessions.clone(),
},
)
.await?;
}
info!("souveraine-secrets: serving org.freedesktop.secrets on the session bus");
std::future::pending::<()>().await;
Ok(())
}

View file

@ -80,6 +80,35 @@ impl SeedId {
Ok(seed)
}
/// Load an existing seed from disk. Errors if absent or malformed.
///
/// Strict counterpart to `load_or_generate` for consumers that must never
/// create an identity — the secret-service daemon, federation responders,
/// anything that treats the seed as a precondition rather than something to
/// bootstrap. No generation path, no directory creation, no fallback.
pub fn load(seed_dir: &Path) -> Result<Self> {
let private_path = seed_dir.join("private.key");
if !private_path.exists() {
anyhow::bail!(
"no seed identity at {} — refusing to proceed; provision one via `souveraine identity generate`",
private_path.display()
);
}
let bytes = std::fs::read(&private_path)
.context("reading seed private key")?;
if bytes.len() != 32 {
anyhow::bail!("seed private key has wrong length: {} (expected 32)", bytes.len());
}
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&bytes);
let signing_key = SigningKey::from_bytes(&key_bytes);
let verifying_key = signing_key.verifying_key();
Ok(Self {
signing_key,
verifying_key,
})
}
pub fn public_key_hex(&self) -> String {
hex::encode(self.verifying_key.to_bytes())
}

164
src/secrets/collection.rs Normal file
View file

@ -0,0 +1,164 @@
//! `org.freedesktop.Secret.Collection` — a single fixed collection at
//! `/org/freedesktop/secrets/collection/souveraine`.
//!
//! 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.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use zbus::interface;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use crate::error::SecretError;
use crate::item::Item;
use crate::session::Sessions;
use crate::store::SecretStore;
use crate::types::Secret;
const COLLECTION_PATH: &str = "/org/freedesktop/secrets/collection/souveraine";
const NULL_PATH: &str = "/";
pub struct Collection {
pub store: Arc<Mutex<SecretStore>>,
pub sessions: Arc<Sessions>,
}
#[interface(name = "org.freedesktop.Secret.Collection")]
impl Collection {
/// Refuse rather than pretend — there is exactly one collection and it
/// isn't going away while the daemon that backs libsecret is running.
async fn delete(&self) -> zbus::fdo::Result<OwnedObjectPath> {
Err(zbus::fdo::Error::NotSupported(
"the souveraine collection cannot be deleted".into(),
))
}
async fn search_items(
&self,
attributes: HashMap<String, String>,
) -> zbus::fdo::Result<Vec<OwnedObjectPath>> {
let store = self.store.lock().unwrap();
Ok(store
.search(&attributes)
.iter()
.map(|id| item_path(id).try_into().unwrap())
.collect())
}
async fn create_item(
&self,
properties: HashMap<String, OwnedValue>,
secret: Secret,
replace: bool,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> Result<(OwnedObjectPath, OwnedObjectPath), SecretError> {
let label = properties
.get("org.freedesktop.Secret.Item.Label")
.and_then(|v| String::try_from(v.clone()).ok())
.unwrap_or_default();
let attributes: HashMap<String, String> = properties
.get("org.freedesktop.Secret.Item.Attributes")
.and_then(|v| HashMap::<String, String>::try_from(v.clone()).ok())
.unwrap_or_default();
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 existing_id = if replace {
let store = self.store.lock().unwrap();
store.search(&attributes).into_iter().next()
} else {
None
};
let is_new = existing_id.is_none();
let id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string());
{
let mut store = self.store.lock().unwrap();
store
.set(id.clone(), label, attributes, &plaintext)
.map_err(|e| SecretError::failed(format!("failed to store item: {e}")))?;
}
let path = item_path(&id);
if is_new {
emitter
.connection()
.object_server()
.at(
path.clone(),
Item {
id: id.clone(),
store: self.store.clone(),
sessions: self.sessions.clone(),
},
)
.await
.map_err(|e| SecretError::failed(format!("failed to register item object: {e}")))?;
}
let item: OwnedObjectPath = path.try_into().unwrap();
let prompt: OwnedObjectPath = NULL_PATH.try_into().unwrap();
Ok((item, prompt))
}
#[zbus(property)]
async fn items(&self) -> Vec<OwnedObjectPath> {
let store = self.store.lock().unwrap();
store
.all_ids()
.iter()
.map(|id| item_path(id).try_into().unwrap())
.collect()
}
#[zbus(property)]
async fn label(&self) -> String {
"souveraine".to_string()
}
#[zbus(property)]
async fn set_label(&self, _value: String) -> zbus::Result<()> {
// Single fixed collection — relabeling is a no-op, not an error;
// clients that just set the label they already read shouldn't fail.
Ok(())
}
#[zbus(property)]
async fn locked(&self) -> bool {
false
}
#[zbus(property)]
async fn created(&self) -> u64 {
now_unix()
}
#[zbus(property)]
async fn modified(&self) -> u64 {
now_unix()
}
}
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)
}

107
src/secrets/dh.rs Normal file
View file

@ -0,0 +1,107 @@
//! OpenSession key exchange for the Secret Service D-Bus API.
//!
//! Implements `dh-ietf1024-sha256-aes128-cbc-pkcs7`: a classic (non-EC)
//! Diffie-Hellman exchange over the RFC 2409 "Second Oakley Group" (MODP,
//! 1024-bit), with the shared secret run through HKDF-SHA256 to derive a
//! 16-byte AES-128 session key. This mirrors libsecret's own
//! `secret-session.c` exactly — client and server must agree byte-for-byte
//! on the group and the KDF, or negotiation silently produces mismatched
//! keys instead of a clean failure.
//!
//! Reference: <https://gitlab.gnome.org/GNOME/libsecret/-/blob/main/libsecret/secret-session.c>
use anyhow::{Context, Result};
use hkdf::Hkdf;
use num_bigint::BigUint;
use num_traits::Num;
use sha2::Sha256;
/// RFC 2409 Second Oakley Group: 1024-bit MODP prime, generator 2.
const MODP_1024_PRIME_HEX: &str = concat!(
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD",
"129024E088A67CC74020BBEA63B139B22514A08798E3404",
"DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C",
"245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406",
"B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE",
"45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD",
"24CF5F83655D23DCA3AD961C62F356208552BB9ED529077",
"096966D670C354E4ABC9804F1746C08CA18217C32905E46",
"2E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF",
"06F4C52C9DE2BCBF6955817183995497CEA956AE515D226",
"1898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF",
);
const GENERATOR: u64 = 2;
/// This side's half of an in-progress OpenSession exchange.
pub struct DhSession {
private: BigUint,
prime: BigUint,
/// The public value we sent to the peer — kept only for logging/debugging.
pub public: BigUint,
}
impl DhSession {
/// Generate our private exponent and compute the public value to send back
/// to the client as `OpenSession`'s output.
pub fn new() -> Result<Self> {
let prime = BigUint::from_str_radix(MODP_1024_PRIME_HEX, 16)
.context("parsing MODP-1024 prime")?;
let generator = BigUint::from(GENERATOR);
// 1024-bit exchange: a private exponent as wide as the group avoids
// biasing the shared secret's bit length. `rand` fills the private
// key; libsecret does the equivalent via gcry_mpi_randomize.
let mut bytes = [0u8; 128];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut bytes);
let private = BigUint::from_bytes_be(&bytes) % &prime;
let public = generator.modpow(&private, &prime);
Ok(Self { private, prime, public })
}
/// Combine the peer's public value with our private exponent, then run
/// the shared secret through HKDF-SHA256 (no salt, no info — matching
/// libsecret) to derive a 16-byte AES-128 session key.
///
/// The raw DH shared secret must be encoded as a fixed-width big-endian
/// big number *without* stripping leading zero bytes — libsecret's
/// `egg_dh_gen_secret` preserves the full modulus width. Losing that
/// padding is the classic interop bug: it silently shifts every key
/// derived from a shared secret that happens to start with a zero byte.
pub fn derive_session_key(&self, peer_public: &BigUint) -> Result<[u8; 16]> {
let shared = peer_public.modpow(&self.private, &self.prime);
let modulus_len = (self.prime.bits() as usize + 7) / 8;
let mut shared_bytes = vec![0u8; modulus_len];
let raw = shared.to_bytes_be();
anyhow::ensure!(
raw.len() <= modulus_len,
"DH shared secret wider than modulus — malformed peer public value"
);
shared_bytes[modulus_len - raw.len()..].copy_from_slice(&raw);
let hk = Hkdf::<Sha256>::new(None, &shared_bytes);
let mut key = [0u8; 16];
hk.expand(&[], &mut key)
.map_err(|_| anyhow::anyhow!("HKDF expand failed producing session key"))?;
Ok(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_sides_derive_the_same_session_key() {
let alice = DhSession::new().unwrap();
let bob = DhSession::new().unwrap();
let alice_key = alice.derive_session_key(&bob.public).unwrap();
let bob_key = bob.derive_session_key(&alice.public).unwrap();
assert_eq!(alice_key, bob_key);
}
}

29
src/secrets/error.rs Normal file
View file

@ -0,0 +1,29 @@
//! Secret Service D-Bus error names.
//!
//! Reference: <https://specifications.freedesktop.org/secret-service/latest/errors.html>
//! Only three custom errors are defined by the spec; anything else maps to
//! a generic `org.freedesktop.DBus.Error.Failed`.
use zbus::DBusError;
#[derive(Debug, DBusError)]
#[zbus(prefix = "org.freedesktop.Secret.Error")]
pub enum SecretError {
/// The object must be unlocked before this action can be carried out.
#[allow(dead_code)]
IsLocked(String),
/// The session does not exist.
NoSession(String),
/// No such item or collection exists.
NoSuchObject(String),
#[zbus(error)]
ZBus(zbus::Error),
}
impl SecretError {
/// A generic failure that isn't one of the spec's three named errors —
/// surfaces to the caller as `org.freedesktop.DBus.Error.Failed`.
pub fn failed(msg: impl Into<String>) -> Self {
Self::ZBus(zbus::Error::Failure(msg.into()))
}
}

132
src/secrets/item.rs Normal file
View file

@ -0,0 +1,132 @@
//! `org.freedesktop.Secret.Item` — one object per stored secret, at
//! `/org/freedesktop/secrets/collection/souveraine/<id>`.
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::error::SecretError;
use crate::session::Sessions;
use crate::store::SecretStore;
use crate::types::Secret;
pub struct Item {
pub id: String,
pub store: Arc<Mutex<SecretStore>>,
pub sessions: Arc<Sessions>,
}
#[interface(name = "org.freedesktop.Secret.Item")]
impl Item {
async fn delete(
&self,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> Result<OwnedObjectPath, SecretError> {
{
let mut store = self.store.lock().unwrap();
store
.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 _: bool = emitter
.connection()
.object_server()
.remove::<Item, _>(path)
.await
.map_err(|e| SecretError::failed(format!("failed to unregister item object: {e}")))?;
Ok("/".try_into().unwrap())
}
async fn get_secret(&self, session: OwnedObjectPath) -> Result<Secret, SecretError> {
let store = self.store.lock().unwrap();
let plaintext = store
.get(&self.id)
.map_err(|e| SecretError::failed(format!("failed to read item: {e}")))?
.ok_or_else(|| SecretError::NoSuchObject(self.id.clone()))?;
let (parameters, value) = self
.sessions
.seal(&session, &plaintext)
.ok_or_else(|| SecretError::NoSession(format!("no such session: {session}")))?;
Ok(Secret {
session,
parameters,
value,
content_type: "text/plain".to_string(),
})
}
async fn set_secret(&self, secret: Secret) -> 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}")))?;
Ok(())
}
#[zbus(property)]
async fn locked(&self) -> bool {
false
}
#[zbus(property)]
async fn attributes(&self) -> HashMap<String, String> {
self.store.lock().unwrap().attributes(&self.id).unwrap_or_default()
}
#[zbus(property)]
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();
if let Ok(Some(plaintext)) = store.get(&self.id) {
let _ = store.set(self.id.clone(), label, attributes, &plaintext);
}
Ok(())
}
#[zbus(property)]
async fn label(&self) -> String {
self.store.lock().unwrap().label(&self.id).unwrap_or_default()
}
#[zbus(property)]
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();
if let Ok(Some(plaintext)) = store.get(&self.id) {
let _ = store.set(self.id.clone(), value, attributes, &plaintext);
}
Ok(())
}
#[zbus(property)]
async fn created(&self) -> u64 {
now_unix()
}
#[zbus(property)]
async fn modified(&self) -> u64 {
now_unix()
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}

192
src/secrets/service.rs Normal file
View file

@ -0,0 +1,192 @@
//! `org.freedesktop.Secret.Service` — the root object at
//! `/org/freedesktop/secrets`.
//!
//! Everything is permanently unlocked: the seed identity is resident in this
//! process the whole time it runs, so there is no separate "unlock the
//! keyring" step the way there is for a passphrase-based store. `Unlock`
//! always succeeds immediately with no prompt.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use num_bigint::BigUint;
use tracing::warn;
use zbus::interface;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value};
use crate::dh::DhSession;
use crate::error::SecretError;
use crate::session::{SessionCrypto, Sessions};
use crate::session_object::Session;
use crate::store::SecretStore;
use crate::types::Secret;
const COLLECTION_PATH: &str = "/org/freedesktop/secrets/collection/souveraine";
const NULL_PATH: &str = "/";
pub struct SecretService {
pub store: Arc<Mutex<SecretStore>>,
pub sessions: Arc<Sessions>,
}
#[interface(name = "org.freedesktop.Secret.Service")]
impl SecretService {
async fn open_session(
&self,
algorithm: String,
input: OwnedValue,
#[zbus(signal_emitter)] emitter: SignalEmitter<'_>,
) -> Result<(OwnedValue, OwnedObjectPath), SecretError> {
let path = self.sessions.next_path();
let out = match algorithm.as_str() {
"plain" => {
self.sessions.insert(path.clone(), SessionCrypto::Plain);
OwnedValue::try_from(Value::from("")).unwrap()
}
"dh-ietf1024-sha256-aes128-cbc-pkcs7" => {
let peer_public_bytes: Vec<u8> = Vec::<u8>::try_from(input).map_err(|_| {
SecretError::failed(
"OpenSession input must be the client's DH public value as bytes",
)
})?;
let peer_public = BigUint::from_bytes_be(&peer_public_bytes);
let dh = DhSession::new()
.map_err(|e| SecretError::failed(format!("DH session setup failed: {e}")))?;
let key = dh
.derive_session_key(&peer_public)
.map_err(|e| SecretError::failed(format!("DH key derivation failed: {e}")))?;
self.sessions.insert(path.clone(), SessionCrypto::Aes { key });
let our_public_bytes = dh.public.to_bytes_be();
OwnedValue::try_from(Value::from(our_public_bytes)).unwrap()
}
other => {
return Err(SecretError::failed(format!(
"unsupported OpenSession algorithm: {other}"
)));
}
};
emitter
.connection()
.object_server()
.at(
path.clone(),
Session {
path: path.clone(),
sessions: self.sessions.clone(),
},
)
.await
.map_err(|e| SecretError::failed(format!("failed to register session object: {e}")))?;
Ok((out, path))
}
async fn create_collection(
&self,
_properties: HashMap<String, OwnedValue>,
_alias: String,
) -> zbus::fdo::Result<(OwnedObjectPath, OwnedObjectPath)> {
// Single fixed collection — see Collection docs for why.
let path: OwnedObjectPath = COLLECTION_PATH.try_into().unwrap();
let prompt: OwnedObjectPath = NULL_PATH.try_into().unwrap();
Ok((path, prompt))
}
async fn search_items(
&self,
attributes: HashMap<String, String>,
) -> zbus::fdo::Result<(Vec<OwnedObjectPath>, Vec<OwnedObjectPath>)> {
let store = self.store.lock().unwrap();
let ids = store.search(&attributes);
let unlocked: Vec<OwnedObjectPath> = ids
.iter()
.map(|id| item_path(id).try_into().unwrap())
.collect();
Ok((unlocked, Vec::new()))
}
/// Everything is already unlocked (the seed never locks). Report success
/// immediately with no prompt.
async fn unlock(
&self,
objects: Vec<OwnedObjectPath>,
) -> zbus::fdo::Result<(Vec<OwnedObjectPath>, OwnedObjectPath)> {
let prompt: OwnedObjectPath = NULL_PATH.try_into().unwrap();
Ok((objects, prompt))
}
/// There is nothing to lock — refuse the concept rather than pretend.
async fn lock(
&self,
_objects: Vec<OwnedObjectPath>,
) -> zbus::fdo::Result<(Vec<OwnedObjectPath>, OwnedObjectPath)> {
let prompt: OwnedObjectPath = NULL_PATH.try_into().unwrap();
Ok((Vec::new(), prompt))
}
async fn get_secrets(
&self,
items: Vec<OwnedObjectPath>,
session: OwnedObjectPath,
) -> Result<HashMap<OwnedObjectPath, Secret>, SecretError> {
let store = self.store.lock().unwrap();
let mut out = HashMap::new();
for item_obj_path in items {
let Some(id) = id_from_item_path(&item_obj_path) else {
continue;
};
let Ok(Some(plaintext)) = store.get(id) else {
continue;
};
let Some((parameters, value)) = self.sessions.seal(&session, &plaintext) else {
return Err(SecretError::NoSession(format!("no such session: {session}")));
};
out.insert(
item_obj_path,
Secret {
session: session.clone(),
parameters,
value,
content_type: "text/plain".to_string(),
},
);
}
Ok(out)
}
async fn read_alias(&self, name: String) -> zbus::fdo::Result<OwnedObjectPath> {
if name == "default" {
Ok(COLLECTION_PATH.try_into().unwrap())
} else {
Ok(NULL_PATH.try_into().unwrap())
}
}
async fn set_alias(&self, name: String, collection: OwnedObjectPath) -> zbus::fdo::Result<()> {
if name != "default" || collection.as_str() != COLLECTION_PATH {
warn!(%name, %collection, "set_alias: only the 'default' alias on the souveraine collection is supported, ignoring");
}
Ok(())
}
#[zbus(property)]
async fn collections(&self) -> Vec<OwnedObjectPath> {
vec![COLLECTION_PATH.try_into().unwrap()]
}
}
fn item_path(id: &str) -> String {
format!("{COLLECTION_PATH}/{id}")
}
fn id_from_item_path<'a>(path: &'a ObjectPath<'_>) -> Option<&'a str> {
path.as_str().strip_prefix(&format!("{COLLECTION_PATH}/"))
}

68
src/secrets/session.rs Normal file
View file

@ -0,0 +1,68 @@
//! Tracks negotiated `OpenSession` transports.
//!
//! libsecret always tries the AES algorithm first and falls back to `plain`
//! only if the daemon returns `NotSupported` — so supporting just these two
//! covers every real client.
use std::collections::HashMap;
use std::sync::Mutex;
use zbus::zvariant::OwnedObjectPath;
pub enum SessionCrypto {
Plain,
Aes { key: [u8; 16] },
}
pub struct Sessions {
inner: Mutex<HashMap<OwnedObjectPath, SessionCrypto>>,
next_id: Mutex<u64>,
}
impl Sessions {
pub fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
next_id: Mutex::new(0),
}
}
pub fn next_path(&self) -> OwnedObjectPath {
let mut id = self.next_id.lock().unwrap();
*id += 1;
format!("/org/freedesktop/secrets/session/s{}", *id)
.try_into()
.expect("session path is always a valid object path")
}
pub fn insert(&self, path: OwnedObjectPath, crypto: SessionCrypto) {
self.inner.lock().unwrap().insert(path, crypto);
}
pub fn remove(&self, path: &OwnedObjectPath) {
self.inner.lock().unwrap().remove(path);
}
/// Encrypt `plaintext` per the session's negotiated transport, returning
/// `(parameters, value)` ready to place in a `Secret` struct.
pub fn seal(&self, path: &OwnedObjectPath, plaintext: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let sessions = self.inner.lock().unwrap();
match sessions.get(path)? {
SessionCrypto::Plain => Some((Vec::new(), plaintext.to_vec())),
SessionCrypto::Aes { key } => {
let (iv, ciphertext) = crate::transport::encrypt(key, plaintext);
Some((iv, ciphertext))
}
}
}
/// Decrypt a `Secret`'s `(parameters, value)` per the session's negotiated
/// transport.
pub fn unseal(&self, path: &OwnedObjectPath, parameters: &[u8], value: &[u8]) -> Option<anyhow::Result<Vec<u8>>> {
let sessions = self.inner.lock().unwrap();
match sessions.get(path)? {
SessionCrypto::Plain => Some(Ok(value.to_vec())),
SessionCrypto::Aes { key } => Some(crate::transport::decrypt(key, parameters, value)),
}
}
}

View file

@ -0,0 +1,31 @@
//! `org.freedesktop.Secret.Session` — the object `OpenSession` hands back.
//! Its only job is `Close`, so a client can tell us to forget the negotiated
//! key instead of it living for the daemon's whole lifetime.
use std::sync::Arc;
use zbus::interface;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::OwnedObjectPath;
use crate::error::SecretError;
use crate::session::Sessions;
pub struct Session {
pub path: OwnedObjectPath,
pub sessions: Arc<Sessions>,
}
#[interface(name = "org.freedesktop.Secret.Session")]
impl Session {
async fn close(&self, #[zbus(signal_emitter)] emitter: SignalEmitter<'_>) -> Result<(), SecretError> {
self.sessions.remove(&self.path);
emitter
.connection()
.object_server()
.remove::<Session, _>(self.path.clone())
.await
.map_err(|e| SecretError::failed(format!("failed to unregister session object: {e}")))?;
Ok(())
}
}

143
src/secrets/store.rs Normal file
View file

@ -0,0 +1,143 @@
//! On-disk encrypted item storage, keyed from the machine's existing `SeedId`.
//!
//! 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.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use aes::Aes256;
use anyhow::{Context, Result};
use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use hkdf::Hkdf;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::identity::SeedId;
const DOMAIN_SEPARATION: &[u8] = b"souveraine-secrets/store-key/v1";
type Encryptor = cbc::Encryptor<Aes256>;
type Decryptor = cbc::Decryptor<Aes256>;
#[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.
pub sealed: Vec<u8>,
}
pub struct SecretStore {
path: PathBuf,
storage_key: [u8; 32],
items: HashMap<String, StoredItem>,
}
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() {
let raw = std::fs::read(&path).context("reading secrets store file")?;
if raw.is_empty() {
HashMap::new()
} else {
serde_json::from_slice(&raw).context("parsing secrets store file")?
}
} else {
HashMap::new()
};
Ok(Self { path, storage_key, items })
}
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)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))?;
}
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 });
self.persist()
}
pub fn get(&self, id: &str) -> Result<Option<Vec<u8>>> {
let Some(item) = self.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")?;
Ok(Some(plaintext))
}
pub fn delete(&mut self, id: &str) -> Result<()> {
self.items.remove(id);
self.persist()
}
pub fn search(&self, attributes: &HashMap<String, String>) -> Vec<String> {
self.items
.iter()
.filter(|(_, item)| {
attributes.iter().all(|(k, v)| item.attributes.get(k) == Some(v))
})
.map(|(id, _)| id.clone())
.collect()
}
pub fn label(&self, id: &str) -> Option<String> {
self.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())
}
pub fn all_ids(&self) -> Vec<String> {
self.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());
let mut key = [0u8; 32];
hk.expand(b"aes-256-store-key", &mut key)
.expect("HKDF expand with fixed 32-byte output cannot fail");
key
}
pub fn default_store_path(base_path: &Path) -> PathBuf {
base_path.join("secrets").join("store.json")
}

46
src/secrets/transport.rs Normal file
View file

@ -0,0 +1,46 @@
//! AES-128-CBC + PKCS7 wire encryption for secrets sent over a negotiated
//! `OpenSession`. Matches libsecret's `secret-session.c` framing: the
//! `Secret` struct on the wire is `(session_path, iv_bytes, ciphertext_bytes,
//! content_type)`, IV is 16 random bytes generated fresh per secret.
use aes::Aes128;
use anyhow::{Context, Result};
use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use rand::RngCore;
type Encryptor = cbc::Encryptor<Aes128>;
type Decryptor = cbc::Decryptor<Aes128>;
/// Encrypt `plaintext` under `session_key`, returning (iv, ciphertext).
pub fn encrypt(session_key: &[u8; 16], plaintext: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
let ciphertext = Encryptor::new(session_key.into(), &iv.into())
.encrypt_padded_vec_mut::<cbc::cipher::block_padding::Pkcs7>(plaintext);
(iv.to_vec(), ciphertext)
}
/// Decrypt a secret's `(iv, ciphertext)` under `session_key`.
pub fn decrypt(session_key: &[u8; 16], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
anyhow::ensure!(iv.len() == 16, "IV must be 16 bytes, got {}", iv.len());
let iv_arr: [u8; 16] = iv.try_into().unwrap();
Decryptor::new(session_key.into(), &iv_arr.into())
.decrypt_padded_vec_mut::<cbc::cipher::block_padding::Pkcs7>(ciphertext)
.context("AES-CBC/PKCS7 decrypt failed — wrong session key or corrupt ciphertext")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
let key = [7u8; 16];
let (iv, ct) = encrypt(&key, b"hunter2");
let pt = decrypt(&key, &iv, &ct).unwrap();
assert_eq!(pt, b"hunter2");
}
}

20
src/secrets/types.rs Normal file
View file

@ -0,0 +1,20 @@
//! Wire types for the Secret Service D-Bus API.
use serde::{Deserialize, Serialize};
use zbus::zvariant::{OwnedObjectPath, Type};
/// The `Secret` struct: `(oayays)`.
///
/// - `session`: the object path of the session this secret was encoded under.
/// - `parameters`: algorithm-specific parameters (the AES IV, for the
/// `dh-ietf1024-sha256-aes128-cbc-pkcs7` algorithm; empty for `plain`).
/// - `value`: the secret's bytes — ciphertext under AES, or plaintext under
/// `plain`.
/// - `content_type`: a MIME type, e.g. `text/plain`.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct Secret {
pub session: OwnedObjectPath,
pub parameters: Vec<u8>,
pub value: Vec<u8>,
pub content_type: String,
}