commit e89eab671c1856f24c5d6cf53e75d2d42bed5157
parent c7c711b86655a466aecce6c46abb1e5a1861054f
Author: triesap <tyson@radroots.org>
Date: Wed, 5 Aug 2026 22:11:46 +0000
refactor: adopt context-bound envelopes
Diffstat:
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");
+ }
}