#![forbid(unsafe_code)]
pub mod audit;
mod rotate;
mod store;
use std::fmt;
use thiserror::Error;
use zeroize::Zeroizing;
pub use audit::{Anchor, ChainAudit, chain_sink, verify};
pub use rotate::{RewrapReport, RotationReport};
pub use store::{HarnessOnly, SecretStore, Secrets};
pub const SQLITE_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
cratefield_core::SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
),
cratefield_core::SqlMigration::new(
"0002",
"audit",
include_str!("../migrations/sqlite/0002_audit.sql"),
),
cratefield_core::SqlMigration::new(
"0003",
"audit-store",
include_str!("../migrations/sqlite/0003_audit_store.sql"),
),
cratefield_core::SqlMigration::new(
"0004",
"store-attribution",
include_str!("../migrations/sqlite/0004_store_attribution.sql"),
),
];
const _: () = cratefield_core::assert_migration_set(&SQLITE_MIGRATIONS);
pub const POSTGRES_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
cratefield_core::SqlMigration::new(
"0001",
"init",
include_str!("../migrations/postgres/0001_init.sql"),
),
cratefield_core::SqlMigration::new(
"0002",
"audit",
include_str!("../migrations/postgres/0002_audit.sql"),
),
cratefield_core::SqlMigration::new(
"0003",
"audit-store",
include_str!("../migrations/postgres/0003_audit_store.sql"),
),
cratefield_core::SqlMigration::new(
"0004",
"store-attribution",
include_str!("../migrations/postgres/0004_store_attribution.sql"),
),
];
const _: () = cratefield_core::assert_migration_set(&POSTGRES_MIGRATIONS);
#[must_use]
pub fn migrations() -> cratefield_core::Migrations {
cratefield_core::Migrations {
sqlite: &SQLITE_MIGRATIONS,
postgres: &POSTGRES_MIGRATIONS,
}
}
pub const CIPHER: &str = "xchacha20poly1305";
pub(crate) fn scoped_store() -> &'static str {
"store = ?"
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreId {
Global,
Tenant(String),
}
impl StoreId {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
StoreId::Global => "global",
StoreId::Tenant(id) => id,
}
}
}
impl fmt::Display for StoreId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actor(String);
impl Actor {
pub fn new(who: impl Into<String>) -> Result<Self, SecretsError> {
let who = who.into();
if who.trim().is_empty() {
return Err(SecretsError::Invalid(
"an actor cannot be empty: every access is attributed".to_owned(),
));
}
Ok(Self(who))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
pub struct SecretBytes(Zeroizing<Vec<u8>>);
impl SecretBytes {
#[must_use]
pub fn new(bytes: Vec<u8>) -> Self {
Self(Zeroizing::new(bytes))
}
#[must_use]
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_str(&self) -> Result<&str, SecretsError> {
std::str::from_utf8(&self.0)
.map_err(|_| SecretsError::Invalid("this secret is not UTF-8".to_owned()))
}
}
impl From<&str> for SecretBytes {
fn from(value: &str) -> Self {
Self::new(value.as_bytes().to_vec())
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretBytes([redacted])")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretMeta {
pub name: String,
pub version: u32,
pub created_at: String,
pub created_by: String,
pub deleted: bool,
}
pub type Version = u32;
#[derive(Debug, Error)]
pub enum SecretsError {
#[error("the key could not be unwrapped: {0}")]
Kms(#[from] cratefield_kms::KmsError),
#[error("the secrets database: {0}")]
Database(#[from] cratefield_core::DbError),
#[error(
"secret `{name}` version {version} did not decrypt in store `{store}`: the row does not \
match the context it was sealed with (moved, renamed, rolled back, or altered)"
)]
NotAuthentic {
store: String,
name: String,
version: Version,
},
#[error("store `{0}` has no usable data key: nothing in it can be decrypted")]
NoKey(String),
#[error("invalid input: {0}")]
Invalid(String),
#[error("the access could not be audited, so it was refused: {0}")]
NotAudited(String),
#[error("the audit chain for store `{store}` breaks at seq {seq}: {detail}")]
ChainBroken {
store: String,
seq: i64,
detail: String,
},
}
#[async_trait::async_trait]
pub trait Audit: Send + Sync {
async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Put,
Get,
List,
Delete,
RotateDek,
Rewrap,
}
impl Access {
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Some(match text {
"put" => Access::Put,
"get" => Access::Get,
"list" => Access::List,
"delete" => Access::Delete,
"rotate_dek" => Access::RotateDek,
"rewrap" => Access::Rewrap,
_ => return None,
})
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Access::Put => "put",
Access::Get => "get",
Access::List => "list",
Access::Delete => "delete",
Access::RotateDek => "rotate_dek",
Access::Rewrap => "rewrap",
}
}
}
#[derive(Debug)]
pub struct AuditEvent<'a> {
pub store: &'a StoreId,
pub access: Access,
pub actor: &'a Actor,
pub name: Option<&'a str>,
pub version: Option<Version>,
pub allowed: bool,
pub request_id: Option<&'a str>,
}
pub struct TracingAudit;
#[async_trait::async_trait]
impl Audit for TracingAudit {
async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError> {
tracing::info!(
store = %event.store,
access = event.access.as_str(),
actor = event.actor.as_str(),
name = event.name.unwrap_or("-"),
version = event.version.unwrap_or(0),
allowed = event.allowed,
"secret access"
);
cratefield_core::forward_control_event(
cratefield_core::ControlLevel::Info,
&format!(
"secret access: store {} {} actor {} name {} version {} allowed {}",
event.store,
event.access.as_str(),
event.actor.as_str(),
event.name.unwrap_or("-"),
event.version.unwrap_or(0),
event.allowed
),
);
Ok(())
}
}
#[must_use]
pub fn aad(store: &StoreId, name: &str, version: Version, key_id: &str) -> Vec<u8> {
fn push(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&u32::try_from(bytes.len()).unwrap_or(u32::MAX).to_le_bytes());
out.extend_from_slice(bytes);
}
let mut out = Vec::with_capacity(64);
out.extend_from_slice(b"FZ-SECRETS-AAD-v1");
push(&mut out, store.as_str().as_bytes());
push(&mut out, name.as_bytes());
push(&mut out, &version.to_le_bytes());
push(&mut out, key_id.as_bytes());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_secret_does_not_print_itself() {
let secret = SecretBytes::from("sk_live_do_not_log_me");
let shown = format!("{secret:?}");
assert_eq!(shown, "SecretBytes([redacted])");
assert!(!shown.contains("sk_live"), "{shown}");
}
#[test]
fn an_actor_is_never_empty() {
assert!(Actor::new("").is_err());
assert!(Actor::new(" ").is_err());
assert_eq!(Actor::new("waitlist").expect("ok").as_str(), "waitlist");
}
#[test]
fn the_aad_is_injective_across_every_field() {
let base = aad(&StoreId::Global, "resend/api_key", 1, "k1");
let variants = [
aad(&StoreId::Tenant("t1".into()), "resend/api_key", 1, "k1"),
aad(&StoreId::Global, "resend/api_keys", 1, "k1"),
aad(&StoreId::Global, "resend/api_key", 2, "k1"),
aad(&StoreId::Global, "resend/api_key", 1, "k2"),
];
for variant in &variants {
assert_ne!(&base, variant, "two contexts produced the same AAD");
}
assert_eq!(base, aad(&StoreId::Global, "resend/api_key", 1, "k1"));
}
#[test]
fn field_boundaries_cannot_be_slid() {
let left = aad(&StoreId::Tenant("ab".into()), "c", 1, "k");
let right = aad(&StoreId::Tenant("a".into()), "bc", 1, "k");
assert_ne!(left, right);
}
}