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:
parent
72b01457d2
commit
04a8390e8d
14 changed files with 1111 additions and 0 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
23
Cargo.toml
23
Cargo.toml
|
|
@ -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"]
|
||||
|
|
|
|||
117
src/bin/souveraine-secrets.rs
Normal file
117
src/bin/souveraine-secrets.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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
164
src/secrets/collection.rs
Normal 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
107
src/secrets/dh.rs
Normal 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) % ′
|
||||
|
||||
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
29
src/secrets/error.rs
Normal 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
132
src/secrets/item.rs
Normal 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
192
src/secrets/service.rs
Normal 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
68
src/secrets/session.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/secrets/session_object.rs
Normal file
31
src/secrets/session_object.rs
Normal 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
143
src/secrets/store.rs
Normal 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
46
src/secrets/transport.rs
Normal 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
20
src/secrets/types.rs
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue