lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 411130bc045b27b4bcdd2078fd45c366d050dac9
parent 44ce8f2acb9d46a5d8b0eb912d9e7047a542aad0
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 07:37:44 +0000

secrets: define secret IDs, references, and redacted values

- Validate portable secret identifiers and non-zero provider key versions.
- Model explicit backend ownership with single-owner secret references.
- Redact identifier diagnostics and reject ordinary handle clone or serialization.
- Verify behavior, serde validation, doctests, clippy, no_std, wasm, and architecture.

Diffstat:
MCargo.lock | 4++++
Mcrates/secrets/Cargo.toml | 11++++++++++-
Mcrates/secrets/src/error.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/id.rs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/lib.rs | 5+++++
Acrates/secrets/tests/id_contract.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/package_boundary.rs | 10+++++++---
7 files changed, 390 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4971,6 +4971,10 @@ dependencies = [ [[package]] name = "radroots_secrets" version = "0.1.0-alpha" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "radroots_signing" diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -18,10 +18,19 @@ name = "radroots_secrets" [features] default = ["std", "serde"] std = [] -serde = [] +serde = ["dep:serde"] memory = ["std"] file = ["std"] keyring = ["std"] +[dependencies] +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } + [lints] workspace = true diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs @@ -1 +1,64 @@ //! Normalized secret-operation errors. + +use core::fmt; + +/// Why a [`crate::SecretId`] failed validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SecretIdError { + /// The identifier was empty. + Empty, + /// The identifier exceeded the package limit. + TooLong { + /// Observed UTF-8 byte length. + actual_bytes: usize, + /// Maximum accepted UTF-8 byte length. + max_bytes: usize, + }, + /// The identifier contained a character outside its portable alphabet. + InvalidCharacter { + /// UTF-8 byte offset of the invalid character. + byte_offset: usize, + }, +} + +/// A normalized, secret-safe package failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// A secret identifier failed validation. + InvalidSecretId(SecretIdError), + /// Key versions start at one; zero is never a valid version. + InvalidKeyVersion, +} + +impl fmt::Display for SecretIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("secret identifier is empty"), + Self::TooLong { + actual_bytes, + max_bytes, + } => write!( + formatter, + "secret identifier is too long: {actual_bytes} bytes; maximum is {max_bytes}" + ), + Self::InvalidCharacter { byte_offset } => write!( + formatter, + "secret identifier contains an invalid character at byte offset {byte_offset}" + ), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSecretId(reason) => reason.fmt(formatter), + Self::InvalidKeyVersion => formatter.write_str("secret key version must be non-zero"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} diff --git a/crates/secrets/src/id.rs b/crates/secrets/src/id.rs @@ -1 +1,204 @@ //! Typed secret identifiers and references. + +use crate::error::{Error, SecretIdError}; +use alloc::string::{String, ToString}; +use core::fmt; +use core::num::NonZeroU32; +use core::str::FromStr; + +/// Maximum encoded length of a portable secret identifier. +pub const SECRET_ID_MAX_BYTES: usize = 128; + +/// A validated, backend-independent secret identifier. +/// +/// Identifier contents are available only through [`Self::as_str`]. Ordinary +/// display and debug formatting are intentionally redacted. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SecretId(String); + +impl SecretId { + /// Parses an identifier from the portable ASCII alphabet. + /// + /// The first character must be alphanumeric. Remaining characters may + /// additionally use `.`, `_`, `-`, and `:` separators. + pub fn parse(value: impl AsRef<str>) -> Result<Self, Error> { + let value = value.as_ref(); + if value.is_empty() { + return Err(Error::InvalidSecretId(SecretIdError::Empty)); + } + if value.len() > SECRET_ID_MAX_BYTES { + return Err(Error::InvalidSecretId(SecretIdError::TooLong { + actual_bytes: value.len(), + max_bytes: SECRET_ID_MAX_BYTES, + })); + } + for (byte_offset, character) in value.char_indices() { + let valid = character.is_ascii_alphanumeric() + || (byte_offset > 0 && matches!(character, '.' | '_' | '-' | ':')); + if !valid { + return Err(Error::InvalidSecretId(SecretIdError::InvalidCharacter { + byte_offset, + })); + } + } + Ok(Self(value.to_string())) + } + + /// Returns the validated identifier for explicit backend use. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Debug for SecretId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretId(<redacted>)") + } +} + +impl fmt::Display for SecretId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("<redacted secret id>") + } +} + +impl FromStr for SecretId { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SecretId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SecretId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// A provider-owned key revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeyVersion(NonZeroU32); + +impl KeyVersion { + /// Creates a non-zero key version. + pub const fn new(value: u32) -> Result<Self, Error> { + match NonZeroU32::new(value) { + Some(value) => Ok(Self(value)), + None => Err(Error::InvalidKeyVersion), + } + } + + /// Returns the numeric version. + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} + +/// The explicit adapter family that owns a secret reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum BackendKind { + /// Deterministic in-process storage selected by the host. + Memory, + /// Explicit file-backed storage selected by the host. + File, + /// Operating-system keyring storage selected by the host. + Keyring, + /// A host-provided implementation outside the built-in adapters. + External, +} + +/// A single-owner capability handle for a secret held by a provider. +/// +/// Cloning and ordinary serialization are intentionally unavailable. Debug +/// output never reveals the identifier. +/// +/// ```compile_fail +/// use radroots_secrets::{SecretId, SecretRef}; +/// use radroots_secrets::id::{BackendKind, KeyVersion}; +/// +/// let reference = SecretRef::new( +/// SecretId::parse("account-signing-key")?, +/// BackendKind::Memory, +/// KeyVersion::new(1)?, +/// ); +/// let _duplicate = reference.clone(); +/// # Ok::<(), radroots_secrets::Error>(()) +/// ``` +/// +/// ```compile_fail +/// use radroots_secrets::{SecretId, SecretRef}; +/// use radroots_secrets::id::{BackendKind, KeyVersion}; +/// +/// let reference = SecretRef::new( +/// SecretId::parse("account-signing-key")?, +/// BackendKind::Memory, +/// KeyVersion::new(1)?, +/// ); +/// let _json = serde_json::to_string(&reference)?; +/// # Ok::<(), Box<dyn std::error::Error>>(()) +/// ``` +pub struct SecretRef { + id: SecretId, + backend: BackendKind, + key_version: KeyVersion, +} + +impl SecretRef { + /// Creates a capability reference from validated metadata. + #[must_use] + pub const fn new(id: SecretId, backend: BackendKind, key_version: KeyVersion) -> Self { + Self { + id, + backend, + key_version, + } + } + + /// Returns the validated provider-local identifier. + #[must_use] + pub const fn id(&self) -> &SecretId { + &self.id + } + + /// Returns the adapter family that owns the secret. + #[must_use] + pub const fn backend(&self) -> BackendKind { + self.backend + } + + /// Returns the expected provider key version. + #[must_use] + pub const fn key_version(&self) -> KeyVersion { + self.key_version + } +} + +impl fmt::Debug for SecretRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretRef") + .field("id", &"<redacted>") + .field("backend", &self.backend) + .field("key_version", &self.key_version) + .finish() + } +} diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs @@ -2,6 +2,8 @@ #![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + pub mod envelope; pub mod error; #[cfg(feature = "file")] @@ -13,3 +15,6 @@ pub mod keyring; pub mod memory; pub mod provider; pub mod wrapping; + +pub use error::Error; +pub use id::{SecretId, SecretRef}; diff --git a/crates/secrets/tests/id_contract.rs b/crates/secrets/tests/id_contract.rs @@ -0,0 +1,98 @@ +use radroots_secrets::error::SecretIdError; +use radroots_secrets::id::{BackendKind, KeyVersion, SECRET_ID_MAX_BYTES}; +use radroots_secrets::{Error, SecretId, SecretRef}; + +#[test] +fn identifiers_accept_the_portable_alphabet_and_reject_unsafe_values() { + for valid in ["a", "account-01", "farm.primary_key:v2", "A_B"] { + assert_eq!(SecretId::parse(valid).expect("valid id").as_str(), valid); + } + + assert_eq!( + SecretId::parse(""), + Err(Error::InvalidSecretId(SecretIdError::Empty)) + ); + assert_eq!( + SecretId::parse("-leading-separator"), + Err(Error::InvalidSecretId(SecretIdError::InvalidCharacter { + byte_offset: 0 + })) + ); + assert_eq!( + SecretId::parse("path/traversal"), + Err(Error::InvalidSecretId(SecretIdError::InvalidCharacter { + byte_offset: 4 + })) + ); + assert_eq!( + SecretId::parse("é"), + Err(Error::InvalidSecretId(SecretIdError::InvalidCharacter { + byte_offset: 0 + })) + ); + assert_eq!( + SecretId::parse("a".repeat(SECRET_ID_MAX_BYTES + 1)), + Err(Error::InvalidSecretId(SecretIdError::TooLong { + actual_bytes: SECRET_ID_MAX_BYTES + 1, + max_bytes: SECRET_ID_MAX_BYTES, + })) + ); +} + +#[test] +fn identifiers_and_references_are_redacted_in_diagnostics() { + let id = SecretId::parse("account-signing-key").expect("valid id"); + assert_eq!(format!("{id:?}"), "SecretId(<redacted>)"); + assert_eq!(id.to_string(), "<redacted secret id>"); + + let reference = SecretRef::new( + id, + BackendKind::Keyring, + KeyVersion::new(7).expect("non-zero version"), + ); + let diagnostic = format!("{reference:?}"); + assert!(diagnostic.contains("<redacted>")); + assert!(diagnostic.contains("Keyring")); + assert!(diagnostic.contains("KeyVersion(7)")); + assert!(!diagnostic.contains("account-signing-key")); +} + +#[test] +fn references_expose_only_validated_metadata() { + assert_eq!(KeyVersion::new(0), Err(Error::InvalidKeyVersion)); + let version = KeyVersion::new(3).expect("non-zero version"); + let reference = SecretRef::new( + SecretId::parse("service-token").expect("valid id"), + BackendKind::External, + version, + ); + + assert_eq!(reference.id().as_str(), "service-token"); + assert_eq!(reference.backend(), BackendKind::External); + assert_eq!(reference.key_version().get(), 3); +} + +#[cfg(feature = "serde")] +#[test] +fn identifier_serde_round_trips_through_validation() { + let id = SecretId::parse("farm.primary-key:v1").expect("valid id"); + let json = serde_json::to_string(&id).expect("serialize id"); + assert_eq!(json, "\"farm.primary-key:v1\""); + assert_eq!( + serde_json::from_str::<SecretId>(&json) + .expect("deserialize id") + .as_str(), + id.as_str() + ); + assert!(serde_json::from_str::<SecretId>("\"../escape\"").is_err()); +} + +#[test] +fn normalized_errors_never_echo_identifier_input() { + let raw = "secret/value"; + let error = SecretId::parse(raw).expect_err("invalid id"); + let display = error.to_string(); + let debug = format!("{error:?}"); + assert!(!display.contains(raw)); + assert!(!debug.contains(raw)); +} diff --git a/crates/secrets/tests/package_boundary.rs b/crates/secrets/tests/package_boundary.rs @@ -43,11 +43,15 @@ fn crate_root_contains_only_the_approved_module_skeleton() { "envelope", "error", "file", "id", "keyring", "memory", "provider", "wrapping", ]) ); - assert!( + assert_eq!( ROOT.lines() .map(str::trim) - .all(|line| !line.starts_with("pub use ")), - "behavioral root exports belong to later checkpoints" + .filter(|line| line.starts_with("pub use ")) + .collect::<BTreeSet<_>>(), + BTreeSet::from([ + "pub use error::Error;", + "pub use id::{SecretId, SecretRef};" + ]) ); }