commit 0d5cba8382782a845fe0789ced633499d72f468a
parent 909453b31c95972db0503a498f083e349fdfe173
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 21:30:45 +0000
rhi: resolve canonical wrapping credential
Diffstat:
14 files changed, 812 insertions(+), 961 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -183,6 +183,11 @@
wrapping credential. Do not add plaintext or adjacent keys, implicit identity,
or ordinary-run generation/replacement. Validate expected public-key and
policy bindings before readiness.
+- Resolve the wrapping credential only from the validated fixed artifact name
+ beneath the same instance's canonical secrets root. The resolver is
+ read-existing-only, accepts no caller path or bytes, and supports only
+ service-host and repo-local profiles. The governed envelope and credential
+ are excluded from state backups.
- Do not read, reseal, import, or migrate prototype or legacy identity-envelope
formats. Missing, wrong, legacy, or misbound envelopes and wrapping
credentials fail closed.
@@ -254,8 +259,8 @@
sensitive evidence, private identifiers, paths, upstream errors, and
equivalent protected material out of config, logs, status, metrics, audit,
fixtures, packages, process arguments, environment contracts, error strings,
- and backups. A governed backup may contain the encrypted identity envelope
- but never the material needed to unwrap it.
+ and backups. Governed state backups contain neither the encrypted identity
+ envelope nor any material needed to unwrap it.
- Backup and restore must preserve writer-lock, manifest, digest, integrity,
schema, service, instance, identity, policy, permission, fsync, atomic-rename,
and protected-material-exclusion invariants. Orphaned or impossible state
diff --git a/Cargo.lock b/Cargo.lock
@@ -408,7 +408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
"zeroize",
]
@@ -420,7 +420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
- "rand_core 0.6.4",
+ "rand_core",
"typenum",
]
@@ -488,7 +488,7 @@ dependencies = [
"ff",
"generic-array",
"group",
- "rand_core 0.6.4",
+ "rand_core",
"sec1",
"subtle",
"zeroize",
@@ -552,7 +552,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -782,7 +782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -1427,7 +1427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core 0.6.4",
+ "rand_core",
"subtle",
]
@@ -1721,18 +1721,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
-dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.5",
+ "rand_chacha",
+ "rand_core",
]
[[package]]
@@ -1742,17 +1732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
+ "rand_core",
]
[[package]]
@@ -1765,15 +1745,6 @@ dependencies = [
]
[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
-[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1859,7 +1830,6 @@ dependencies = [
"nostr",
"radroots_event",
"radroots_event_codec",
- "radroots_identity",
"radroots_nostr",
"radroots_protocol",
"radroots_runtime_paths",
@@ -1868,7 +1838,6 @@ dependencies = [
"radroots_service_sqlite",
"radroots_storage",
"radroots_trade",
- "rand 0.9.2",
"rustix",
"serde",
"serde_json",
@@ -1958,7 +1927,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
- "rand 0.8.5",
+ "rand",
"secp256k1-sys",
"serde",
]
diff --git a/Cargo.toml b/Cargo.toml
@@ -45,7 +45,6 @@ workspace = true
[dependencies]
radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", features = ["serde"] }
radroots_event_codec = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", features = ["json"] }
-radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" }
radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", features = ["events"] }
radroots_protocol = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" }
radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" }
@@ -59,8 +58,7 @@ chacha20poly1305 = { version = "0.10" }
clap = { version = "4", features = ["derive"] }
futures-executor = { version = "0.3" }
jsonschema = { version = "0.48.1", default-features = false }
-nostr = { version = "0.44.7", features = ["nip49"] }
-rand = { version = "0.9" }
+nostr = { version = "0.44.7" }
rustix = { version = "1", features = ["fs", "process", "std"] }
serde = { version = "1", default-features = false }
serde_json = { version = "1", default-features = false }
diff --git a/README b/README
@@ -63,8 +63,8 @@ artifact plus governed configuration apply.
One validated CLI invocation resolves through `RhiRuntimeContext`, which owns
the shared typed `RuntimeContext`, exact common artifacts, the validated
-`service.identity.ncrypt` credential path, and the explicit or canonical
-configuration selection. The service identity is fixed to `rhi`; the instance
+`service.identity.ncrypt` encrypted identity artifact path, and the explicit or
+canonical configuration selection. The service identity is fixed to `rhi`; the instance
comes only from `InstanceId`; profile and repo-local-root provenance are the
closed `bootstrap_cli` vocabulary. Callers cannot construct or mutate another
path set, override artifact names, or obtain a public path report.
@@ -121,6 +121,15 @@ create-new and caller-supplied; ordinary startup never generates an identity,
legacy envelopes are rejected, and RHI state backups contain no envelope,
wrapping credential, or plaintext identity.
+The separately governed wrapping-credential resolver derives exactly one
+validated artifact name from the admitted identity binding and resolves it only
+beneath the same runtime context's canonical instance secrets root. It accepts
+only an existing 32-byte, owner-controlled, single-link regular file for
+service-host and repo-local profiles. It never accepts caller paths or bytes,
+creates credentials or directories, consults environment or process arguments,
+or falls back to an adjacent envelope sibling. The former prototype identity
+and adjacent-key storage APIs are not part of the crate surface.
+
Validate the standalone crate through extbuild:
```text
diff --git a/contracts/services_hardening/wrapping_credential_resolution.v1.json b/contracts/services_hardening/wrapping_credential_resolution.v1.json
@@ -0,0 +1,41 @@
+{
+ "schema": "radroots.rhi.wrapping-credential-resolution",
+ "schema_version": 1,
+ "contract_version": 1,
+ "artifact_name": {
+ "shared_type": "ServiceCredentialArtifactName",
+ "maximum_utf8_bytes": 128
+ },
+ "artifact_path": "<canonical_instance_secrets>/<validated_credential_name>",
+ "artifact": {
+ "wire": "raw_32_bytes",
+ "exact_bytes": 32,
+ "symlink_follow": false,
+ "regular_file": true,
+ "single_link": true,
+ "owner_uid": "effective_uid",
+ "read_modes_octal": ["0400", "0600"],
+ "secrets_root_modes_octal": ["0500", "0700"]
+ },
+ "profiles": {
+ "service_host": "existing_injected_or_mounted",
+ "repo_local": "existing_offline_provisioned",
+ "interactive": "unsupported"
+ },
+ "resolution": {
+ "read_existing_only": true,
+ "creates_credential": false,
+ "creates_parent": false,
+ "caller_supplies_path": false,
+ "caller_supplies_bytes": false,
+ "ordinary_run_generates": false
+ },
+ "forbidden_sources": [
+ "toml_secret",
+ "environment_secret",
+ "process_argument_secret",
+ "adjacent_envelope_sibling",
+ "implicit_fallback"
+ ],
+ "backup_included": false
+}
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2"
workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
version = "0.1.0-alpha"
source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0"
-cargo_lock_sha256 = "285a66e7c07092bd6ebebf3260c25bd037513d36f4fa8b3eb870a05c7e6c836e"
+cargo_lock_sha256 = "acf51e7848c64d0361b5d00f5035edc93daa6d3b3f73256ba3718c6b4f498db9"
rust_version = "1.97.1"
host_feature_profile = "service-host"
diff --git a/src/host_identity.rs b/src/host_identity.rs
@@ -1,327 +0,0 @@
-//! Myc-owned secret identity container.
-//!
-//! `radroots_identity` deliberately exposes only public, transport-neutral
-//! values. This host-private type keeps service key custody and the legacy
-//! Nostr-facing profile payload inside Myc.
-
-use std::fs;
-use std::path::{Path, PathBuf};
-
-use nostr::nips::nip19::ToBech32;
-use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
-use nostr::{Keys, SecretKey};
-use serde::{Deserialize, Serialize};
-use thiserror::Error;
-
-#[derive(Debug, Error)]
-pub enum IdentityError {
- #[error("identity file missing at {0}")]
- NotFound(PathBuf),
- #[error("identity generation is not permitted for {0}")]
- GenerationNotAllowed(PathBuf),
- #[error("failed to read identity file at {0}")]
- Read(PathBuf, #[source] std::io::Error),
- #[error("failed to create identity directory {0}")]
- CreateDir(PathBuf, #[source] std::io::Error),
- #[error("failed to write identity file at {0}")]
- Write(PathBuf, #[source] std::io::Error),
- #[error("invalid identity JSON")]
- InvalidJson(#[from] serde_json::Error),
- #[error("invalid secret key")]
- InvalidSecretKey(#[from] nostr::key::Error),
- #[error("invalid public key")]
- InvalidPublicKey,
- #[error("public key does not match secret key")]
- PublicKeyMismatch,
- #[error("invalid encrypted secret key")]
- InvalidEncryptedSecretKey,
- #[error("failed to encrypt secret key")]
- EncryptSecretKey,
- #[error("failed to decrypt encrypted secret key")]
- DecryptEncryptedSecretKey,
- #[error("unsupported identity file format")]
- InvalidIdentityFormat,
- #[error("protected identity storage error at {path}: {message}")]
- ProtectedStorage { path: PathBuf, message: String },
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(transparent)]
-pub struct RadrootsIdentityId(String);
-
-impl RadrootsIdentityId {
- pub fn from_public_key(public_key: nostr::PublicKey) -> Result<Self, IdentityError> {
- let key = radroots_nostr::key::public_key_from_nostr(public_key)
- .map_err(|_| IdentityError::InvalidPublicKey)?;
- Ok(Self(
- radroots_identity::IdentityId::from_public_key(key).to_hex(),
- ))
- }
-
- pub fn parse(value: &str) -> Result<Self, IdentityError> {
- radroots_identity::IdentityId::from_hex(value)
- .map(|identity_id| Self(identity_id.to_hex()))
- .map_err(|_| IdentityError::InvalidPublicKey)
- }
-
- pub fn as_str(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn into_string(self) -> String {
- self.0
- }
-
- pub fn to_final(&self) -> radroots_identity::IdentityId {
- radroots_identity::IdentityId::from_hex(self.0.as_str())
- .expect("host identity ids are constructed from validated keys")
- }
-}
-
-impl std::fmt::Display for RadrootsIdentityId {
- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.0.fmt(formatter)
- }
-}
-
-impl From<radroots_identity::PublicKey> for RadrootsIdentityId {
- fn from(public_key: radroots_identity::PublicKey) -> Self {
- Self(radroots_identity::IdentityId::from_public_key(public_key).to_hex())
- }
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityProfile {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub identifier: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub metadata: Option<nostr::Event>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub application_handler: Option<nostr::Event>,
-}
-
-impl RadrootsIdentityProfile {
- pub fn is_empty(&self) -> bool {
- self.identifier.is_none() && self.metadata.is_none() && self.application_handler.is_none()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityPublic {
- pub id: RadrootsIdentityId,
- pub public_key_hex: String,
- pub public_key_npub: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub profile: Option<RadrootsIdentityProfile>,
-}
-
-impl PartialEq for RadrootsIdentityPublic {
- fn eq(&self, other: &Self) -> bool {
- self.id == other.id
- && self.public_key_hex == other.public_key_hex
- && self.profile == other.profile
- }
-}
-
-impl Eq for RadrootsIdentityPublic {}
-
-impl RadrootsIdentityPublic {
- pub fn new(public_key: nostr::PublicKey) -> Result<Self, IdentityError> {
- Ok(Self {
- id: RadrootsIdentityId::from_public_key(public_key)?,
- public_key_hex: public_key.to_hex(),
- public_key_npub: public_key
- .to_bech32()
- .expect("validated Nostr public keys encode as npub"),
- profile: None,
- })
- }
-
- pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self {
- self.profile = (!profile.is_empty()).then_some(profile);
- self
- }
-
- pub fn from_final_public_key(
- public_key: radroots_identity::PublicKey,
- ) -> Result<Self, IdentityError> {
- let public_key = radroots_nostr::key::public_key_to_nostr(public_key)
- .map_err(|_| IdentityError::InvalidPublicKey)?;
- Self::new(public_key)
- }
-
- pub fn id(&self) -> &RadrootsIdentityId {
- &self.id
- }
-
- pub fn public_key(&self) -> radroots_identity::PublicKey {
- radroots_identity::PublicKey::from_hex(self.public_key_hex.as_str())
- .expect("host public identities are constructed from validated keys")
- }
-
- pub fn to_final(&self) -> radroots_identity::PublicIdentity {
- radroots_identity::PublicIdentity::new(self.public_key())
- }
-
- pub fn account_id(&self) -> radroots_identity::AccountId {
- self.id.to_final().into()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(deny_unknown_fields)]
-pub struct RadrootsIdentityFile {
- pub secret_key: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub public_key: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub identifier: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub metadata: Option<nostr::Event>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub application_handler: Option<nostr::Event>,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsIdentity {
- keys: Keys,
- profile: Option<RadrootsIdentityProfile>,
-}
-
-impl RadrootsIdentity {
- pub fn new(keys: Keys) -> Self {
- Self {
- keys,
- profile: None,
- }
- }
-
- pub fn generate() -> Self {
- Self::new(Keys::generate())
- }
-
- pub fn from_secret_key_str(value: &str) -> Result<Self, IdentityError> {
- let secret = SecretKey::parse(value)?;
- Ok(Self::new(Keys::new(secret)))
- }
-
- pub fn from_encrypted_secret_key_str(
- payload: &str,
- password: &str,
- ) -> Result<Self, IdentityError> {
- use nostr::nips::nip19::FromBech32;
- let encrypted = EncryptedSecretKey::from_bech32(payload)
- .map_err(|_| IdentityError::InvalidEncryptedSecretKey)?;
- let secret = encrypted
- .decrypt(password)
- .map_err(|_| IdentityError::DecryptEncryptedSecretKey)?;
- Ok(Self::new(Keys::new(secret)))
- }
-
- pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> {
- let encrypted =
- EncryptedSecretKey::new(self.keys.secret_key(), password, 16, KeySecurity::Unknown)
- .map_err(|_| IdentityError::EncryptSecretKey)?;
- encrypted
- .to_bech32()
- .map_err(|_| IdentityError::EncryptSecretKey)
- }
-
- pub fn keys(&self) -> &Keys {
- &self.keys
- }
-
- pub fn public_key(&self) -> nostr::PublicKey {
- self.keys.public_key()
- }
-
- pub fn final_public_key(&self) -> radroots_identity::PublicKey {
- radroots_nostr::key::public_key_from_nostr(self.public_key())
- .expect("identity keys always contain a valid public key")
- }
-
- pub fn id(&self) -> RadrootsIdentityId {
- RadrootsIdentityId::from_public_key(self.public_key())
- .expect("identity keys always contain a valid public key")
- }
-
- pub fn public_key_hex(&self) -> String {
- self.public_key().to_hex()
- }
-
- pub fn secret_key_hex(&self) -> String {
- self.keys.secret_key().to_secret_hex()
- }
-
- pub fn profile(&self) -> Option<&RadrootsIdentityProfile> {
- self.profile.as_ref()
- }
-
- pub fn set_profile(&mut self, profile: RadrootsIdentityProfile) {
- self.profile = (!profile.is_empty()).then_some(profile);
- }
-
- pub fn to_public(&self) -> RadrootsIdentityPublic {
- let mut public = RadrootsIdentityPublic::new(self.public_key())
- .expect("identity keys always contain a valid public key");
- public.profile = self.profile.clone();
- public
- }
-
- pub fn to_file(&self) -> RadrootsIdentityFile {
- let profile = self.profile.clone().unwrap_or_default();
- RadrootsIdentityFile {
- secret_key: self.secret_key_hex(),
- public_key: Some(self.public_key_hex()),
- identifier: profile.identifier,
- metadata: profile.metadata,
- application_handler: profile.application_handler,
- }
- }
-
- pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> {
- let path = path.as_ref();
- if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) {
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- }
- fs::write(path, serde_json::to_vec_pretty(&self.to_file())?)
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))
- }
-
- pub fn load_from_path_auto(path: impl AsRef<Path>) -> Result<Self, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- let file: RadrootsIdentityFile = serde_json::from_slice(encoded.as_slice())?;
- Self::try_from(file)
- }
-}
-
-impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity {
- type Error = IdentityError;
-
- fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> {
- let mut identity = Self::from_secret_key_str(file.secret_key.as_str())?;
- if file
- .public_key
- .as_deref()
- .is_some_and(|public| public != identity.public_key_hex())
- {
- return Err(IdentityError::PublicKeyMismatch);
- }
- identity.set_profile(RadrootsIdentityProfile {
- identifier: file.identifier,
- metadata: file.metadata,
- application_handler: file.application_handler,
- });
- Ok(identity)
- }
-}
diff --git a/src/identity_credential.rs b/src/identity_credential.rs
@@ -0,0 +1,466 @@
+//! Canonical wrapping-credential artifact resolution.
+
+use core::fmt;
+use std::error::Error;
+
+use radroots_runtime_paths::{ServiceCredentialArtifactName, service_credential_artifact_path};
+
+use crate::{
+ RhiBootstrapProfileV1, RhiEncryptedIdentityEnvelopeErrorKind, RhiIdentityEnvelopeBinding,
+ RhiIdentityProviderKind, RhiRuntimeContext, RhiWrappingCredential,
+ identity_envelope::load_resolved_wrapping_credential,
+};
+
+/// Exact fixed wrapping-credential artifact length.
+pub const RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize = 32;
+/// Exact Rhi wrapping-credential resolution contract version.
+pub const RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 = 1;
+
+/// Stable source-free credential-resolution failure classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiCredentialResolutionErrorKind {
+ InvalidBinding,
+ UnsupportedProfile,
+ InvalidReference,
+ MissingCredential,
+ InsecureSecretsRoot,
+ InsecureCredential,
+ InvalidCredential,
+ Io,
+ UnsupportedPlatform,
+}
+
+impl RhiCredentialResolutionErrorKind {
+ /// Returns the stable machine-facing safe code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidBinding => "provider_credential_binding_invalid",
+ Self::UnsupportedProfile => "provider_credential_profile_unsupported",
+ Self::InvalidReference => "provider_credential_reference_invalid",
+ Self::MissingCredential => "provider_credential_missing",
+ Self::InsecureSecretsRoot => "provider_credential_root_insecure",
+ Self::InsecureCredential => "provider_credential_artifact_insecure",
+ Self::InvalidCredential => "provider_credential_material_invalid",
+ Self::Io => "provider_credential_io_failed",
+ Self::UnsupportedPlatform => "provider_credential_platform_unsupported",
+ }
+ }
+
+ const fn message(self) -> &'static str {
+ match self {
+ Self::InvalidBinding => "provider credential binding is invalid",
+ Self::UnsupportedProfile => "provider credential profile is unsupported",
+ Self::InvalidReference => "provider credential reference is invalid",
+ Self::MissingCredential => "provider credential is missing",
+ Self::InsecureSecretsRoot => "provider credential root is insecure",
+ Self::InsecureCredential => "provider credential artifact is insecure",
+ Self::InvalidCredential => "provider credential material is invalid",
+ Self::Io => "provider credential storage failed",
+ Self::UnsupportedPlatform => "provider credential storage is unsupported",
+ }
+ }
+}
+
+/// One source-free wrapping-credential resolution failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiCredentialResolutionError {
+ kind: RhiCredentialResolutionErrorKind,
+}
+
+impl RhiCredentialResolutionError {
+ /// Returns the stable failure kind.
+ #[must_use]
+ pub const fn kind(self) -> RhiCredentialResolutionErrorKind {
+ self.kind
+ }
+
+ /// Returns the stable machine-facing safe code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ self.kind.code()
+ }
+}
+
+impl fmt::Debug for RhiCredentialResolutionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiCredentialResolutionError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiCredentialResolutionError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.kind.message())
+ }
+}
+
+impl Error for RhiCredentialResolutionError {}
+
+const fn resolution_error(kind: RhiCredentialResolutionErrorKind) -> RhiCredentialResolutionError {
+ RhiCredentialResolutionError { kind }
+}
+
+/// Resolves one existing wrapping credential from the canonical instance secrets root.
+///
+/// The caller supplies no path or credential bytes. Production deployment and
+/// repo-local offline tooling provision the fixed artifact externally; this
+/// operation is read-only and never creates a credential or parent directory.
+pub fn resolve_rhi_wrapping_credential(
+ runtime: &RhiRuntimeContext,
+ binding: &RhiIdentityEnvelopeBinding,
+) -> Result<RhiWrappingCredential, RhiCredentialResolutionError> {
+ if !matches!(
+ runtime.profile(),
+ RhiBootstrapProfileV1::ServiceHost | RhiBootstrapProfileV1::RepoLocal
+ ) {
+ return Err(resolution_error(
+ RhiCredentialResolutionErrorKind::UnsupportedProfile,
+ ));
+ }
+ if binding.kind() != RhiIdentityProviderKind::EncryptedFile {
+ return Err(resolution_error(
+ RhiCredentialResolutionErrorKind::InvalidBinding,
+ ));
+ }
+ if !binding.matches_runtime(runtime) {
+ return Err(resolution_error(
+ RhiCredentialResolutionErrorKind::InvalidBinding,
+ ));
+ }
+ let reference = binding
+ .credential_reference()
+ .ok_or_else(|| resolution_error(RhiCredentialResolutionErrorKind::InvalidBinding))?;
+ let name = ServiceCredentialArtifactName::new(reference.as_str())
+ .map_err(|_| resolution_error(RhiCredentialResolutionErrorKind::InvalidReference))?;
+ let path = service_credential_artifact_path(runtime.context().paths(), &name);
+ if binding.encrypted_envelope_path() == Some(path.as_path()) {
+ return Err(resolution_error(
+ RhiCredentialResolutionErrorKind::InvalidBinding,
+ ));
+ }
+ load_resolved_wrapping_credential(&path).map_err(|error| {
+ let kind = match error.kind() {
+ RhiEncryptedIdentityEnvelopeErrorKind::InvalidPath
+ | RhiEncryptedIdentityEnvelopeErrorKind::InvalidBinding => {
+ RhiCredentialResolutionErrorKind::InvalidBinding
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::MissingEnvelope => {
+ RhiCredentialResolutionErrorKind::MissingCredential
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::InsecureParent => {
+ RhiCredentialResolutionErrorKind::InsecureSecretsRoot
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact => {
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::InvalidCredential => {
+ RhiCredentialResolutionErrorKind::InvalidCredential
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform => {
+ RhiCredentialResolutionErrorKind::UnsupportedPlatform
+ }
+ RhiEncryptedIdentityEnvelopeErrorKind::InvalidProvisioningMaterial
+ | RhiEncryptedIdentityEnvelopeErrorKind::AlreadyExists
+ | RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedEnvelopeVersion
+ | RhiEncryptedIdentityEnvelopeErrorKind::MalformedEnvelope
+ | RhiEncryptedIdentityEnvelopeErrorKind::WrongCredential
+ | RhiEncryptedIdentityEnvelopeErrorKind::IdentityMismatch
+ | RhiEncryptedIdentityEnvelopeErrorKind::Io => RhiCredentialResolutionErrorKind::Io,
+ };
+ resolution_error(kind)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ use std::fs;
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ use std::os::unix::fs::{PermissionsExt, symlink};
+ use std::path::{Path, PathBuf};
+
+ use nostr::{Keys, SecretKey};
+ use sha2::{Digest, Sha256};
+
+ use radroots_storage::event::SourceGeneration;
+
+ use crate::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigProfile,
+ RhiStateMetadata, parse_rhi_cli_v1_from, parse_rhi_config_v1, resolve_rhi_runtime_context,
+ };
+
+ use super::*;
+
+ const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+
+ fn bytes(label: &str) -> [u8; 32] {
+ Sha256::digest(label.as_bytes()).into()
+ }
+
+ fn runtime(root: &Path, profile: &str, instance: &str) -> RhiRuntimeContext {
+ let root = root.to_str().expect("UTF-8 test root");
+ let arguments = if profile == "repo-local" {
+ vec![
+ "rhi",
+ "--profile",
+ profile,
+ "--instance",
+ instance,
+ "--repo-local-root",
+ root,
+ "run",
+ ]
+ } else {
+ vec!["rhi", "--profile", profile, "--instance", instance, "run"]
+ };
+ let invocation = parse_rhi_cli_v1_from(arguments).expect("test invocation");
+ let environment = if profile == "interactive" {
+ RadrootsHostEnvironment {
+ home_dir: Some(PathBuf::from(root)),
+ xdg_config_home: Some(PathBuf::from(root).join("config")),
+ xdg_data_home: Some(PathBuf::from(root).join("data")),
+ xdg_state_home: Some(PathBuf::from(root).join("state")),
+ xdg_cache_home: Some(PathBuf::from(root).join("cache")),
+ xdg_runtime_dir: Some(PathBuf::from(root).join("run")),
+ ..RadrootsHostEnvironment::default()
+ }
+ } else {
+ RadrootsHostEnvironment::default()
+ };
+ resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, environment),
+ &invocation,
+ )
+ .expect("runtime context")
+ }
+
+ fn binding(runtime: &RhiRuntimeContext, envelope_path: &Path) -> RhiIdentityEnvelopeBinding {
+ let mut identity = bytes("radroots.rhi.credential-test.identity.v1");
+ while SecretKey::from_slice(&identity).is_err() {
+ identity = Sha256::digest(identity).into();
+ }
+ let public_key = Keys::new(SecretKey::from_slice(&identity).expect("identity"))
+ .public_key()
+ .to_hex();
+ let source = CONFIG
+ .replace(
+ "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
+ envelope_path.to_str().expect("UTF-8 envelope path"),
+ )
+ .replace(&"2".repeat(64), &public_key);
+ let configuration = parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal)
+ .expect("configuration");
+ let metadata = RhiStateMetadata::new(
+ runtime,
+ &configuration,
+ SourceGeneration::new([0x6b; 32]).expect("source generation"),
+ 1,
+ )
+ .expect("state metadata");
+ RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata)
+ .expect("identity binding")
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ fn prepare_credential(runtime: &RhiRuntimeContext, name: &str, contents: &[u8]) -> PathBuf {
+ let root = runtime.context().paths().secrets();
+ fs::create_dir_all(root).expect("secrets root");
+ fs::set_permissions(root, fs::Permissions::from_mode(0o700)).expect("secrets mode");
+ let path = root.join(name);
+ fs::write(&path, contents).expect("credential artifact");
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("credential mode");
+ path
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn canonical_existing_artifact_resolves_without_path_or_value_exposure() {
+ let directory = tempfile::tempdir().expect("test root");
+ let runtime = runtime(directory.path(), "repo-local", "primary");
+ let envelope_parent = directory.path().join("envelopes");
+ fs::create_dir(&envelope_parent).expect("envelope parent");
+ fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
+ .expect("envelope parent mode");
+ let binding = binding(&runtime, &envelope_parent.join("service.identity.ncrypt"));
+ let credential_bytes = bytes("radroots.rhi.credential-test.wrapping.v1");
+ let path = prepare_credential(
+ &runtime,
+ binding
+ .credential_reference()
+ .expect("credential reference")
+ .as_str(),
+ &credential_bytes,
+ );
+
+ let credential =
+ resolve_rhi_wrapping_credential(&runtime, &binding).expect("credential resolution");
+ assert_eq!(
+ format!("{credential:?}"),
+ "RhiWrappingCredential([redacted])"
+ );
+ assert_eq!(
+ path,
+ runtime
+ .context()
+ .paths()
+ .secrets()
+ .join("service_wrapping_key")
+ );
+ assert_eq!(
+ fs::read(&path).expect("credential unchanged"),
+ credential_bytes
+ );
+ assert_eq!(
+ fs::metadata(&path)
+ .expect("credential metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o400))
+ .expect("read-only credential mode");
+ fs::set_permissions(
+ runtime.context().paths().secrets(),
+ fs::Permissions::from_mode(0o500),
+ )
+ .expect("read-only secrets root mode");
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect("owner-read-only artifact and secrets root");
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn binding_cannot_be_reused_for_another_instance() {
+ let directory = tempfile::tempdir().expect("test root");
+ let primary = runtime(directory.path(), "repo-local", "primary");
+ let secondary = runtime(directory.path(), "repo-local", "secondary");
+ let binding = binding(&primary, &directory.path().join("service.identity.ncrypt"));
+ prepare_credential(
+ &secondary,
+ "service_wrapping_key",
+ &bytes("radroots.rhi.credential-test.secondary.v1"),
+ );
+
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&secondary, &binding)
+ .expect_err("binding is tied to the primary runtime")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InvalidBinding
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn missing_adjacent_short_long_zero_and_insecure_artifacts_fail_closed() {
+ let directory = tempfile::tempdir().expect("test root");
+ let runtime = runtime(directory.path(), "repo-local", "primary");
+ let envelope_parent = directory.path().join("envelopes");
+ fs::create_dir(&envelope_parent).expect("envelope parent");
+ fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700))
+ .expect("envelope parent mode");
+ let envelope_path = envelope_parent.join("service.identity.ncrypt");
+ let binding = binding(&runtime, &envelope_path);
+ let adjacent = envelope_parent.join("service.identity.key");
+ fs::write(&adjacent, bytes("adjacent key")).expect("adjacent file");
+ fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets root");
+ fs::set_permissions(
+ runtime.context().paths().secrets(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("secrets mode");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("canonical credential is missing")
+ .kind(),
+ RhiCredentialResolutionErrorKind::MissingCredential
+ );
+
+ let reference = binding.credential_reference().expect("reference").as_str();
+ let path = prepare_credential(&runtime, reference, &[1; 31]);
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("short")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ );
+ fs::write(&path, [1; 33]).expect("long");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("long")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ );
+ fs::write(&path, [0; 32]).expect("zero");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("zero")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InvalidCredential
+ );
+ fs::write(&path, [1; 32]).expect("valid length");
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("insecure mode");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("mode")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ );
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("restore mode");
+ let second_link = runtime.context().paths().secrets().join("second-link");
+ fs::hard_link(&path, &second_link).expect("hard link");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("hard link")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ );
+ fs::remove_file(&second_link).expect("remove hard link");
+ fs::remove_file(&path).expect("remove credential");
+ symlink(&adjacent, &path).expect("credential symlink");
+ assert_eq!(
+ resolve_rhi_wrapping_credential(&runtime, &binding)
+ .expect_err("symlink")
+ .kind(),
+ RhiCredentialResolutionErrorKind::InsecureCredential
+ );
+ }
+
+ #[test]
+ fn unsupported_interactive_profile_and_errors_are_source_free() {
+ let directory = tempfile::tempdir().expect("test root");
+ let bound_runtime = runtime(directory.path(), "repo-local", "primary");
+ let runtime = runtime(directory.path(), "interactive", "primary");
+ let binding = binding(
+ &bound_runtime,
+ &directory.path().join("service.identity.ncrypt"),
+ );
+ let error =
+ resolve_rhi_wrapping_credential(&runtime, &binding).expect_err("interactive profile");
+ assert_eq!(
+ error.kind(),
+ RhiCredentialResolutionErrorKind::UnsupportedProfile
+ );
+ assert!(Error::source(&error).is_none());
+ let rendered = format!("{error} {error:?}");
+ assert!(!rendered.contains(directory.path().to_string_lossy().as_ref()));
+ for kind in [
+ RhiCredentialResolutionErrorKind::InvalidBinding,
+ RhiCredentialResolutionErrorKind::UnsupportedProfile,
+ RhiCredentialResolutionErrorKind::InvalidReference,
+ RhiCredentialResolutionErrorKind::MissingCredential,
+ RhiCredentialResolutionErrorKind::InsecureSecretsRoot,
+ RhiCredentialResolutionErrorKind::InsecureCredential,
+ RhiCredentialResolutionErrorKind::InvalidCredential,
+ RhiCredentialResolutionErrorKind::Io,
+ RhiCredentialResolutionErrorKind::UnsupportedPlatform,
+ ] {
+ let error = resolution_error(kind);
+ assert!(!error.code().is_empty());
+ assert!(Error::source(&error).is_none());
+ }
+ }
+}
diff --git a/src/identity_envelope.rs b/src/identity_envelope.rs
@@ -77,6 +77,7 @@ pub struct RhiIdentityEnvelopeBinding {
envelope_path: PathBuf,
credential_reference: ServiceCredentialArtifactName,
expected_identity: RhiExpectedPublicIdentity,
+ state_paths: radroots_service_sqlite::ServiceSqlitePaths,
}
impl RhiIdentityEnvelopeBinding {
@@ -111,6 +112,7 @@ impl RhiIdentityEnvelopeBinding {
envelope_path: path,
credential_reference,
expected_identity: metadata.expected_identity().clone(),
+ state_paths: metadata.paths().clone(),
})
}
@@ -139,6 +141,11 @@ impl RhiIdentityEnvelopeBinding {
pub(crate) fn encrypted_envelope_path(&self) -> Option<&Path> {
Some(self.envelope_path.as_path())
}
+
+ pub(crate) fn matches_runtime(&self, runtime: &crate::RhiRuntimeContext) -> bool {
+ radroots_service_sqlite::ServiceSqlitePaths::from_runtime_context(runtime.context())
+ .is_ok_and(|paths| paths == self.state_paths)
+ }
}
impl fmt::Debug for RhiIdentityEnvelopeBinding {
@@ -275,7 +282,29 @@ const fn envelope_error(
/// Sealed zeroizing wrapping credential resolved only by the governed credential boundary.
pub struct RhiWrappingCredential(Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>);
+/// Non-forgeable proof that owns credential bytes admitted by the governed resolver.
+pub(crate) struct RhiCredentialResolutionProof {
+ credential: Zeroizing<[u8; WRAPPING_CREDENTIAL_BYTES]>,
+}
+
+impl fmt::Debug for RhiCredentialResolutionProof {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RhiCredentialResolutionProof([sealed])")
+ }
+}
+
impl RhiWrappingCredential {
+ pub(crate) fn from_resolution(
+ proof: RhiCredentialResolutionProof,
+ ) -> Result<Self, RhiEncryptedIdentityEnvelopeError> {
+ if proof.credential.iter().all(|byte| *byte == 0) {
+ return Err(envelope_error(
+ RhiEncryptedIdentityEnvelopeErrorKind::InvalidCredential,
+ ));
+ }
+ Ok(Self(proof.credential))
+ }
+
fn expose<T>(&self, use_credential: impl FnOnce(&[u8; 32]) -> T) -> T {
use_credential(&self.0)
}
@@ -434,6 +463,17 @@ pub fn open_rhi_encrypted_identity(
))
}
+pub(crate) fn load_resolved_wrapping_credential(
+ path: &Path,
+) -> Result<RhiWrappingCredential, RhiEncryptedIdentityEnvelopeError> {
+ ensure_supported_platform()?;
+ validate_requested_path(path)?;
+ let encoded = Zeroizing::new(read_existing_exact(path, WRAPPING_CREDENTIAL_BYTES)?);
+ let mut credential = Zeroizing::new([0_u8; WRAPPING_CREDENTIAL_BYTES]);
+ credential.copy_from_slice(&encoded);
+ RhiWrappingCredential::from_resolution(RhiCredentialResolutionProof { credential })
+}
+
fn require_wire_version(encoded: &[u8]) -> Result<(), RhiEncryptedIdentityEnvelopeError> {
if encoded.len() < 6 || &encoded[..4] != b"RRS1" {
return Err(envelope_error(
@@ -836,7 +876,11 @@ mod native {
file.write_all(encoded)
.and_then(|()| file.sync_all())
.map_err(|_| envelope_error(RhiEncryptedIdentityEnvelopeErrorKind::Io))?;
- file_identity(&file, Some(encoded.len()))?;
+ file_identity(
+ &file,
+ Some(encoded.len()),
+ RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES,
+ )?;
validate_current_binding(
&path,
&parent,
@@ -844,6 +888,7 @@ mod native {
&file,
identity,
encoded.len(),
+ RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES,
)?;
parent
.sync_all()
@@ -855,6 +900,7 @@ mod native {
&file,
identity,
encoded.len(),
+ RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES,
)
})();
if result.is_err() {
@@ -864,6 +910,21 @@ mod native {
}
pub(super) fn read_existing(path: &Path) -> Result<Vec<u8>, RhiEncryptedIdentityEnvelopeError> {
+ read_existing_bounded(path, RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, None)
+ }
+
+ pub(super) fn read_existing_exact(
+ path: &Path,
+ expected_length: usize,
+ ) -> Result<Vec<u8>, RhiEncryptedIdentityEnvelopeError> {
+ read_existing_bounded(path, expected_length, Some(expected_length))
+ }
+
+ fn read_existing_bounded(
+ path: &Path,
+ maximum_length: usize,
+ expected_length: Option<usize>,
+ ) -> Result<Vec<u8>, RhiEncryptedIdentityEnvelopeError> {
let path = ArtifactPath::parse(path)?;
let parent = open_parent(&path.parent_path, false)?;
let parent_identity = directory_identity(&parent, false)?;
@@ -885,7 +946,7 @@ mod native {
let mut file = File::from(descriptor);
let status = fstat(&file)
.map_err(|_| envelope_error(RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?;
- let length = validate_file_status(&status, None)?;
+ let length = validate_file_status(&status, expected_length, maximum_length)?;
let identity = status_identity(
&status,
RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact,
@@ -902,7 +963,15 @@ mod native {
RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact,
));
}
- validate_current_binding(&path, &parent, parent_identity, &file, identity, length)?;
+ validate_current_binding(
+ &path,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ length,
+ maximum_length,
+ )?;
Ok(encoded)
}
@@ -961,10 +1030,11 @@ mod native {
fn file_identity(
file: &File,
expected_length: Option<usize>,
+ maximum_length: usize,
) -> Result<Identity, RhiEncryptedIdentityEnvelopeError> {
let status = fstat(file)
.map_err(|_| envelope_error(RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?;
- validate_file_status(&status, expected_length)?;
+ validate_file_status(&status, expected_length, maximum_length)?;
status_identity(
&status,
RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact,
@@ -996,6 +1066,7 @@ mod native {
fn validate_file_status(
status: &rustix::fs::Stat,
expected_length: Option<usize>,
+ maximum_length: usize,
) -> Result<usize, RhiEncryptedIdentityEnvelopeError> {
let mode = native_mode(status.st_mode) & 0o777;
let length = usize::try_from(status.st_size)
@@ -1005,7 +1076,7 @@ mod native {
|| status.st_uid != geteuid().as_raw()
|| !matches!(mode, 0o400 | 0o600)
|| length == 0
- || length > RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES
+ || length > maximum_length
|| expected_length.is_some_and(|expected| expected != length)
{
return Err(envelope_error(
@@ -1022,6 +1093,7 @@ mod native {
held_file: &File,
expected_file: Identity,
expected_length: usize,
+ maximum_length: usize,
) -> Result<(), RhiEncryptedIdentityEnvelopeError> {
let current_parent = open_parent(&path.parent_path, false)?;
if directory_identity(held_parent, false)? != expected_parent
@@ -1040,8 +1112,8 @@ mod native {
)
.map_err(|_| envelope_error(RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact))?,
);
- if file_identity(held_file, Some(expected_length))? != expected_file
- || file_identity(¤t_file, Some(expected_length))? != expected_file
+ if file_identity(held_file, Some(expected_length), maximum_length)? != expected_file
+ || file_identity(¤t_file, Some(expected_length), maximum_length)? != expected_file
{
return Err(envelope_error(
RhiEncryptedIdentityEnvelopeErrorKind::InsecureArtifact,
@@ -1091,7 +1163,7 @@ mod native {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
-use native::{persist_create_new, read_existing, validate_requested_path};
+use native::{persist_create_new, read_existing, read_existing_exact, validate_requested_path};
#[cfg(any(target_os = "linux", target_os = "macos"))]
const fn ensure_supported_platform() -> Result<(), RhiEncryptedIdentityEnvelopeError> {
@@ -1129,6 +1201,16 @@ fn read_existing(_path: &Path) -> Result<Vec<u8>, RhiEncryptedIdentityEnvelopeEr
))
}
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn read_existing_exact(
+ _path: &Path,
+ _expected_length: usize,
+) -> Result<Vec<u8>, RhiEncryptedIdentityEnvelopeError> {
+ Err(envelope_error(
+ RhiEncryptedIdentityEnvelopeErrorKind::UnsupportedPlatform,
+ ))
+}
+
#[cfg(test)]
mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
diff --git a/src/identity_storage.rs b/src/identity_storage.rs
@@ -1,575 +0,0 @@
-use std::ffi::OsString;
-use std::fs::{self, OpenOptions};
-use std::io::Write;
-use std::path::{Path, PathBuf};
-
-use chacha20poly1305::aead::{Aead, KeyInit, Payload};
-use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
-use radroots_secrets::context::{
- EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId,
-};
-use radroots_secrets::envelope::{
- ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION, LegacyV1ResealAuthority, Nonce, SealMaterial,
- SealRequest,
-};
-use radroots_secrets::error::Operation;
-use radroots_secrets::id::{BackendKind, KeyVersion};
-use radroots_secrets::wrapping::{
- BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
-};
-use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef};
-use zeroize::Zeroize;
-
-use crate::host_identity::{
- IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic,
-};
-
-const RHI_IDENTITY_KEY_SLOT: &str = "rhi_identity";
-const WRAPPING_KEY_BYTES: usize = 32;
-const WRAPPING_NONCE_BYTES: usize = 24;
-const LEGACY_WRAPPED_KEY_VERSION: u8 = 1;
-const WRAPPED_KEY_VERSION: u8 = 2;
-const WRAPPING_AAD_DOMAIN: &[u8] = b"rhi.wrapped_data_key.v2";
-
-pub fn encrypted_identity_key_path(path: impl AsRef<Path>) -> PathBuf {
- encrypted_identity_wrapping_key_path(path)
-}
-
-pub fn load_service_identity(path: &Path) -> Result<RadrootsIdentity, IdentityError> {
- let path = path.to_path_buf();
- if path.exists() {
- return load_encrypted_identity(path);
- }
- Err(IdentityError::GenerationNotAllowed(path))
-}
-
-struct RhiFileKeyWrapping {
- key_path: PathBuf,
-}
-
-impl RhiFileKeyWrapping {
- fn new(identity_path: &Path) -> Self {
- Self {
- key_path: encrypted_identity_wrapping_key_path(identity_path),
- }
- }
-
- fn load_or_create_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- if let Ok(raw) = fs::read(&self.key_path) {
- return key_from_bytes(raw.as_slice());
- }
- if let Some(parent) = self
- .key_path
- .parent()
- .filter(|path| !path.as_os_str().is_empty())
- {
- fs::create_dir_all(parent).map_err(|_| secret_backend_failure(Operation::Provision))?;
- }
- let key: [u8; WRAPPING_KEY_BYTES] = rand::random();
- match OpenOptions::new()
- .write(true)
- .create_new(true)
- .open(&self.key_path)
- {
- Ok(mut file) => {
- file.write_all(&key)
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- file.sync_all()
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- set_secret_permissions(&self.key_path)
- .map_err(|_| secret_backend_failure(Operation::Write))?;
- Ok(key)
- }
- Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
- let raw = fs::read(&self.key_path)
- .map_err(|_| secret_backend_failure(Operation::Read))?;
- key_from_bytes(raw.as_slice())
- }
- Err(_) => Err(secret_backend_failure(Operation::Provision)),
- }
- }
-
- fn load_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- let raw = fs::read(&self.key_path).map_err(|_| secret_backend_failure(Operation::Read))?;
- key_from_bytes(raw.as_slice())
- }
-}
-
-impl KeyWrapping for RhiFileKeyWrapping {
- fn wrap<'a>(
- &'a self,
- request: WrapRequest<'a>,
- ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Wrap)?;
- let mut key = self.load_or_create_key()?;
- let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random();
- let aad = wrapping_aad(request.reference(), request.context());
- let ciphertext = request.plaintext().expose_secret(|plaintext| {
- XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt(
- XNonce::from_slice(&nonce),
- Payload {
- msg: plaintext,
- aad: aad.as_slice(),
- },
- )
- });
- key.zeroize();
- let ciphertext = ciphertext.map_err(|_| secret_backend_failure(Operation::Wrap))?;
- let mut wrapped = Vec::with_capacity(1 + nonce.len() + ciphertext.len());
- wrapped.push(WRAPPED_KEY_VERSION);
- wrapped.extend_from_slice(&nonce);
- wrapped.extend_from_slice(ciphertext.as_slice());
- WrappedSecret::from_bytes(wrapped)
- })
- }
-
- fn unwrap<'a>(
- &'a self,
- request: UnwrapRequest<'a>,
- ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Unwrap)?;
- let aad = wrapping_aad(request.reference(), request.context());
- self.unwrap_with_aad(request.wrapped(), WRAPPED_KEY_VERSION, aad.as_slice())
- })
- }
-
- fn unwrap_legacy_v1<'a>(
- &'a self,
- request: LegacyV1UnwrapRequest<'a>,
- ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
- Box::pin(async move {
- validate_identity_reference(request.reference(), Operation::Unwrap)?;
- self.unwrap_with_aad(
- request.wrapped(),
- LEGACY_WRAPPED_KEY_VERSION,
- request.reference().id().as_str().as_bytes(),
- )
- })
- }
-}
-
-impl RhiFileKeyWrapping {
- fn unwrap_with_aad(
- &self,
- wrapped: &WrappedSecret,
- expected_version: u8,
- aad: &[u8],
- ) -> Result<SecretMaterial, radroots_secrets::Error> {
- let wrapped = wrapped.as_bytes();
- if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != expected_version {
- return Err(secret_backend_failure(Operation::Unwrap));
- }
- let mut key = self.load_key()?;
- let plaintext = XChaCha20Poly1305::new(Key::from_slice(&key)).decrypt(
- XNonce::from_slice(&wrapped[1..1 + WRAPPING_NONCE_BYTES]),
- Payload {
- msg: &wrapped[1 + WRAPPING_NONCE_BYTES..],
- aad,
- },
- );
- key.zeroize();
- SecretMaterial::from_slice(
- &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?,
- )
- }
-}
-
-fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> {
- let id = reference.id().as_str().as_bytes();
- let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32);
- aad.extend_from_slice(WRAPPING_AAD_DOMAIN);
- aad.extend_from_slice(
- &u16::try_from(id.len())
- .expect("validated secret identifier length fits u16")
- .to_be_bytes(),
- );
- aad.extend_from_slice(id);
- aad.extend_from_slice(&reference.key_version().get().to_be_bytes());
- aad.extend_from_slice(&context.authentication_digest());
- aad
-}
-
-fn validate_identity_reference(
- reference: &SecretRef,
- operation: Operation,
-) -> Result<(), radroots_secrets::Error> {
- if reference.backend() != BackendKind::External
- || reference.key_version().get() != 1
- || reference.id().as_str() != RHI_IDENTITY_KEY_SLOT
- {
- return Err(secret_backend_failure(operation));
- }
- Ok(())
-}
-
-fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> {
- Ok(SecretRef::new(
- SecretId::parse(RHI_IDENTITY_KEY_SLOT)?,
- BackendKind::External,
- KeyVersion::new(1)?,
- ))
-}
-
-fn identity_envelope_context() -> Result<EnvelopeContext, radroots_secrets::Error> {
- Ok(EnvelopeContext::new(
- EnvelopePurpose::parse("radroots.service_identity")?,
- EnvelopeSubject::parse("service", "rhi")?,
- PayloadSchemaId::parse("radroots.rhi_identity.v1")?,
- ))
-}
-
-fn secret_backend_failure(operation: Operation) -> radroots_secrets::Error {
- radroots_secrets::Error::BackendFailure {
- backend: BackendKind::External,
- operation,
- }
-}
-
-fn key_from_bytes(raw: &[u8]) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
- raw.try_into()
- .map_err(|_| secret_backend_failure(Operation::Read))
-}
-
-fn storage_error(path: &Path, operation: &str) -> IdentityError {
- IdentityError::ProtectedStorage {
- path: path.to_path_buf(),
- message: operation.to_owned(),
- }
-}
-
-pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf {
- let mut value = OsString::from(path.as_ref().as_os_str());
- value.push(".key");
- PathBuf::from(value)
-}
-
-pub fn store_encrypted_identity(
- path: impl AsRef<Path>,
- identity: &RadrootsIdentity,
-) -> Result<(), IdentityError> {
- let path = path.as_ref();
- if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) {
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- }
- let payload = serde_json::to_vec(&identity.to_file())?;
- let plaintext = SecretMaterial::from_slice(payload.as_slice())
- .map_err(|_| storage_error(path, "validate identity secret material"))?;
- let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())
- .map_err(|_| storage_error(path, "validate identity data key"))?;
- let wrapping = RhiFileKeyWrapping::new(path);
- let context =
- identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?;
- let envelope = futures_executor::block_on(EncryptedEnvelope::seal(
- &wrapping,
- SealRequest::new(
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?,
- context,
- &plaintext,
- SealMaterial::new(data_key, Nonce::new(rand::random())),
- ),
- ))
- .map_err(|_| storage_error(path, "seal encrypted identity"))?;
- let encoded = envelope
- .encode()
- .map_err(|_| storage_error(path, "encode encrypted identity"))?;
- atomic_write(path, encoded.as_slice())
-}
-
-pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- let envelope = EncryptedEnvelope::decode(encoded.as_slice())
- .map_err(|_| storage_error(path, "decode encrypted identity"))?;
- let wrapping = RhiFileKeyWrapping::new(path);
- let context =
- identity_envelope_context().map_err(|_| storage_error(path, "build identity context"))?;
- if envelope.version() == LEGACY_ENVELOPE_VERSION {
- return migrate_legacy_identity(path, envelope, &wrapping, context);
- }
- if envelope.version() != ENVELOPE_VERSION {
- return Err(storage_error(
- path,
- "unsupported encrypted identity version",
- ));
- }
- open_identity(path, &envelope, &wrapping, &context)
-}
-
-fn open_identity(
- path: &Path,
- envelope: &EncryptedEnvelope,
- wrapping: &RhiFileKeyWrapping,
- context: &EnvelopeContext,
-) -> Result<RadrootsIdentity, IdentityError> {
- let payload = futures_executor::block_on(envelope.open(wrapping, context))
- .map_err(|_| storage_error(path, "open encrypted identity"))?;
- let file: RadrootsIdentityFile = payload
- .expose_secret(|bytes| serde_json::from_slice(bytes))
- .map_err(IdentityError::from)?;
- RadrootsIdentity::try_from(file)
-}
-
-fn migrate_legacy_identity(
- path: &Path,
- envelope: EncryptedEnvelope,
- wrapping: &RhiFileKeyWrapping,
- context: EnvelopeContext,
-) -> Result<RadrootsIdentity, IdentityError> {
- let expected_reference =
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?;
- let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())
- .map_err(|_| storage_error(path, "validate identity data key"))?;
- let resealed = futures_executor::block_on(envelope.reseal_legacy_v1(
- wrapping,
- &LegacyV1ResealAuthority::new(),
- &expected_reference,
- identity_secret_ref().map_err(|_| storage_error(path, "build identity reference"))?,
- context.clone(),
- &valid_identity_payload,
- SealMaterial::new(data_key, Nonce::new(rand::random())),
- ))
- .map_err(|_| storage_error(path, "migrate legacy encrypted identity"))?;
- let envelope = resealed.into_envelope();
- let identity = open_identity(path, &envelope, wrapping, &context)?;
- let encoded = envelope
- .encode()
- .map_err(|_| storage_error(path, "encode migrated identity"))?;
- atomic_write(path, encoded.as_slice())?;
- Ok(identity)
-}
-
-fn valid_identity_payload(bytes: &[u8]) -> bool {
- serde_json::from_slice::<RadrootsIdentityFile>(bytes)
- .ok()
- .and_then(|file| RadrootsIdentity::try_from(file).ok())
- .is_some()
-}
-
-pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> {
- let path = path.as_ref();
- let identity = load_encrypted_identity(path)?;
- let key_path = encrypted_identity_wrapping_key_path(path);
- let old_key =
- fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?;
- fs::remove_file(&key_path).map_err(|source| IdentityError::Write(key_path.clone(), source))?;
- if let Err(error) = store_encrypted_identity(path, &identity) {
- fs::write(&key_path, old_key)
- .map_err(|source| IdentityError::Write(key_path.clone(), source))?;
- set_secret_permissions(&key_path)
- .map_err(|source| IdentityError::Write(key_path, source))?;
- return Err(error);
- }
- Ok(())
-}
-
-pub fn load_identity_profile(
- path: impl AsRef<Path>,
-) -> Result<RadrootsIdentityPublic, IdentityError> {
- let path = path.as_ref();
- let encoded = fs::read(path).map_err(|source| {
- if source.kind() == std::io::ErrorKind::NotFound {
- IdentityError::NotFound(path.to_path_buf())
- } else {
- IdentityError::Read(path.to_path_buf(), source)
- }
- })?;
- serde_json::from_slice(encoded.as_slice()).map_err(IdentityError::from)
-}
-
-pub fn store_identity_profile(
- path: impl AsRef<Path>,
- identity: &RadrootsIdentity,
-) -> Result<(), IdentityError> {
- let encoded = serde_json::to_vec_pretty(&identity.to_public())?;
- atomic_write(path.as_ref(), encoded.as_slice())
-}
-
-fn atomic_write(path: &Path, encoded: &[u8]) -> Result<(), IdentityError> {
- let parent = path
- .parent()
- .filter(|value| !value.as_os_str().is_empty())
- .unwrap_or_else(|| Path::new("."));
- fs::create_dir_all(parent)
- .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
- let mut temporary = tempfile::NamedTempFile::new_in(parent)
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- temporary
- .write_all(encoded)
- .and_then(|()| temporary.as_file().sync_all())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- set_file_permissions(temporary.as_file())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
- temporary
- .persist(path)
- .map_err(|error| IdentityError::Write(path.to_path_buf(), error.error))?;
- fs::File::open(parent)
- .and_then(|directory| directory.sync_all())
- .map_err(|source| IdentityError::Write(path.to_path_buf(), source))
-}
-
-#[cfg(unix)]
-fn set_secret_permissions(path: &Path) -> std::io::Result<()> {
- use std::os::unix::fs::PermissionsExt;
- fs::set_permissions(path, fs::Permissions::from_mode(0o600))
-}
-
-#[cfg(not(unix))]
-fn set_secret_permissions(_path: &Path) -> std::io::Result<()> {
- Ok(())
-}
-
-fn set_file_permissions(file: &fs::File) -> std::io::Result<()> {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- file.set_permissions(fs::Permissions::from_mode(0o600))
- }
- #[cfg(not(unix))]
- {
- let _ = file;
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn identity() -> RadrootsIdentity {
- RadrootsIdentity::from_secret_key_str(
- "1111111111111111111111111111111111111111111111111111111111111111",
- )
- .expect("identity")
- }
-
- #[test]
- fn encrypted_identity_round_trips_and_rotates_wrapping_key() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc");
- let identity = identity();
- store_encrypted_identity(&path, &identity).expect("store");
- let key_path = encrypted_identity_wrapping_key_path(&path);
- let before = fs::read(&key_path).expect("key before");
- assert_eq!(
- load_encrypted_identity(&path).expect("load").id(),
- identity.id()
- );
- rotate_encrypted_identity(&path).expect("rotate");
- assert_ne!(before, fs::read(key_path).expect("key after"));
- assert_eq!(
- load_encrypted_identity(&path).expect("load").id(),
- identity.id()
- );
- let envelope =
- EncryptedEnvelope::decode(&fs::read(&path).expect("read encrypted identity"))
- .expect("decode encrypted identity");
- assert_eq!(envelope.version(), ENVELOPE_VERSION);
- assert_eq!(
- envelope.context(),
- Some(&identity_envelope_context().expect("identity context"))
- );
- }
-
- #[test]
- fn encrypted_identity_migrates_legacy_v1() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.enc");
- let identity = identity();
- store_legacy_identity(&path, &identity);
-
- assert_eq!(
- load_encrypted_identity(&path)
- .expect("migrate legacy identity")
- .id(),
- identity.id()
- );
- let envelope = EncryptedEnvelope::decode(&fs::read(&path).expect("read migrated identity"))
- .expect("decode migrated identity");
- assert_eq!(envelope.version(), ENVELOPE_VERSION);
- assert_eq!(
- envelope.context(),
- Some(&identity_envelope_context().expect("identity context"))
- );
- }
-
- #[test]
- fn public_profile_round_trips() {
- let temp = tempfile::tempdir().expect("tempdir");
- let path = temp.path().join("identity.json");
- let identity = identity();
- store_identity_profile(&path, &identity).expect("store profile");
- assert_eq!(
- load_identity_profile(path).expect("load profile").id,
- identity.id()
- );
- }
-
- fn store_legacy_identity(path: &Path, identity: &RadrootsIdentity) {
- const NONCE_BYTES: usize = 24;
- const TAG_BYTES: usize = 16;
-
- let wrapping_key = [0x11; 32];
- let data_key = [0x22; 32];
- let wrapping_nonce = [0x33; NONCE_BYTES];
- let envelope_nonce = [0x44; NONCE_BYTES];
- let payload = serde_json::to_vec(&identity.to_file()).expect("encode identity payload");
- let wrapped_ciphertext = XChaCha20Poly1305::new(Key::from_slice(&wrapping_key))
- .encrypt(
- XNonce::from_slice(&wrapping_nonce),
- Payload {
- msg: &data_key,
- aad: RHI_IDENTITY_KEY_SLOT.as_bytes(),
- },
- )
- .expect("wrap legacy data key");
- let mut wrapped = Vec::with_capacity(1 + NONCE_BYTES + wrapped_ciphertext.len());
- wrapped.push(LEGACY_WRAPPED_KEY_VERSION);
- wrapped.extend_from_slice(&wrapping_nonce);
- wrapped.extend_from_slice(&wrapped_ciphertext);
-
- let id = RHI_IDENTITY_KEY_SLOT.as_bytes();
- let mut encoded = Vec::new();
- encoded.extend_from_slice(b"RRS1");
- encoded.extend_from_slice(&LEGACY_ENVELOPE_VERSION.to_be_bytes());
- encoded.extend_from_slice(&[1, 1, 4]);
- encoded.extend_from_slice(&1_u32.to_be_bytes());
- encoded.extend_from_slice(&u16::try_from(id.len()).expect("id length").to_be_bytes());
- encoded.extend_from_slice(id);
- encoded.extend_from_slice(&envelope_nonce);
- encoded.extend_from_slice(
- &u32::try_from(wrapped.len())
- .expect("wrapped length")
- .to_be_bytes(),
- );
- encoded.extend_from_slice(&wrapped);
- encoded.extend_from_slice(
- &u32::try_from(payload.len() + TAG_BYTES)
- .expect("ciphertext length")
- .to_be_bytes(),
- );
- let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&data_key))
- .encrypt(
- XNonce::from_slice(&envelope_nonce),
- Payload {
- msg: &payload,
- aad: &encoded,
- },
- )
- .expect("encrypt legacy payload");
- encoded.extend_from_slice(&ciphertext);
-
- fs::write(path, encoded).expect("write legacy envelope");
- let key_path = encrypted_identity_wrapping_key_path(path);
- fs::write(&key_path, wrapping_key).expect("write wrapping key");
- set_secret_permissions(&key_path).expect("secure wrapping key");
- }
-}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,9 +4,8 @@ pub mod adapters;
mod cli_v1;
mod config_v1;
pub mod features;
-pub mod host_identity;
+mod identity_credential;
mod identity_envelope;
-pub mod identity_storage;
mod runtime_context;
mod state_catalog;
mod state_host;
@@ -24,6 +23,11 @@ pub use config_v1::{
RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1,
RhiRuntimeThreadLimitsV1, parse_rhi_config_v1,
};
+pub use identity_credential::{
+ RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES, RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION,
+ RhiCredentialResolutionError, RhiCredentialResolutionErrorKind,
+ resolve_rhi_wrapping_credential,
+};
pub use identity_envelope::{
RHI_ENCRYPTED_IDENTITY_BACKUP_INCLUDED, RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION,
RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES, RhiDecryptedIdentity,
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -257,6 +257,10 @@ impl RhiStateMetadata {
.is_ok_and(|paths| paths == self.paths)
}
+ pub(crate) const fn paths(&self) -> &ServiceSqlitePaths {
+ &self.paths
+ }
+
pub(crate) fn matches_configuration(&self, configuration: &RhiConfigDocumentV1) -> bool {
normalized_config_digest(configuration.profile(), configuration.normalized())
.is_ok_and(|digest| digest == self.configuration)
diff --git a/tests/services_hardening_credential_resolution.rs b/tests/services_hardening_credential_resolution.rs
@@ -0,0 +1,173 @@
+#![forbid(unsafe_code)]
+
+use serde_json::json;
+
+const CONTRACT: &str =
+ include_str!("../contracts/services_hardening/wrapping_credential_resolution.v1.json");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+const MANIFEST: &str = include_str!("../Cargo.toml");
+const CREDENTIAL_SOURCE: &str = include_str!("../src/identity_credential.rs");
+const ENVELOPE_SOURCE: &str = include_str!("../src/identity_envelope.rs");
+const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json");
+const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const HOST_SOURCE: &str = include_str!("../src/state_host.rs");
+
+#[test]
+fn machine_contract_freezes_the_canonical_read_only_credential_boundary() {
+ let actual: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON");
+ assert_eq!(
+ actual,
+ json!({
+ "schema": "radroots.rhi.wrapping-credential-resolution",
+ "schema_version": 1,
+ "contract_version": 1,
+ "artifact_name": {
+ "shared_type": "ServiceCredentialArtifactName",
+ "maximum_utf8_bytes": 128
+ },
+ "artifact_path": "<canonical_instance_secrets>/<validated_credential_name>",
+ "artifact": {
+ "wire": "raw_32_bytes",
+ "exact_bytes": 32,
+ "symlink_follow": false,
+ "regular_file": true,
+ "single_link": true,
+ "owner_uid": "effective_uid",
+ "read_modes_octal": ["0400", "0600"],
+ "secrets_root_modes_octal": ["0500", "0700"]
+ },
+ "profiles": {
+ "service_host": "existing_injected_or_mounted",
+ "repo_local": "existing_offline_provisioned",
+ "interactive": "unsupported"
+ },
+ "resolution": {
+ "read_existing_only": true,
+ "creates_credential": false,
+ "creates_parent": false,
+ "caller_supplies_path": false,
+ "caller_supplies_bytes": false,
+ "ordinary_run_generates": false
+ },
+ "forbidden_sources": [
+ "toml_secret",
+ "environment_secret",
+ "process_argument_secret",
+ "adjacent_envelope_sibling",
+ "implicit_fallback"
+ ],
+ "backup_included": false
+ })
+ );
+}
+
+#[test]
+fn implementation_derives_only_the_shared_canonical_artifact() {
+ for required in [
+ "binding.matches_runtime(runtime)",
+ "ServiceCredentialArtifactName::new(reference.as_str())",
+ "service_credential_artifact_path(runtime.context().paths(), &name)",
+ "load_resolved_wrapping_credential(&path)",
+ "RhiBootstrapProfileV1::ServiceHost | RhiBootstrapProfileV1::RepoLocal",
+ "pub fn resolve_rhi_wrapping_credential(",
+ ] {
+ assert!(
+ CREDENTIAL_SOURCE.contains(required),
+ "missing canonical resolution boundary {required}"
+ );
+ }
+ assert!(LIB_SOURCE.contains("mod identity_credential;"));
+ assert!(!LIB_SOURCE.contains("pub mod identity_credential"));
+ assert!(ENVELOPE_SOURCE.contains("pub(crate) struct RhiCredentialResolutionProof"));
+ assert!(ENVELOPE_SOURCE.contains("pub(crate) fn from_resolution("));
+}
+
+#[test]
+fn no_configuration_process_sibling_or_creation_fallback_exists() {
+ let production = CREDENTIAL_SOURCE
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production source");
+ for forbidden in [
+ "std::env::",
+ "process::Command",
+ "clap::",
+ "create_dir",
+ "create_new",
+ "OpenOptions",
+ "keyring::",
+ "with_extension(\"key\")",
+ "set_var(",
+ "var_os(",
+ ] {
+ assert!(
+ !production.contains(forbidden),
+ "forbidden credential authority {forbidden}"
+ );
+ }
+ for source in [CONFIG_SCHEMA, CONFIG_EXAMPLE] {
+ for forbidden in [
+ "wrapping_credential =",
+ "credential_bytes",
+ "credential_hex",
+ ] {
+ assert!(!source.contains(forbidden));
+ }
+ }
+}
+
+#[test]
+fn state_host_remains_credential_free_and_prototypes_are_removed() {
+ for forbidden in [
+ "resolve_rhi_wrapping_credential",
+ "RhiWrappingCredential",
+ "identity_credential",
+ "service_wrapping_key",
+ ] {
+ assert!(!HOST_SOURCE.contains(forbidden));
+ }
+ for forbidden in [
+ "pub mod host_identity",
+ "pub mod identity_storage",
+ "LEGACY_ENVELOPE_VERSION",
+ "LegacyV1",
+ "encrypt_secret_key_ncryptsec",
+ "secret_key_hex",
+ "rand::random",
+ ] {
+ assert!(!LIB_SOURCE.contains(forbidden));
+ }
+ let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ assert!(!source_root.join("host_identity.rs").exists());
+ assert!(!source_root.join("identity_storage.rs").exists());
+ for removed_dependency in [
+ "radroots_identity =",
+ "rand = { version = \"0.9\"",
+ "features = [\"nip49\"]",
+ ] {
+ assert!(!MANIFEST.contains(removed_dependency));
+ }
+}
+
+#[test]
+fn public_errors_are_source_path_and_dependency_free() {
+ for required in [
+ "pub enum RhiCredentialResolutionErrorKind",
+ "pub struct RhiCredentialResolutionError",
+ "impl Error for RhiCredentialResolutionError {}",
+ ] {
+ assert!(CREDENTIAL_SOURCE.contains(required));
+ }
+ for forbidden in [
+ "pub path:",
+ "pub source:",
+ "pub credential:",
+ "pub fn credential_path",
+ "pub fn from_resolved_bytes",
+ "pub fn from_resolution",
+ "radroots_runtime_paths::ServiceCredentialArtifactNameError",
+ "rustix::",
+ ] {
+ assert!(!LIB_SOURCE.contains(forbidden));
+ }
+}
diff --git a/tests/services_hardening_identity_envelope.rs b/tests/services_hardening_identity_envelope.rs
@@ -95,6 +95,8 @@ fn implementation_uses_shared_envelopes_and_seals_credential_resolution() {
}
assert!(LIB_SOURCE.contains("mod identity_envelope;"));
assert!(!LIB_SOURCE.contains("pub mod identity_envelope;"));
+ assert!(ENVELOPE_SOURCE.contains("pub(crate) struct RhiCredentialResolutionProof"));
+ assert!(ENVELOPE_SOURCE.contains("pub(crate) fn from_resolution("));
}
#[test]