rhi

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

commit e89eab671c1856f24c5d6cf53e75d2d42bed5157
parent c7c711b86655a466aecce6c46abb1e5a1861054f
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 22:11:46 +0000

refactor: adopt context-bound envelopes

Diffstat:
MCargo.lock | 22+++++++++++++---------
MCargo.toml | 14+++++++-------
Msrc/identity_storage.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 262 insertions(+), 36 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1249,7 +1249,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radroots_blossom" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "mediatype", "serde", @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "radroots_core" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "rust_decimal", "serde", @@ -1270,7 +1270,7 @@ dependencies = [ [[package]] name = "radroots_event" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "hex", "jiff-tzdb", @@ -1288,8 +1288,9 @@ dependencies = [ [[package]] name = "radroots_event_codec" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ + "hex", "radroots_blossom", "radroots_core", "radroots_event", @@ -1298,12 +1299,13 @@ dependencies = [ "secp256k1", "serde", "serde_json", + "sha2", ] [[package]] name = "radroots_identity" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "k256", "serde", @@ -1313,7 +1315,7 @@ dependencies = [ [[package]] name = "radroots_nostr" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "nostr", "radroots_event", @@ -1327,7 +1329,7 @@ dependencies = [ [[package]] name = "radroots_protocol" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "serde", ] @@ -1335,17 +1337,19 @@ dependencies = [ [[package]] name = "radroots_secrets" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "chacha20poly1305", "serde", + "sha2", + "subtle", "zeroize", ] [[package]] name = "radroots_trade" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib.git?rev=74f5c91cbc80248df1f51f644004daaf8ae8e9be#74f5c91cbc80248df1f51f644004daaf8ae8e9be" +source = "git+https://github.com/radrootslabs/lib.git?rev=f7f456d906d91aadbb9fac4850afaa8c5723c3e9#f7f456d906d91aadbb9fac4850afaa8c5723c3e9" dependencies = [ "hex", "radroots_core", diff --git a/Cargo.toml b/Cargo.toml @@ -11,13 +11,13 @@ description = "Radroots trade agreement attestation worker" resolver = "3" [workspace.dependencies] -radroots_event = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_event_codec = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_identity = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_nostr = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_protocol = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_secrets = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } -radroots_trade = { git = "https://github.com/radrootslabs/lib.git", rev = "74f5c91cbc80248df1f51f644004daaf8ae8e9be", version = "=0.1.0-alpha" } +radroots_event = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_event_codec = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_identity = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_nostr = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_protocol = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_secrets = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } +radroots_trade = { git = "https://github.com/radrootslabs/lib.git", rev = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9", version = "=0.1.0-alpha" } [features] default = [] diff --git a/src/identity_storage.rs b/src/identity_storage.rs @@ -5,11 +5,17 @@ use std::path::{Path, PathBuf}; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; -use radroots_secrets::envelope::{Nonce, SealMaterial, SealRequest}; +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, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, + BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, }; use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef}; use zeroize::Zeroize; @@ -21,7 +27,9 @@ use crate::host_identity::{ const RHI_IDENTITY_KEY_SLOT: &str = "rhi_identity"; const WRAPPING_KEY_BYTES: usize = 32; const WRAPPING_NONCE_BYTES: usize = 24; -const WRAPPED_KEY_VERSION: u8 = 1; +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) @@ -104,14 +112,16 @@ impl KeyWrapping for RhiFileKeyWrapping { 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: request.reference().id().as_str().as_bytes(), + aad: aad.as_slice(), }, ) }); @@ -130,26 +140,81 @@ impl KeyWrapping for RhiFileKeyWrapping { request: UnwrapRequest<'a>, ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { Box::pin(async move { - let wrapped = request.wrapped().as_bytes(); - if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != WRAPPED_KEY_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: request.reference().id().as_str().as_bytes(), - }, - ); - key.zeroize(); - SecretMaterial::from_slice( - &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?, + 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)?, @@ -158,6 +223,14 @@ fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> { )) } +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, @@ -198,10 +271,13 @@ pub fn store_encrypted_identity( 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())), ), @@ -225,7 +301,27 @@ pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentit let envelope = EncryptedEnvelope::decode(encoded.as_slice()) .map_err(|_| storage_error(path, "decode encrypted identity"))?; let wrapping = RhiFileKeyWrapping::new(path); - let payload = futures_executor::block_on(envelope.open(&wrapping)) + 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)) @@ -233,6 +329,42 @@ pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentit 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)?; @@ -348,6 +480,36 @@ mod tests { 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] @@ -361,4 +523,64 @@ mod tests { 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"); + } }