use std::sync::Arc;
use gunnar_store::{Result as StoreResult, SecretStore};
use redb::ReadableTable;
use crate::error::Result;
const SECRETS: redb::TableDefinition<'static, &str, &[u8]> = redb::TableDefinition::new("secrets");
const TAG_VALUE: u8 = 0x01;
const TAG_TOMBSTONE: u8 = 0x00;
pub const LEGACY_SECRET_DIRS: &[&str] = &["gunnar-secrets", "gunnar-store"];
const LEGACY_SECRET_FILE: &str = "secrets.arrow";
fn faulted(what: &str, err: impl std::fmt::Display) -> gunnar_store::Error {
gunnar_store::Error::backend(format!("{what}: {err}"))
}
fn decode(key: &str, row: &[u8]) -> StoreResult<Option<Vec<u8>>> {
match row.split_first() {
Some((&TAG_VALUE, rest)) => Ok(Some(rest.to_vec())),
Some((&TAG_TOMBSTONE, _)) => Ok(None),
_ => Err(gunnar_store::Error::backend(format!(
"the control database's row for the secret {key} is {} bytes and does not begin with \
a known tag; refusing to read it as absent, because the caller's next move would be \
to mint a replacement",
row.len()
))),
}
}
fn encode(value: &[u8]) -> Vec<u8> {
let mut row = Vec::with_capacity(1 + value.len());
row.push(TAG_VALUE);
row.extend_from_slice(value);
row
}
#[derive(Clone)]
pub struct RedbSecretStore {
db: Arc<crate::control::ControlDb>,
}
impl std::fmt::Debug for RedbSecretStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("RedbSecretStore")
}
}
impl RedbSecretStore {
pub(crate) fn new(db: Arc<crate::control::ControlDb>) -> Self {
RedbSecretStore { db }
}
pub(crate) fn prepare(db: &redb::Database) -> Result<()> {
let txn = db.begin_write().map_err(|err| {
crate::Error::ControlStore(format!("beginning the secrets table's creation: {err}"))
})?;
txn.open_table(SECRETS).map_err(|err| {
crate::Error::ControlStore(format!("creating the secrets table: {err}"))
})?;
txn.commit().map_err(|err| {
crate::Error::ControlStore(format!("committing the secrets table's creation: {err}"))
})?;
Ok(())
}
pub(crate) fn decode_row_of(key: &str, row: &[u8]) -> StoreResult<Option<Vec<u8>>> {
decode(key, row)
}
pub(crate) fn rows(
txn: &redb::ReadTransaction,
) -> Result<std::collections::BTreeMap<String, Vec<u8>>> {
let table = txn.open_table(SECRETS).map_err(|err| {
crate::Error::ControlStore(format!("opening the secrets table: {err}"))
})?;
let mut out = std::collections::BTreeMap::new();
for entry in table.iter().map_err(|err| {
crate::Error::ControlStore(format!("scanning the secrets table: {err}"))
})? {
let (k, v) = entry.map_err(|err| {
crate::Error::ControlStore(format!("reading a secret row: {err}"))
})?;
out.insert(k.value().to_owned(), v.value().to_vec());
}
Ok(out)
}
fn write(&self, key: &str, row: &[u8]) -> StoreResult<()> {
self.db
.write("a secret write", |txn| {
{
let mut table = txn
.open_table(SECRETS)
.map_err(|err| faulted("opening the secrets table", err))?;
table
.insert(key, row)
.map_err(|err| faulted(&format!("storing the secret {key}"), err))?;
}
Ok(crate::control::Txn::Commit(()))
})
.map_err(off_stripe)
}
fn row(&self, key: &str) -> StoreResult<Option<Option<Vec<u8>>>> {
self.db
.read(
"a secret read",
|txn| {
let table = txn
.open_table(SECRETS)
.map_err(|err| faulted("opening the secrets table", err))?;
let found = table
.get(key)
.map_err(|err| faulted(&format!("reading the secret {key}"), err))?;
match found {
None => Ok(None),
Some(value) => decode(key, value.value()).map(Some).map_err(Into::into),
}
},
|ram| match ram.secret_row(key) {
None => Ok(None),
Some(row) => decode(key, row).map(Some).map_err(Into::into),
},
)
.map_err(off_stripe)
}
}
fn off_stripe(err: crate::Error) -> gunnar_store::Error {
match err {
crate::Error::Store(inner) => inner,
other => gunnar_store::Error::backend(other.to_string()),
}
}
impl SecretStore for RedbSecretStore {
fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
Ok(self.row(key)?.flatten())
}
fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
self.write(key, &encode(value))
}
fn delete(&self, key: &str) -> StoreResult<()> {
self.write(key, &[TAG_TOMBSTONE])
}
fn get_or_create(
&self,
key: &str,
mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
) -> StoreResult<Vec<u8>> {
if self.db.is_suspended() {
if let Some(bytes) = self.get(key)? {
return Ok(bytes);
}
return Err(off_stripe(crate::Error::OffStripe {
why: format!(
"{}; the secret {key} is not in the RAM copy and will not be minted off the stripe",
self.db.ram_copy().map(|r| r.why).unwrap_or_default()
),
}));
}
self.db
.write("a secret get-or-create", |txn| {
let created = {
let mut table = txn
.open_table(SECRETS)
.map_err(|err| faulted("opening the secrets table", err))?;
let existing = table
.get(key)
.map_err(|err| faulted(&format!("reading the secret {key}"), err))?
.map(|value| decode(key, value.value()))
.transpose()?
.flatten();
match existing {
Some(bytes) => {
drop(table);
return Ok(crate::control::Txn::Discard(bytes));
}
None => {
let value = mk()?;
table
.insert(key, encode(&value).as_slice())
.map_err(|err| {
faulted(&format!("storing the secret {key}"), err)
})?;
value
}
}
};
Ok(crate::control::Txn::Commit(created))
})
.map_err(off_stripe)
}
}
pub struct MigratingSecretStore {
new: RedbSecretStore,
legacy: Option<Arc<dyn SecretStore>>,
}
impl std::fmt::Debug for MigratingSecretStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MigratingSecretStore")
.field("legacy", &self.legacy.is_some())
.finish()
}
}
impl MigratingSecretStore {
pub fn new(new: RedbSecretStore, legacy: Option<Arc<dyn SecretStore>>) -> Self {
MigratingSecretStore { new, legacy }
}
fn legacy_get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
match &self.legacy {
None => Ok(None),
Some(store) => store.get(key),
}
}
}
impl SecretStore for MigratingSecretStore {
fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
if let Some(answer) = self.new.row(key)? {
return Ok(answer);
}
let Some(bytes) = self.legacy_get(key)? else {
return Ok(None);
};
tracing::info!(
key,
"adopting a secret out of the legacy Arrow log into the control database; the old \
log is left on disk untouched"
);
self.new.put(key, &bytes)?;
Ok(Some(bytes))
}
fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
self.new.put(key, value)
}
fn delete(&self, key: &str) -> StoreResult<()> {
self.new.delete(key)
}
fn get_or_create(
&self,
key: &str,
mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
) -> StoreResult<Vec<u8>> {
let mut adopted = false;
let value = self.new.get_or_create(key, &mut || {
if let Some(bytes) = self.legacy_get(key)? {
adopted = true;
return Ok(bytes);
}
mk()
})?;
if adopted {
tracing::info!(
key,
"carried a secret forward from the legacy Arrow log into the control database \
rather than minting a new one; the old log is left on disk untouched"
);
}
Ok(value)
}
}
pub fn legacy_secret_log(
control_dir: &std::path::Path,
hash_kind: gix_hash::Kind,
) -> Result<Option<Arc<dyn SecretStore>>> {
let Some(dir) = LEGACY_SECRET_DIRS
.iter()
.map(|name| control_dir.join(name))
.find(|dir| dir.join(LEGACY_SECRET_FILE).is_file())
else {
return Ok(None);
};
let log = crate::arrow_log::ArrowLogStore::open(&dir, hash_kind).map_err(|err| {
crate::Error::ControlStore(format!(
"opening the legacy secret log at {} to carry its secrets into the control database: \
{err}. It is read here and never written; if a previous gunnar was killed, its \
O_EXCL lock may still be there",
dir.display()
))
})?;
tracing::info!(
dir = %dir.display(),
"a legacy Arrow secret log is present; secrets not yet in the control database will be \
read from it once and carried forward"
);
Ok(Some(Arc::new(log.secrets()) as Arc<dyn SecretStore>))
}
#[cfg(test)]
mod tests {
use super::*;
use gunnar_store::mem::MemSecretStore;
use gunnar_store::SecretStoreExt;
fn store() -> RedbSecretStore {
let db = redb::Database::builder()
.create_with_backend(redb::backends::InMemoryBackend::new())
.expect("in-memory control database");
RedbSecretStore::prepare(&db).expect("prepare");
RedbSecretStore::new(Arc::new(crate::control::ControlDb::in_memory_for_tests(db)))
}
#[test]
fn a_secret_round_trips_and_an_unwritten_one_is_absent() {
let store = store();
assert_eq!(store.get("ssh/host_ed25519").expect("get"), None);
store.put("ssh/host_ed25519", b"key").expect("put");
assert_eq!(
store.get("ssh/host_ed25519").expect("get").as_deref(),
Some(&b"key"[..])
);
}
#[test]
fn a_deleted_secret_is_absent_and_its_bytes_are_not_the_row() {
let store = store();
store.put("k", b"secret-bytes").expect("put");
store.delete("k").expect("delete");
assert_eq!(store.get("k").expect("get"), None);
assert_eq!(store.row("k").expect("row"), Some(None));
store.delete("never-written").expect("delete absent");
}
#[test]
fn get_or_create_mints_once_and_then_returns_the_same_bytes() {
let store = store();
let mut calls = 0usize;
let first = store
.get_or_create("k", &mut || {
calls += 1;
Ok(b"minted".to_vec())
})
.expect("first");
let second = store
.get_or_create("k", &mut || {
calls += 1;
Ok(b"different".to_vec())
})
.expect("second");
assert_eq!(first, b"minted".to_vec());
assert_eq!(second, first, "a second call minted a new value");
assert_eq!(calls, 1);
}
#[test]
fn a_failed_mint_stores_nothing() {
let store = store();
let err = store
.get_or_create("k", &mut || {
Err(gunnar_store::Error::backend("the entropy source is gone"))
})
.expect_err("the mint failed");
assert!(format!("{err}").contains("entropy"), "{err}");
assert_eq!(store.get("k").expect("get"), None);
}
#[test]
fn a_legacy_host_key_is_carried_forward_rather_than_reminted() {
let legacy = Arc::new(MemSecretStore::new());
legacy
.put("ssh/host_ed25519", b"the-live-host-key")
.expect("seed");
let new = store();
let migrating = MigratingSecretStore::new(new.clone(), Some(legacy.clone()));
let got = migrating
.get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
.expect("first boot after the migration");
assert_eq!(
got,
b"the-live-host-key".to_vec(),
"the appliance minted a new host key instead of keeping the one it had"
);
assert_eq!(
new.get("ssh/host_ed25519").expect("get").as_deref(),
Some(&b"the-live-host-key"[..])
);
let without_legacy = MigratingSecretStore::new(new.clone(), None);
assert_eq!(
without_legacy
.get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
.expect("second boot"),
b"the-live-host-key".to_vec()
);
let fresh = MigratingSecretStore::new(store(), None);
assert_eq!(
fresh
.get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
.expect("fresh install"),
b"A-BRAND-NEW-KEY".to_vec()
);
}
#[test]
fn a_read_adopts_and_does_not_write_the_legacy_log() {
let legacy = Arc::new(MemSecretStore::new());
legacy.put("pki/issuer_key", b"issuer").expect("seed");
let new = store();
let migrating = MigratingSecretStore::new(new.clone(), Some(legacy.clone()));
assert_eq!(
migrating.get("pki/issuer_key").expect("get").as_deref(),
Some(&b"issuer"[..])
);
assert_eq!(
new.get("pki/issuer_key").expect("get").as_deref(),
Some(&b"issuer"[..]),
"the read did not adopt the value into the control database"
);
migrating.put("pki/issuer_key", b"rotated").expect("put");
assert_eq!(
legacy.get("pki/issuer_key").expect("get").as_deref(),
Some(&b"issuer"[..]),
"the legacy log was written; it must be left byte for byte as it was"
);
assert_eq!(
migrating.get("pki/issuer_key").expect("get").as_deref(),
Some(&b"rotated"[..])
);
}
#[test]
fn a_deleted_secret_does_not_come_back_out_of_the_legacy_log() {
let legacy = Arc::new(MemSecretStore::new());
legacy.put("k", b"old").expect("seed");
let migrating = MigratingSecretStore::new(store(), Some(legacy));
assert_eq!(
migrating.get("k").expect("get").as_deref(),
Some(&b"old"[..])
);
migrating.delete("k").expect("delete");
assert_eq!(
migrating.get("k").expect("get"),
None,
"a deleted secret was resurrected out of the legacy Arrow log"
);
}
#[test]
fn get_or_create_is_atomic_across_threads() {
let store = store();
let mints = std::sync::atomic::AtomicUsize::new(0);
let values: Vec<Vec<u8>> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..8)
.map(|_| {
scope.spawn(|| {
store
.get_or_create("ssh/host_ed25519", &mut || {
let n = mints.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(format!("key-{n}").into_bytes())
})
.expect("get_or_create")
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().expect("join"))
.collect()
});
assert_eq!(
mints.load(std::sync::atomic::Ordering::SeqCst),
1,
"the host key was minted more than once by a race"
);
assert!(
values.windows(2).all(|w| w[0] == w[1]),
"racing callers were handed different host keys"
);
}
#[test]
fn an_unreadable_row_is_a_fault_and_not_an_absence() {
let err = decode("ssh/host_ed25519", &[0x7f, 1, 2, 3]).expect_err("unknown tag");
assert!(format!("{err}").contains("known tag"), "{err}");
assert!(
decode("k", &[]).is_err(),
"an empty row must not read as absent"
);
}
}