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:
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};"
+ ])
);
}