rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 0d5cba8382782a845fe0789ced633499d72f468a
parent 909453b31c95972db0503a498f083e349fdfe173
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 21:30:45 +0000

rhi: resolve canonical wrapping credential

Diffstat:
MAGENTS.md | 9+++++++--
MCargo.lock | 51++++++++++-----------------------------------------
MCargo.toml | 4+---
MREADME | 13+++++++++++--
Acontracts/services_hardening/wrapping_credential_resolution.v1.json | 41+++++++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Dsrc/host_identity.rs | 327-------------------------------------------------------------------------------
Asrc/identity_credential.rs | 466+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/identity_envelope.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Dsrc/identity_storage.rs | 575-------------------------------------------------------------------------------
Msrc/lib.rs | 8++++++--
Msrc/state_metadata.rs | 4++++
Atests/services_hardening_credential_resolution.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_identity_envelope.rs | 2++
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(&current_file, Some(expected_length))? != expected_file + if file_identity(held_file, Some(expected_length), maximum_length)? != expected_file + || file_identity(&current_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]