From 0091b6fc81a5c25ccff191e0a473ea5bf26d99a6 Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 15:18:19 +0100 Subject: [PATCH 1/3] feat(truapi): disclose a profile to chat contacts, present a contact's by name On top of `profile.present`: - `disclose({ reference })` (method 1, App only) stores the user's own reference with the product that disclosed it; `retract()` (2) withdraws it, and only the discloser may. - `presentContact({ peerIdentity })` (3) names a chat contact; the host looks up the reference that contact's host sent and hands it to the existing `ProfilePlatform::present_profile`. The product never holds a contact's reference, so it cannot read, keep, forward or substitute it. Both kinds of reference live in core storage: `ProfileDisclosure` (wallet-owned) and `ProfileReferencesReceived { product_id }` (cleared with the chat product, like its roster). The chat relay that fills the latter comes next; its write helper is here and tested. The CLI installs a presenter that records each presentation to `TRUAPI_PROFILE_LOG` as a SHA-256 and format prefix, never the reference. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/profile-disclose.md | 10 + rust/crates/truapi-client/src/generated.rs | 92 ++++++- .../tests/golden/host-callbacks.ts | 18 +- rust/crates/truapi-host-cli/src/main.rs | 3 + rust/crates/truapi-host-cli/src/profile.rs | 94 +++++++ rust/crates/truapi-platform/src/lib.rs | 17 ++ rust/crates/truapi-platform/src/mock.rs | 4 + rust/crates/truapi-server/src/runtime.rs | 113 ++++++++- .../truapi-server/src/runtime/profile.rs | 139 +++++++++++ .../crates/truapi-server/src/runtime/tests.rs | 230 ++++++++++++++++++ rust/crates/truapi/src/api/profile.rs | 64 ++++- rust/crates/truapi/src/v01/profile.rs | 72 ++++++ rust/crates/truapi/src/versioned/profile.rs | 9 + 13 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 .changeset/profile-disclose.md create mode 100644 rust/crates/truapi-host-cli/src/profile.rs create mode 100644 rust/crates/truapi-server/src/runtime/profile.rs diff --git a/.changeset/profile-disclose.md b/.changeset/profile-disclose.md new file mode 100644 index 000000000..4b8bed0fc --- /dev/null +++ b/.changeset/profile-disclose.md @@ -0,0 +1,10 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add `profile.disclose`, `profile.retract` and `profile.presentContact`. A product discloses one opaque reference to +the user's chat contacts and may withdraw it; a product names a contact by peer identity and the host presents the +reference that contact disclosed, so no product holds a contact's reference. Disclosed and received references live in +core storage (`ProfileDisclosure`, `ProfileReferencesReceived`); the chat relay that fills the latter is not part of +this change. diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 480da1686..9ff49d68f 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "c8972ad11436a788"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "5e0d5318926dc17f"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1654,6 +1654,87 @@ impl RequestMethod for ProfilePresent { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `profile_disclose` method marker. +pub struct ProfileDisclose; +impl ProfileDisclose { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "disclose", + wire_name: "profile_disclose", + request_type: "truapi::versioned::profile::HostProfileDiscloseRequest", + response_type: "truapi::versioned::profile::HostProfileDiscloseResponse", + error_type: Some("truapi::versioned::profile::HostProfileDiscloseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 1, + }), + }; +} +impl RequestMethod for ProfileDisclose { + type Request = truapi::versioned::profile::HostProfileDiscloseRequest; + type Response = truapi::versioned::profile::HostProfileDiscloseResponse; + type Error = truapi::versioned::profile::HostProfileDiscloseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `profile_retract` method marker. +pub struct ProfileRetract; +impl ProfileRetract { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "retract", + wire_name: "profile_retract", + request_type: "truapi::versioned::profile::HostProfileRetractRequest", + response_type: "truapi::versioned::profile::HostProfileRetractResponse", + error_type: Some("truapi::versioned::profile::HostProfileRetractError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 2, + }), + }; +} +impl RequestMethod for ProfileRetract { + type Request = truapi::versioned::profile::HostProfileRetractRequest; + type Response = truapi::versioned::profile::HostProfileRetractResponse; + type Error = truapi::versioned::profile::HostProfileRetractError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `profile_present_contact` method marker. +pub struct ProfilePresentContact; +impl ProfilePresentContact { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Profile", + method: "present_contact", + wire_name: "profile_present_contact", + request_type: "truapi::versioned::profile::HostProfilePresentContactRequest", + response_type: "truapi::versioned::profile::HostProfilePresentContactResponse", + error_type: Some("truapi::versioned::profile::HostProfilePresentContactError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 3, + }), + }; +} +impl RequestMethod for ProfilePresentContact { + type Request = truapi::versioned::profile::HostProfilePresentContactRequest; + type Response = truapi::versioned::profile::HostProfilePresentContactResponse; + type Error = truapi::versioned::profile::HostProfilePresentContactError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `renderer_render` method marker. pub struct RendererRender; impl RendererRender { @@ -2339,6 +2420,9 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2416,6 +2500,9 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, SigningCreateTransaction::DESCRIPTOR, SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, @@ -2500,6 +2587,9 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ PreimageLookupSubscribe::DESCRIPTOR, PreimageSubmit::DESCRIPTOR, ProfilePresent::DESCRIPTOR, + ProfileDisclose::DESCRIPTOR, + ProfileRetract::DESCRIPTOR, + ProfilePresentContact::DESCRIPTOR, RendererRender::DESCRIPTOR, RendererActionSubscribe::DESCRIPTOR, ResourceAllocationRequest::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 9bfe201ff..22a70100d 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -262,7 +262,19 @@ export type CoreStorageKey = | { tag: "NativeChatProducts"; value: { rootPublicKey: Uint8Array; genesisHash: Uint8Array }; - }; + } + /** + * The profile reference the user disclosed to their chat contacts, with + * the product that disclosed it. Wallet-owned: one per user, whichever + * product wrote it. The reference is a bearer capability. + */ + | { tag: "ProfileDisclosure"; value?: undefined } + /** + * Profile references this product's chat contacts disclosed, newest per + * contact. Product-indexed, like the roster they belong to, so clearing + * the product clears them. The references are bearer capabilities. + */ + | { tag: "ProfileReferencesReceived"; value: { productId: string } }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -1127,6 +1139,10 @@ export const CoreStorageKey: S.Codec = S.lazy( rootPublicKey: S.Bytes(32), genesisHash: S.Bytes(32), }) as S.Codec<{ rootPublicKey: Uint8Array; genesisHash: Uint8Array }>, + ProfileDisclosure: S._void, + ProfileReferencesReceived: S.Struct({ productId: S.str }) as S.Codec<{ + productId: string; + }>, }), ); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 3325b91f9..3c9ab844e 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -24,6 +24,7 @@ mod network; mod platform; mod pocket; mod product_config; +mod profile; mod qr_scanner; mod register_name; mod script_project; @@ -1262,6 +1263,7 @@ async fn run_pairing_host( if let Some(pocket) = pocket_host { pairing_runtime.set_pocket_platform(pocket); } + pairing_runtime.set_profile_platform(profile::CliProfileHost::from_env()); // Resolved before the port is bound, so a bad URL still fails on the argument // rather than half-way through startup - but reported below, once the UI @@ -1796,6 +1798,7 @@ fn build_signing_runtime( if let Some(pocket) = pocket { runtime.set_pocket_platform(pocket); } + runtime.set_profile_platform(profile::CliProfileHost::from_env()); runtime.start_statement_allowance_renewal(); Ok((runtime, platform)) } diff --git a/rust/crates/truapi-host-cli/src/profile.rs b/rust/crates/truapi-host-cli/src/profile.rs new file mode 100644 index 000000000..7d4f4ffe3 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/profile.rs @@ -0,0 +1,94 @@ +//! Profile presenter for the CLI. +//! +//! The CLI has no UI to draw a profile in, so a presentation is accepted and +//! recorded: every `present` (and `present_contact`, which reaches the host as +//! a `present` of the reference the core substituted) is appended to the +//! transcript named by `TRUAPI_PROFILE_LOG`, one JSON object per line, so a +//! battery can assert what the host was handed. +//! +//! The reference is a bearer capability, so the transcript carries its +//! SHA-256 and format prefix, never the reference itself. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use truapi::latest::{HostProfilePresentError, HostProfilePresentRequest}; +use truapi_platform::{ProductContext, ProfilePlatform, async_trait}; + +/// A presenter that shows nothing and remembers everything it was asked. +pub struct CliProfileHost { + transcript: Option, +} + +impl CliProfileHost { + /// Build a presenter recording to `TRUAPI_PROFILE_LOG` when that names a + /// path. The transcript is truncated at startup so a run never reads an + /// earlier run's presentations as its own. + pub fn from_env() -> Arc { + let transcript = std::env::var_os("TRUAPI_PROFILE_LOG").map(PathBuf::from); + if let Some(path) = transcript.as_ref() + && let Err(error) = std::fs::write(path, b"") + { + tracing::warn!(?path, %error, "profile transcript could not be truncated"); + } + Arc::new(Self { transcript }) + } + + fn record(&self, line: serde_json::Value) { + let Some(path) = self.transcript.as_ref() else { + return; + }; + let appended = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes())); + if let Err(error) = appended { + tracing::warn!(?path, %error, "profile transcript could not be appended to"); + } + } +} + +/// The part of a reference before its first `:` or `#`, which names its format +/// without revealing its secret. +fn reference_kind(reference: &str) -> &str { + let end = reference + .find(['#', ':']) + .unwrap_or(reference.len()) + .min(32); + &reference[..end] +} + +#[async_trait] +impl ProfilePlatform for CliProfileHost { + async fn present_profile( + &self, + product: &ProductContext, + request: HostProfilePresentRequest, + ) -> Result<(), HostProfilePresentError> { + let digest = hex::encode(Sha256::digest(request.reference.as_bytes())); + tracing::info!(product = %product.product_id, %digest, "profile presented"); + self.record(serde_json::json!({ + "event": "present", + "product": product.product_id, + "kind": reference_kind(&request.reference), + "sha256": digest, + })); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reference_kind_never_includes_its_secret() { + assert_eq!(reference_kind("seity-contacts:v1:abcd"), "seity-contacts"); + assert_eq!(reference_kind("bafk2bz#00ff"), "bafk2bz"); + assert_eq!(reference_kind(&"a".repeat(80)).len(), 32); + } +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 23557eef9..c539a3146 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1912,6 +1912,19 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, + /// The profile reference the user disclosed to their chat contacts, with + /// the product that disclosed it. Wallet-owned: one per user, whichever + /// product wrote it. The reference is a bearer capability. + #[codec(index = 17)] + ProfileDisclosure, + /// Profile references this product's chat contacts disclosed, newest per + /// contact. Product-indexed, like the roster they belong to, so clearing + /// the product clears them. The references are bearer capabilities. + #[codec(index = 18)] + ProfileReferencesReceived { + /// Chat product whose contacts sent the references. + product_id: String, + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1968,6 +1981,10 @@ pub fn describe_core_storage_key( CoreStorageKey::MainPurseCoinage { .. } => ("MainPurseCoinage", None), CoreStorageKey::NativeChatDevice { .. } => ("NativeChatDevice", None), CoreStorageKey::NativeChatProducts { .. } => ("NativeChatProducts", None), + CoreStorageKey::ProfileDisclosure => ("ProfileDisclosure", None), + CoreStorageKey::ProfileReferencesReceived { product_id } => { + ("ProfileReferencesReceived", Some(product_id)) + } CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs index faf473415..77ed5f2a5 100644 --- a/rust/crates/truapi-platform/src/mock.rs +++ b/rust/crates/truapi-platform/src/mock.rs @@ -827,6 +827,10 @@ fn core_key(key: &CoreStorageKey) -> String { hex_key(root_public_key), hex_key(genesis_hash) ), + CoreStorageKey::ProfileDisclosure => "core:profile-disclosure".to_string(), + CoreStorageKey::ProfileReferencesReceived { product_id } => { + format!("core:profile-references-received:{product_id}") + } CoreStorageKey::NativeChatFileChunk { root_public_key, genesis_hash, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fe9c1b6a9..0d031535d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -28,6 +28,7 @@ mod native_chat; mod pairing_host; pub(crate) mod product_manifest; mod product_subtree; +mod profile; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. @@ -95,7 +96,11 @@ use truapi::versioned::pocket::{ }; use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::versioned::profile::{ - HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, + HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfilePresentContactError, HostProfilePresentContactRequest, + HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, + HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, + HostProfileRetractResponse, }; use truapi::versioned::renderer::{ HostRendererActionSubscribeError, HostRendererActionSubscribeItem, @@ -1407,6 +1412,112 @@ impl Profile for ProductRuntimeHost { .map(|()| HostProfilePresentResponse::V1) .map_err(|error| CallError::Domain(HostProfilePresentError::V1(error))) } + + #[instrument(skip_all, fields(runtime.method = "profile.disclose"))] + async fn disclose( + &self, + _cx: &CallContext, + request: HostProfileDiscloseRequest, + ) -> Result> { + // The user's own profile is disclosed from where they manage it, an + // App, not from a background Worker. + if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let HostProfileDiscloseRequest::V1(request) = request; + if !is_screened_profile_reference(&request.reference) { + return Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::InvalidReference, + ))); + } + let disclosure = profile::Disclosure { + product_id: self.product_id(), + reference: request.reference, + }; + profile::write_disclosure(self.platform.as_ref(), &disclosure) + .await + .map(|()| HostProfileDiscloseResponse::V1) + .map_err(|reason| { + CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::Unknown { reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "profile.retract"))] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { + return Err(CallError::Denied); + } + let unknown = |reason| { + CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::Unknown { reason }, + )) + }; + let storage = self.platform.as_ref(); + match profile::read_disclosure(storage).await.map_err(unknown)? { + None => Ok(HostProfileRetractResponse::V1), + // One product may not withdraw what another disclosed. + Some(disclosure) if disclosure.product_id != self.product_id() => { + Err(CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::NotDiscloser, + ))) + } + Some(_) => profile::clear_disclosure(storage) + .await + .map(|()| HostProfileRetractResponse::V1) + .map_err(unknown), + } + } + + #[instrument(skip_all, fields(runtime.method = "profile.present_contact"))] + async fn present_contact( + &self, + _cx: &CallContext, + request: HostProfilePresentContactRequest, + ) -> Result> { + let platform = self.profile_platform()?; + let HostProfilePresentContactRequest::V1(request) = request; + let domain = |error| CallError::Domain(HostProfilePresentContactError::V1(error)); + let received = profile::received_reference( + self.platform.as_ref(), + &self.product_id(), + &request.peer_identity, + ) + .await + .map_err(|reason| domain(v01::HostProfilePresentContactError::Unknown { reason }))? + .ok_or_else(|| domain(v01::HostProfilePresentContactError::NotShared))?; + // A stored reference passed the same screen when it arrived; check + // again rather than trust storage. + if !is_screened_profile_reference(&received.reference) { + return Err(domain( + v01::HostProfilePresentContactError::InvalidReference, + )); + } + platform + .present_profile( + &self.product, + v01::HostProfilePresentRequest { + reference: received.reference, + }, + ) + .await + .map(|()| HostProfilePresentContactResponse::V1) + .map_err(|error| { + domain(match error { + v01::HostProfilePresentError::InvalidReference => { + v01::HostProfilePresentContactError::InvalidReference + } + v01::HostProfilePresentError::Unknown { reason } => { + v01::HostProfilePresentContactError::Unknown { reason } + } + }) + }) + } } fn is_screened_profile_reference(reference: &str) -> bool { diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs new file mode 100644 index 000000000..43ab4814c --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -0,0 +1,139 @@ +//! Profile disclosure state: the reference the user disclosed to their chat +//! contacts, and the references this product's contacts disclosed to them. +//! +//! Both are bearer capabilities. They live in core storage, never in product +//! storage, and never cross back to a product: `present_contact` names a +//! contact and the host substitutes the reference. + +use parity_scale_codec::{Decode, Encode}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +/// The user's own disclosed reference and the product that disclosed it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct Disclosure { + pub(crate) product_id: String, + pub(crate) reference: String, +} + +/// One contact's disclosed reference, as their host sent it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct ReceivedReference { + pub(crate) peer_identity: [u8; 32], + /// The product on the contact's side that disclosed it. + pub(crate) discloser_product_id: String, + pub(crate) reference: String, +} + +/// Versioned so the slot can change shape without a silent misread. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +enum StoredReferences { + #[codec(index = 0)] + V1(Vec), +} + +/// A contact roster is bounded; so is what the host keeps for it. +#[allow(dead_code, reason = "written by the chat relay, which lands next")] +const MAX_RECEIVED_REFERENCES: usize = 4096; + +fn storage_error(error: impl core::fmt::Debug) -> String { + format!("profile storage failed: {error:?}") +} + +pub(crate) async fn read_disclosure( + storage: &(impl CoreStorage + ?Sized), +) -> Result, String> { + let Some(raw) = storage + .read_core_storage(CoreStorageKey::ProfileDisclosure) + .await + .map_err(storage_error)? + else { + return Ok(None); + }; + Disclosure::decode(&mut raw.as_slice()) + .map(Some) + .map_err(|error| format!("stored profile disclosure is unreadable: {error}")) +} + +pub(crate) async fn write_disclosure( + storage: &(impl CoreStorage + ?Sized), + disclosure: &Disclosure, +) -> Result<(), String> { + storage + .write_core_storage(CoreStorageKey::ProfileDisclosure, disclosure.encode()) + .await + .map_err(storage_error) +} + +pub(crate) async fn clear_disclosure(storage: &(impl CoreStorage + ?Sized)) -> Result<(), String> { + storage + .clear_core_storage(CoreStorageKey::ProfileDisclosure) + .await + .map_err(storage_error) +} + +async fn read_received( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, +) -> Result, String> { + let key = CoreStorageKey::ProfileReferencesReceived { + product_id: product_id.to_string(), + }; + let Some(raw) = storage + .read_core_storage(key) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + match StoredReferences::decode(&mut raw.as_slice()) { + Ok(StoredReferences::V1(entries)) => Ok(entries), + Err(error) => Err(format!("stored profile references are unreadable: {error}")), + } +} + +/// The reference a contact disclosed to this product's user, if any. +pub(crate) async fn received_reference( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, + peer_identity: &[u8; 32], +) -> Result, String> { + Ok(read_received(storage, product_id) + .await? + .into_iter() + .find(|entry| &entry.peer_identity == peer_identity)) +} + +/// Record what a contact's host sent: the newest reference replaces the old +/// one, and `None` (a retraction) removes it. +#[allow(dead_code, reason = "written by the chat relay, which lands next")] +pub(crate) async fn record_received_reference( + storage: &(impl CoreStorage + ?Sized), + product_id: &str, + peer_identity: [u8; 32], + discloser_product_id: String, + reference: Option, +) -> Result<(), String> { + let mut entries = read_received(storage, product_id).await?; + entries.retain(|entry| entry.peer_identity != peer_identity); + if let Some(reference) = reference { + if entries.len() >= MAX_RECEIVED_REFERENCES { + return Err("too many contact profile references".to_string()); + } + entries.push(ReceivedReference { + peer_identity, + discloser_product_id, + reference, + }); + } + let key = CoreStorageKey::ProfileReferencesReceived { + product_id: product_id.to_string(), + }; + if entries.is_empty() { + storage.clear_core_storage(key).await.map_err(storage_error) + } else { + storage + .write_core_storage(key, StoredReferences::V1(entries).encode()) + .await + .map_err(storage_error) + } +} diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 0d89d3f89..926127743 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1324,6 +1324,236 @@ fn profile_present_forwards_screened_references_and_is_unsupported_without_an_ad )); } +/// A product runtime on a shared platform, so several products see one core +/// storage the way they do on a real host. +fn profile_host_on( + platform: Arc, + product: ProductContext, + profile: Option>, +) -> ProductRuntimeHost { + let (host_config, _) = runtime_config(&product.product_id); + let services = RuntimeServices::new( + platform, + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + test_spawner(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.profile_platform = + profile.map(|profile| profile as Arc); + ProductRuntimeHost::from_services(services, adapters, pairing_host, product) +} + +fn disclose( + host: &ProductRuntimeHost, + reference: &str, +) -> Result> { + futures::executor::block_on(Profile::disclose( + host, + &CallContext::default(), + HostProfileDiscloseRequest::V1(v01::HostProfileDiscloseRequest { + reference: reference.to_string(), + }), + )) +} + +fn retract( + host: &ProductRuntimeHost, +) -> Result> { + futures::executor::block_on(Profile::retract( + host, + &CallContext::default(), + HostProfileRetractRequest::V1, + )) +} + +fn present_contact( + host: &ProductRuntimeHost, + peer_identity: [u8; 32], +) -> Result> { + futures::executor::block_on(Profile::present_contact( + host, + &CallContext::default(), + HostProfilePresentContactRequest::V1(v01::HostProfilePresentContactRequest { + peer_identity, + }), + )) +} + +const CONTACTS_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; + +#[test] +fn profile_disclose_stores_the_reference_and_only_its_discloser_may_retract_it() { + let platform = stub_platform(); + let seity = profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + None, + ); + let other = profile_host_on( + platform.clone(), + ProductContext::new("other.dot".to_string()).expect("valid product"), + None, + ); + + assert_eq!( + disclose(&seity, CONTACTS_REFERENCE).expect("an App discloses a screened reference"), + HostProfileDiscloseResponse::V1 + ); + let stored = futures::executor::block_on(profile::read_disclosure(platform.as_ref())) + .expect("readable") + .expect("stored"); + assert_eq!(stored.product_id, "seity.dot"); + assert_eq!(stored.reference, CONTACTS_REFERENCE); + + assert!(matches!( + retract(&other), + Err(CallError::Domain(HostProfileRetractError::V1( + v01::HostProfileRetractError::NotDiscloser + ))) + )); + assert_eq!( + retract(&seity).expect("the discloser retracts"), + HostProfileRetractResponse::V1 + ); + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + None + ); + assert_eq!( + retract(&seity).expect("retracting nothing is not an error"), + HostProfileRetractResponse::V1 + ); +} + +#[test] +fn profile_disclose_is_for_apps_and_screened_references_only() { + let platform = stub_platform(); + let worker = profile_host_on( + platform.clone(), + ProductContext::new_with_execution( + "seity.dot".to_string(), + truapi_platform::ProductExecutionKind::Worker, + ) + .expect("valid product"), + None, + ); + assert!(matches!( + disclose(&worker, CONTACTS_REFERENCE), + Err(CallError::Denied) + )); + assert!(matches!(retract(&worker), Err(CallError::Denied))); + + let app = profile_host_on( + platform.clone(), + ProductContext::new("seity.dot".to_string()).expect("valid product"), + None, + ); + for rejected in [ + String::new(), + "a".repeat(2049), + "seity contacts".to_string(), + ] { + assert!(matches!( + disclose(&app, &rejected), + Err(CallError::Domain(HostProfileDiscloseError::V1( + v01::HostProfileDiscloseError::InvalidReference + ))) + )); + } + assert_eq!( + futures::executor::block_on(profile::read_disclosure(platform.as_ref())).expect("readable"), + None, + "nothing unscreened is stored" + ); +} + +#[test] +fn profile_present_contact_substitutes_the_reference_the_contact_sent() { + let platform = stub_platform(); + let presented = Arc::new(RecordingProfilePlatform::default()); + let chat = profile_host_on( + platform.clone(), + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ); + let alice = [0xa1; 32]; + let bob = [0xb0; 32]; + // What the relay does when Alice's host sends her reference. + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + Some(CONTACTS_REFERENCE.to_string()), + )) + .expect("recorded"); + + assert_eq!( + present_contact(&chat, alice).expect("a contact who shared is presented"), + HostProfilePresentContactResponse::V1 + ); + assert_eq!( + presented + .presented + .lock() + .expect("presented mutex poisoned") + .as_slice(), + [("egui-chat.dot".to_string(), CONTACTS_REFERENCE.to_string())], + "the host presents the stored reference, attributed to the caller" + ); + assert!(matches!( + present_contact(&chat, bob), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + // Another product's contacts are not this product's. + let other = profile_host_on( + platform.clone(), + ProductContext::new("other-chat.dot".to_string()).expect("valid product"), + Some(presented.clone()), + ); + assert!(matches!( + present_contact(&other, alice), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + // A retraction from Alice's host removes what this host holds. + futures::executor::block_on(profile::record_received_reference( + platform.as_ref(), + "egui-chat.dot", + alice, + "seity.dot".to_string(), + None, + )) + .expect("recorded"); + assert!(matches!( + present_contact(&chat, alice), + Err(CallError::Domain(HostProfilePresentContactError::V1( + v01::HostProfilePresentContactError::NotShared + ))) + )); + + assert!(matches!( + present_contact( + &profile_host_on( + platform, + ProductContext::new("egui-chat.dot".to_string()).expect("valid product"), + None, + ), + alice, + ), + Err(CallError::Unsupported) + )); +} + #[test] fn chain_follow_ids_are_scoped_per_product_core() { let (host_config, product) = runtime_config("same.dot"); diff --git a/rust/crates/truapi/src/api/profile.rs b/rust/crates/truapi/src/api/profile.rs index ab6decdc3..f67e4f811 100644 --- a/rust/crates/truapi/src/api/profile.rs +++ b/rust/crates/truapi/src/api/profile.rs @@ -1,7 +1,11 @@ //! Unified [`Profile`] trait. use crate::versioned::profile::{ - HostProfilePresentError, HostProfilePresentRequest, HostProfilePresentResponse, + HostProfileDiscloseError, HostProfileDiscloseRequest, HostProfileDiscloseResponse, + HostProfilePresentContactError, HostProfilePresentContactRequest, + HostProfilePresentContactResponse, HostProfilePresentError, HostProfilePresentRequest, + HostProfilePresentResponse, HostProfileRetractError, HostProfileRetractRequest, + HostProfileRetractResponse, }; use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; @@ -33,4 +37,62 @@ pub trait Profile: Send + Sync { ) -> Result> { Err(CallError::unavailable()) } + /// Give the user's chat contacts this reference to their profile. + /// + /// The host stores it as the user's own and relays it to each contact, + /// replacing whatever it sent before; the product never learns who they + /// are. App executions only. A reference this core cannot screen is + /// `InvalidReference`. + /// + /// ```ts + /// const result = await truapi.profile.disclose({ + /// reference: "seity-contacts:v1:" + "00".repeat(64), + /// }); + /// console.log("profile disclosed:", result); + /// ``` + #[wire(id = 1)] + async fn disclose( + &self, + _cx: &CallContext, + _request: HostProfileDiscloseRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Withdraw the reference this product disclosed. Contacts are told to + /// drop what they hold. A product that did not disclose it is refused. + /// + /// ```ts + /// const result = await truapi.profile.retract(); + /// console.log("profile retracted:", result); + /// ``` + #[wire(id = 2)] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Show a chat contact's profile in host-owned UI. + /// + /// The product names the contact; the host looks up the reference that + /// contact shared and presents it as `present` would. The reference never + /// reaches the product. A contact who shared nothing is `NotShared`. + /// + /// ```ts + /// const result = await truapi.profile.presentContact({ + /// peerIdentity: new Uint8Array(32), + /// }); + /// console.log("contact profile presentation:", result); + /// ``` + #[wire(id = 3)] + async fn present_contact( + &self, + _cx: &CallContext, + _request: HostProfilePresentContactRequest, + ) -> Result> { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/v01/profile.rs b/rust/crates/truapi/src/v01/profile.rs index 17786331e..50328696b 100644 --- a/rust/crates/truapi/src/v01/profile.rs +++ b/rust/crates/truapi/src/v01/profile.rs @@ -34,3 +34,75 @@ pub enum HostProfilePresentError { reason: String, }, } + +/// Request to give the user's chat contacts a profile reference. +/// +/// The reference is a bearer capability for everyone the host relays it to. +/// The host stores it as the user's own and never parses it. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfileDiscloseRequest { + /// Opaque profile reference, e.g. a Seity contacts reference. + pub reference: String, +} + +impl fmt::Debug for HostProfileDiscloseRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HostProfileDiscloseRequest") + .field("reference", &"[REDACTED]") + .finish() + } +} + +/// Profile disclosure failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfileDiscloseError { + /// The reference is empty, too long, or not printable ASCII. + InvalidReference, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// Profile retraction failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfileRetractError { + /// Another product disclosed the reference the host holds. + NotDiscloser, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} + +/// Request to show a chat contact's profile in host-owned UI. +/// +/// The product names the contact, never a reference: the host looks up the +/// reference that contact's host sent, so the product cannot read, keep or +/// substitute it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct HostProfilePresentContactRequest { + /// The contact's authenticated root identity, as the chat API names it. + pub peer_identity: [u8; 32], +} + +/// Contact profile presentation failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum HostProfilePresentContactError { + /// This contact has not shared a profile with the user. + NotShared, + /// The host holds a reference it cannot parse. + InvalidReference, + /// Catch-all. + Unknown { + /// Human-readable reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned/profile.rs b/rust/crates/truapi/src/versioned/profile.rs index 00903f6f8..2c23bbbce 100644 --- a/rust/crates/truapi/src/versioned/profile.rs +++ b/rust/crates/truapi/src/versioned/profile.rs @@ -6,4 +6,13 @@ truapi_macros::versioned_type! { pub enum HostProfilePresentRequest { V1 => v01::HostProfilePresentRequest } pub enum HostProfilePresentResponse { V1 } pub enum HostProfilePresentError { V1 => v01::HostProfilePresentError } + pub enum HostProfileDiscloseRequest { V1 => v01::HostProfileDiscloseRequest } + pub enum HostProfileDiscloseResponse { V1 } + pub enum HostProfileDiscloseError { V1 => v01::HostProfileDiscloseError } + pub enum HostProfileRetractRequest { V1 } + pub enum HostProfileRetractResponse { V1 } + pub enum HostProfileRetractError { V1 => v01::HostProfileRetractError } + pub enum HostProfilePresentContactRequest { V1 => v01::HostProfilePresentContactRequest } + pub enum HostProfilePresentContactResponse { V1 } + pub enum HostProfilePresentContactError { V1 => v01::HostProfilePresentContactError } } From 298cf4286c07cf0c5dc40116d3e957cc1caf5d1b Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 15:35:45 +0100 Subject: [PATCH 2/3] feat(chat): relay disclosed profile references host to host The Chat v2 half of profile disclosure, built on the actor's host-private outbox that payments and rich files already use: - Wire: `ProfileReference { discloser_product_id, reference: Option }` at V2 content index 21 (a new shared wire index; needs agreeing with native Chat before it ships). - Send: `publish_profile_reference`, run on Initialize, seals one message per ready peer whose watermark differs from the user's disclosure, as an `OutgoingKind::ProfileReference` the product submits as opaque ciphertext. The watermark (trailing `profile_shared`, absent from older snapshots) advances at queue time; a withdrawal is sent to peers that hold one. - Receive: the frame is classified and screened, stored per peer in `ProfileReferencesReceived` for the chat product, and cut from the opened plaintext (forcing a re-encode), so the product never sees it. Only live frames update it, never compacted history. Products cannot prepare one. Co-Authored-By: Claude Opus 5.5 (1M context) --- rust/crates/truapi-chat-v2/src/lib.rs | 79 +++++++ .../truapi-server/src/runtime/chat_device.rs | 45 ++++ .../truapi-server/src/runtime/native_chat.rs | 1 + .../src/runtime/native_chat/actor.rs | 25 ++- .../src/runtime/native_chat/actor/history.rs | 43 +++- .../src/runtime/native_chat/actor/profile.rs | 209 ++++++++++++++++++ .../src/runtime/native_chat/actor/receive.rs | 13 ++ .../src/runtime/native_chat/actor/tests.rs | 194 ++++++++++++++++ .../truapi-server/src/runtime/profile.rs | 2 - 9 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 72317d31a..583df5882 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -377,6 +377,13 @@ pub enum V2ChatMessageContent { request_id: String, device: V2PeerDevice, }, + /// A profile reference the sender's host discloses to this contact, or + /// `None` to withdraw it. Host-originated and host-consumed: products + /// never send or see it. V2 wire enum index 21. + ProfileReference { + discloser_product_id: String, + reference: Option, + }, /// The envelope was valid enough to recover id/timestamp, but the versioned /// content wrapper is not yet represented by this SDK surface. UnsupportedVersion { version_index: u8 }, @@ -977,6 +984,29 @@ pub fn encode_device_removed_message( }) } +/// Encode a v2 profile-reference message (content index 21). +pub fn encode_profile_reference_message( + message_id: &str, + timestamp: u64, + discloser_product_id: &str, + reference: Option<&str>, +) -> Result, ChatError> { + encode_message(message_id, timestamp, |out| { + out.push(21); + encode_string(out, discloser_product_id)?; + match reference { + Some(reference) => { + out.push(1); + encode_string(out, reference) + } + None => { + out.push(0); + Ok(()) + } + } + }) +} + /// Encode a v2 compacted-messages reference (content index 19). pub fn encode_compacted_messages_message( message_id: &str, @@ -1272,6 +1302,23 @@ pub fn decode_message(data: &[u8]) -> Result { }, } } + 21 => { + let discloser_product_id = cursor.read_string("discloser_product_id")?; + let reference = match cursor.read_u8("reference_option")? { + 0 => None, + 1 => Some(cursor.read_string("reference")?), + value => { + return Err(ChatError::InvalidEncoding(format!( + "invalid profile reference option {value}" + ))); + } + }; + cursor.finish()?; + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } + } index => V2ChatMessageContent::UnsupportedContent { content_index: index, }, @@ -3598,6 +3645,38 @@ mod tests { } ); } + #[test] + fn profile_reference_wire_roundtrips_disclosure_and_withdrawal() { + let disclosed = encode_profile_reference_message( + "profile", + 5, + "seity.dot", + Some("seity-contacts:v1:00"), + ) + .unwrap(); + let decoded = decode_message(&disclosed).unwrap(); + assert_eq!(decoded.message_id, "profile"); + assert_eq!( + decoded.content, + V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: Some("seity-contacts:v1:00".into()), + } + ); + let withdrawn = encode_profile_reference_message("profile", 6, "seity.dot", None).unwrap(); + assert_eq!( + decode_message(&withdrawn).unwrap().content, + V2ChatMessageContent::ProfileReference { + discloser_product_id: "seity.dot".into(), + reference: None, + } + ); + // A malformed option byte is refused, not guessed at. + let mut bad = withdrawn.clone(); + *bad.last_mut().unwrap() = 7; + assert!(decode_message(&bad).is_err()); + } + #[test] fn current_multi_device_wire_roundtrips() { let added = encode_device_added_message("add", 1, &[1; 32], &[2; 32]).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/chat_device.rs b/rust/crates/truapi-server/src/runtime/chat_device.rs index a83513c59..660abb352 100644 --- a/rust/crates/truapi-server/src/runtime/chat_device.rs +++ b/rust/crates/truapi-server/src/runtime/chat_device.rs @@ -105,6 +105,30 @@ pub(crate) enum OpenedDeviceMessage { /// Native notification metadata, never ordinary guest content. This Host has /// no mobile push provider; retain only ordering and replay evidence. PushToken { timestamp: u64, digest: [u8; 32] }, + /// A profile reference the peer's host disclosed, or `None` withdrawing it. + /// Host-consumed: the reference is a bearer capability and never reaches + /// the product. + ProfileReference(ProfileReferenceFrame), +} + +/// A screened profile reference frame. +pub(crate) struct ProfileReferenceFrame { + /// Native message identifier. + pub(crate) message_id: String, + /// Sender timestamp. + pub(crate) timestamp: u64, + /// Product on the sender's side that disclosed the reference. + pub(crate) discloser_product_id: String, + /// The reference, or `None` for a withdrawal. + pub(crate) reference: Option, +} + +/// The same bound and alphabet the core screens a product's reference with. +const MAX_PROFILE_REFERENCE_BYTES: usize = 2048; +const MAX_PROFILE_PRODUCT_ID_BYTES: usize = 256; + +fn screened_ascii(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && value.bytes().all(|byte| byte.is_ascii_graphic()) } /// Lifecycle metadata to validate against durable Host roster and replay state. @@ -544,6 +568,27 @@ pub(crate) fn classify_message( } V2ChatMessageContent::ContactAdded => DeviceLifecycle::ContactAdded, V2ChatMessageContent::LeftChat => DeviceLifecycle::LeftChat, + V2ChatMessageContent::ProfileReference { + discloser_product_id, + reference, + } => { + validate_id(&message.message_id)?; + if !screened_ascii(&discloser_product_id, MAX_PROFILE_PRODUCT_ID_BYTES) + || reference.as_deref().is_some_and(|reference| { + !screened_ascii(reference, MAX_PROFILE_REFERENCE_BYTES) + }) + { + return Err(ChatDeviceError::InvalidEncoding); + } + return Ok(OpenedDeviceMessage::ProfileReference( + ProfileReferenceFrame { + message_id: message.message_id, + timestamp: message.timestamp, + discloser_product_id, + reference, + }, + )); + } ordinary => { validate_ordinary(&ordinary)?; return Ok(OpenedDeviceMessage::Ordinary(core::mem::take(bytes))); diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index 3207a458e..708bb5820 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -392,6 +392,7 @@ impl NativeChatRegistry { match &mut request { Request::Initialize => { chat.drive_files(&context).await?; + chat.publish_profile_reference(&context).await?; } Request::Bind { username } => { binding = Some(chat.bind(&context, std::mem::take(username)).await?); diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs index 273548329..e2787ce7d 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor.rs @@ -4,6 +4,7 @@ mod files; mod history; +mod profile; mod receive; #[cfg(test)] mod tests; @@ -146,6 +147,8 @@ enum OutgoingKind { Payment([u8; 32]), Acknowledgment, Rich([u8; 32]), + /// Appended last so earlier snapshots still decode. + ProfileReference([u8; 32]), } #[derive(Clone, Encode, Decode)] @@ -210,6 +213,8 @@ struct State { rich_messages: Vec, marker: [u8; 4], boundary: BoundaryState, + /// Trailing, and absent from snapshots written before it existed. + profile_shared: Vec, } impl State { @@ -232,6 +237,7 @@ impl State { rich_messages: Vec::new(), marker: *b"HCN3", boundary: BoundaryState::default(), + profile_shared: Vec::new(), }) } fn peer(&self, identity: &[u8; 32]) -> Result<&Peer, Error> { @@ -303,6 +309,12 @@ impl Decode for State { } BoundaryState::decode(input)? }; + // Added after the boundary state: a snapshot that ends here predates it. + let profile_shared = if input.remaining_len()? == Some(0) { + Vec::new() + } else { + >::decode(input)? + }; Ok(Self { secret, index, @@ -321,6 +333,7 @@ impl Decode for State { rich_messages, marker: *b"HCN3", boundary, + profile_shared, }) } } @@ -625,7 +638,9 @@ impl NativeChatActor { state.boundary.legacy_pending || matches!( entry.kind, - OutgoingKind::Payment(_) | OutgoingKind::Rich(_) + OutgoingKind::Payment(_) + | OutgoingKind::Rich(_) + | OutgoingKind::ProfileReference(_) ) }) .map(|entry| entry.prepared(state)) @@ -696,6 +711,9 @@ impl NativeChatActor { state .outbox .retain(|entry| matches!(entry.kind, OutgoingKind::Payment(_))); + // Profile references queued before the migration are dropped with + // it, so forget what was sent and let the reconcile resend. + state.profile_shared.clear(); state.messages.clear(); state.acknowledgments.clear(); state.sent.clear(); @@ -1183,7 +1201,10 @@ impl NativeChatActor { .filter(|entry| { matches!(entry.kind, OutgoingKind::Payment(_)) || (!state.boundary.legacy_pending - && matches!(entry.kind, OutgoingKind::Rich(_))) + && matches!( + entry.kind, + OutgoingKind::Rich(_) | OutgoingKind::ProfileReference(_) + )) }) .cloned() .collect::>() diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs index ade7e3fea..cca34324e 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/history.rs @@ -184,6 +184,10 @@ impl NativeChatActor { let mut rich = Vec::new(); let mut bytes_seen = 0usize; let mut had_history = false; + // Profile references are the Host's, not the product's: collected here, + // stored after the open commits, and cut out of what the product sees. + let mut profile_references = Vec::new(); + let mut stripped = false; while let Some((mut bytes, depth)) = work.pop() { context.require_current()?; bytes_seen = bytes_seen @@ -279,6 +283,17 @@ impl NativeChatActor { expanded.push(core::mem::take(&mut *bytes)); } } + OpenedDeviceMessage::ProfileReference(frame) => { + if !super::receive::valid_peer_timestamp(frame.timestamp, current_unix_secs()) { + return Err(Error::InvalidStatement); + } + // Never forwarded, whatever the depth; only a live frame + // updates what this Host holds, never compacted history. + stripped = true; + if depth == 0 { + profile_references.push(frame); + } + } } } let rich = self.prepare_rich(context, peer, &request_id, rich).await?; @@ -292,9 +307,11 @@ impl NativeChatActor { files::merge_received(state, rich) }) .await?; + self.record_profile_references(context, peer, profile_references) + .await?; // Preserve the original canonical request when no HOP expansion was // needed, except references already transferred by legacy migration. - let plaintext = if had_history { + let plaintext = if had_history || stripped { wire::encode_transport_request_plaintext(&request_id, &expanded) .map_err(|_| Error::InvalidStatement)? } else { @@ -362,9 +379,33 @@ impl NativeChatActor { validate_deliveries(&state.boundary.history) }) .await?; + self.record_profile_references(context, peer, profile_references) + .await?; self.continue_open(context, id, 0).await } + /// Keep the newest profile reference each frame carries for `peer`, in + /// this product's received-reference slot. `None` withdraws it. + async fn record_profile_references( + &self, + context: &NativeChatContext, + peer: [u8; 32], + frames: Vec, + ) -> Result<(), Error> { + for frame in frames { + crate::runtime::profile::record_received_reference( + &*context.services.platform, + &self.product, + peer, + frame.discloser_product_id, + frame.reference, + ) + .await + .map_err(|_| Error::StorageUnavailable)?; + } + Ok(()) + } + pub(in crate::runtime::native_chat) async fn continue_open( self: &Arc, context: &NativeChatContext, diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs new file mode 100644 index 000000000..4097a5d10 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: AGPL-3.0-only +//! Host-originated profile references: the user's disclosed reference, sealed +//! to each established peer's devices and handed to the product as opaque +//! prepared statements, like payments and rich files. +//! +//! A per-peer watermark records what this Host last queued for that peer, so +//! the initial share, a new contact, a replacement and a withdrawal are one +//! reconcile: every peer whose watermark differs from the disclosure is sent +//! the disclosure. The watermark advances when the message is queued. + +use super::*; +use crate::runtime::native_chat::background::require_authorized; +use crate::runtime::profile::{Disclosure, read_disclosure}; + +/// What this Host last queued to one peer. +#[derive(Clone, PartialEq, Eq, Encode, Decode)] +pub(super) struct ProfileWatermark { + pub(super) peer: [u8; 32], + /// Digest of the disclosure sent, identifying it without keeping it. + pub(super) digest: [u8; 32], + /// Product that disclosed it, repeated on a withdrawal. + pub(super) discloser_product_id: String, +} + +fn disclosure_digest(disclosure: &Disclosure) -> [u8; 32] { + hash( + &( + b"native-chat-profile-v1", + &disclosure.product_id, + &disclosure.reference, + ) + .encode(), + ) +} + +/// What one peer should be sent now: the disclosure, or a withdrawal of the +/// one it holds. `None` when it already holds what it should. +fn wanted( + disclosure: Option<&Disclosure>, + current: Option<&ProfileWatermark>, +) -> Option<(String, Option, Option<[u8; 32]>)> { + match (disclosure, current) { + (Some(disclosure), current) => { + let digest = disclosure_digest(disclosure); + if current.is_some_and(|watermark| watermark.digest == digest) { + return None; + } + Some(( + disclosure.product_id.clone(), + Some(disclosure.reference.clone()), + Some(digest), + )) + } + (None, Some(watermark)) => Some((watermark.discloser_product_id.clone(), None, None)), + (None, None) => None, + } +} + +impl NativeChatActor { + /// Queue a profile reference (or withdrawal) for every ready peer whose + /// watermark differs from the user's current disclosure. + pub(in crate::runtime::native_chat) async fn publish_profile_reference( + self: &Arc, + context: &NativeChatContext, + ) -> Result { + context.require_current()?; + if self + .store + .read(|state| state.boundary.legacy_pending) + .await? + { + return Ok(false); + } + let disclosure = read_disclosure(&*context.services.platform) + .await + .map_err(|_| Error::StorageUnavailable)?; + let stale = self + .store + .read({ + let disclosure = disclosure.clone(); + move |state| { + state + .peers + .iter() + .filter(|peer| peer.ready()) + .filter(|peer| { + let current = state + .profile_shared + .iter() + .find(|watermark| watermark.peer == peer.identity); + wanted(disclosure.as_ref(), current).is_some() + }) + .map(|peer| peer.identity) + .collect::>() + } + }) + .await?; + if stale.is_empty() { + return Ok(false); + } + require_authorized(context, &self.product).await?; + let actor = self.clone(); + let valid = context.session_valid.clone(); + self.store + .update(move |state| { + if !valid() { + return Err(Error::NotConnected); + } + for identity in stale { + let peer = state.peer(&identity)?.clone(); + if !peer.ready() { + continue; + } + let current = state + .profile_shared + .iter() + .find(|watermark| watermark.peer == identity); + let Some((discloser, reference, digest)) = wanted(disclosure.as_ref(), current) + else { + continue; + }; + let tag = hash(&(identity, &discloser, &reference).encode()); + let request_id = format!("profile-{}", hex::encode(&tag[..8])); + let bytes = wire::encode_profile_reference_message( + &request_id, + current_unix_secs().saturating_mul(1000), + &discloser, + reference.as_deref(), + ) + .map_err(|_| Error::InvalidRequest)?; + let messages = Zeroizing::new(vec![bytes]); + let statement = actor.multi_statement( + state, + &peer, + &peer.active_devices(), + &request_id, + &messages, + )?; + // Only the newest disclosure is worth delivering. + state.outbox.retain(|entry| { + entry.peer != identity + || !matches!(entry.kind, OutgoingKind::ProfileReference(_)) + }); + state.queue(Outgoing { + peer: identity, + request_id, + digest: hash(&messages.encode()), + kind: OutgoingKind::ProfileReference(tag), + roster_revision: peer.revision, + statement, + last_attempt: 0, + })?; + state + .profile_shared + .retain(|watermark| watermark.peer != identity); + if let Some(digest) = digest { + state.profile_shared.push(ProfileWatermark { + peer: identity, + digest, + discloser_product_id: discloser, + }); + } + } + Ok(()) + }) + .await?; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disclosure(reference: &str) -> Disclosure { + Disclosure { + product_id: "seity.dot".into(), + reference: reference.into(), + } + } + + #[test] + fn a_peer_is_sent_only_what_it_does_not_hold() { + let current = disclosure("seity-contacts:v1:aa"); + let held = ProfileWatermark { + peer: [1; 32], + digest: disclosure_digest(¤t), + discloser_product_id: "seity.dot".into(), + }; + assert!(wanted(Some(¤t), Some(&held)).is_none()); + let (_, reference, _) = wanted(Some(&disclosure("seity-contacts:v1:bb")), Some(&held)) + .expect("a replacement is sent"); + assert_eq!(reference.as_deref(), Some("seity-contacts:v1:bb")); + let (discloser, reference, digest) = + wanted(None, Some(&held)).expect("a withdrawal is sent to a holder"); + assert_eq!( + (discloser.as_str(), reference, digest), + ("seity.dot", None, None) + ); + assert!( + wanted(None, None).is_none(), + "nothing to withdraw from a new peer" + ); + assert!( + wanted(Some(¤t), None).is_some(), + "a new peer is sent the disclosure" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs index 64c65dd69..e3884625e 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/receive.rs @@ -959,6 +959,19 @@ fn exchange_digest(messages: &[OpenedDeviceMessage]) -> Result<[u8; 32], Error> hasher.update(digest); continue; } + OpenedDeviceMessage::ProfileReference(frame) => { + hasher.update(&[6]); + let bytes = ( + frame.message_id.as_str(), + frame.timestamp, + frame.discloser_product_id.as_str(), + frame.reference.as_deref(), + ) + .encode(); + hasher.update(&(bytes.len() as u32).to_le_bytes()); + hasher.update(&bytes); + continue; + } OpenedDeviceMessage::DeviceControl(control) => { let mut bytes = (control.message_id.as_str(), control.timestamp).encode(); match &control.content { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs index 9a93a7743..51d17d0cf 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/tests.rs @@ -1587,3 +1587,197 @@ fn state_decode_accepts_old_prefix_and_tagged_extension_but_rejects_corruption() assert!(State::decode(&mut truncated_extension.as_slice()).is_err()); assert!(State::decode(&mut &legacy[..legacy.len() - 1]).is_err()); } + +const PROFILE_REFERENCE: &str = "seity-contacts:v1:5c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb535c9584ba6e565351723d57394780b31b4c2156123e1269c4724ae5f01258bb53"; + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[test] +fn a_disclosed_profile_reference_is_sealed_once_per_peer_and_withdrawn_on_retract() { + block_on(async { + use crate::runtime::profile::{Disclosure, clear_disclosure, write_disclosure}; + let fixture = Fixture::new(); + set_product_grants( + &fixture.platform, + PRODUCT, + truapi_platform::PermissionAuthorizationStatus::Authorized, + ) + .await; + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let profile_entries = |state: &State| { + state + .outbox + .iter() + .filter(|entry| matches!(entry.kind, OutgoingKind::ProfileReference(_))) + .count() + }; + + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "nothing disclosed, nothing sent" + ); + + write_disclosure( + fixture.platform.as_ref(), + &Disclosure { + product_id: "seity.dot".into(), + reference: PROFILE_REFERENCE.into(), + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + let view = actor.public_view(&fixture.context, vec![]).await.unwrap(); + assert_eq!( + view.prepared.len(), + 1, + "one opaque statement for the product to submit" + ); + assert_eq!(view.prepared[0].peer_identity, identity.account); + assert!(view.prepared[0].requires_ack); + assert!( + !contains( + &view.prepared[0].statement.encode(), + PROFILE_REFERENCE.as_bytes() + ), + "the product carries ciphertext, never the reference" + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "the watermark stops a second send of the same disclosure" + ); + + write_disclosure( + fixture.platform.as_ref(), + &Disclosure { + product_id: "seity.dot".into(), + reference: format!("{PROFILE_REFERENCE}ff"), + }, + ) + .await + .unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + assert_eq!( + actor.store.read(profile_entries).await.unwrap(), + 1, + "a replacement supersedes the queued disclosure" + ); + + clear_disclosure(fixture.platform.as_ref()).await.unwrap(); + assert!( + actor + .publish_profile_reference(&fixture.context) + .await + .unwrap(), + "a holder is sent the withdrawal" + ); + assert!( + actor + .store + .read(|state| state.profile_shared.is_empty()) + .await + .unwrap() + ); + assert!( + !actor + .publish_profile_reference(&fixture.context) + .await + .unwrap() + ); + }); +} + +#[test] +fn a_received_profile_reference_is_kept_by_the_host_and_cut_from_what_the_product_opens() { + block_on(async { + use crate::runtime::profile::received_reference; + let fixture = Fixture::new(); + let actor = fixture.actor().await; + let identity = IdentityFixture::new(); + let peer = DeviceFixture::new(1); + seed_peer(&actor, &identity, &[&peer]).await; + let registry = NativeChatRegistry::default(); + + let text = wire::encode_text_message("hello", fixture.timestamp, "hi").unwrap(); + let frame = wire::encode_profile_reference_message( + "profile-1", + fixture.timestamp, + "seity.dot", + Some(PROFILE_REFERENCE), + ) + .unwrap(); + let plaintext = + wire::encode_transport_request_plaintext("incoming-profile", &[frame, text.clone()]) + .unwrap(); + let packet = native_packet(&actor, &identity, &peer, &plaintext, false, false); + let (opened, _) = actor + .open_statement(&fixture.context, ®istry, packet) + .await + .unwrap(); + assert_eq!(opened.len(), 1); + assert!(!contains( + &opened[0].plaintext, + PROFILE_REFERENCE.as_bytes() + )); + let wire::V2StatementTransportData::Request { messages, .. } = + wire::decode_transport_plaintext(&opened[0].plaintext).unwrap() + else { + panic!("the product still receives the request to acknowledge"); + }; + assert_eq!( + messages, + vec![text], + "ordinary content passes through untouched" + ); + let held = received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + .await + .unwrap() + .expect("the host keeps what the contact disclosed"); + assert_eq!(held.reference, PROFILE_REFERENCE); + assert_eq!(held.discloser_product_id, "seity.dot"); + + let withdrawal = wire::encode_profile_reference_message( + "profile-2", + fixture.timestamp, + "seity.dot", + None, + ) + .unwrap(); + let plaintext = + wire::encode_transport_request_plaintext("incoming-withdrawal", &[withdrawal]).unwrap(); + let packet = native_packet(&actor, &identity, &peer, &plaintext, false, false); + actor + .open_statement(&fixture.context, ®istry, packet) + .await + .unwrap(); + assert_eq!( + received_reference(fixture.platform.as_ref(), PRODUCT, &identity.account) + .await + .unwrap(), + None + ); + }); +} diff --git a/rust/crates/truapi-server/src/runtime/profile.rs b/rust/crates/truapi-server/src/runtime/profile.rs index 43ab4814c..dc9e9de25 100644 --- a/rust/crates/truapi-server/src/runtime/profile.rs +++ b/rust/crates/truapi-server/src/runtime/profile.rs @@ -32,7 +32,6 @@ enum StoredReferences { } /// A contact roster is bounded; so is what the host keeps for it. -#[allow(dead_code, reason = "written by the chat relay, which lands next")] const MAX_RECEIVED_REFERENCES: usize = 4096; fn storage_error(error: impl core::fmt::Debug) -> String { @@ -105,7 +104,6 @@ pub(crate) async fn received_reference( /// Record what a contact's host sent: the newest reference replaces the old /// one, and `None` (a retraction) removes it. -#[allow(dead_code, reason = "written by the chat relay, which lands next")] pub(crate) async fn record_received_reference( storage: &(impl CoreStorage + ?Sized), product_id: &str, From e89fd093a9015da74516c35cd3727167ca018f0b Mon Sep 17 00:00:00 2001 From: Corey Hathaway Date: Sat, 26 Sep 2026 16:25:45 +0100 Subject: [PATCH 3/3] docs(rfc): profile disclosure to chat contacts The RFC for `disclose` / `retract` / `presentContact` and the Chat v2 relay on the host-private outbox, unnumbered and in draft. Its open questions are the known gaps of the prototype: the content-type index, several disclosing products, consent, other devices, reconcile timing, and where references are resolved. Each gap is also marked where it lives in the code. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/rfcs/profile-disclosure.md | 161 ++++++++++++++++++ rust/crates/truapi-chat-v2/src/lib.rs | 2 + .../tests/golden/host-callbacks.ts | 3 + rust/crates/truapi-platform/src/lib.rs | 3 + rust/crates/truapi-server/src/runtime.rs | 1 + .../truapi-server/src/runtime/native_chat.rs | 2 + .../src/runtime/native_chat/actor/profile.rs | 3 + 7 files changed, 175 insertions(+) create mode 100644 docs/rfcs/profile-disclosure.md diff --git a/docs/rfcs/profile-disclosure.md b/docs/rfcs/profile-disclosure.md new file mode 100644 index 000000000..3c3624cbf --- /dev/null +++ b/docs/rfcs/profile-disclosure.md @@ -0,0 +1,161 @@ +--- +title: "Profile disclosure to chat contacts" +owner: "@corey-hathaway" +status: draft +--- + +# RFC — Profile disclosure to chat contacts + +## Summary + +A product hands the host one opaque profile reference for the user's chat contacts. The host relays it to each +contact over Chat v2 and keeps the references contacts relay back. A chat product then asks the host to show a +contact's profile by naming the contact, and the host presents the reference that contact disclosed through the +existing `profile.present` path. No product holds another user's reference. + +## Motivation + +`profile.present` shows a profile from a reference the calling product already holds. A chat product has no honest +way to hold one for a contact: the reference is a bearer capability, so a product that carries it can read, keep and +forward the profile, and can show any reference against any contact. The reference has to travel host to host and stay +inside the hosts, and Chat v2 leaves ordinary delivery to products. + +## Requirements + +- **Blind:** the disclosing product never learns who the contacts are. +- **Sealed:** no product reads a reference in transit or at rest, on either side. +- **Bound:** a presented profile is the one that contact's host sent, not one a product chose. +- **Stable:** a change to the referenced profile does not require relaying again. +- **Withdrawable:** the discloser can retract, and contacts drop what they hold. + +## Approach + +The design has four parts: + +- The `Profile` trait gains `disclose`, `retract` and `present_contact`. +- Core storage holds the user's disclosure and the references received per chat product. +- The Chat v2 actor relays disclosures through its host-private outbox. +- `present_contact` substitutes the stored reference into `present`. + +### Trait + +```rust +#[wire_trait(id = 20)] +#[crate::async_trait] +pub trait Profile: Send + Sync { + /// Show the referenced profile in host-owned UI. + #[wire(id = 0)] + async fn present( + &self, + _cx: &CallContext, + _request: HostProfilePresentRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Give the user's chat contacts this reference. App executions only. + #[wire(id = 1)] + async fn disclose( + &self, + _cx: &CallContext, + _request: HostProfileDiscloseRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Withdraw the reference this product disclosed. + #[wire(id = 2)] + async fn retract( + &self, + _cx: &CallContext, + _request: HostProfileRetractRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + + /// Show the profile a chat contact disclosed. + #[wire(id = 3)] + async fn present_contact( + &self, + _cx: &CallContext, + _request: HostProfilePresentContactRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} + +pub struct HostProfileDiscloseRequest { + /// Opaque reference, screened like a `present` reference. + pub reference: String, +} +pub enum HostProfileDiscloseError { + /// The reference is empty, too long, or not printable ASCII. + InvalidReference, + /// Catch-all. + Unknown { reason: String }, +} +pub enum HostProfileRetractError { + /// Another product disclosed the reference the host holds. + NotDiscloser, + /// Catch-all. + Unknown { reason: String }, +} +pub struct HostProfilePresentContactRequest { + /// The contact's authenticated root identity, as the Chat v2 API names it. + pub peer_identity: [u8; 32], +} +pub enum HostProfilePresentContactError { + /// The contact has not disclosed a profile to the user. + NotShared, + /// The stored reference no longer passes screening. + InvalidReference, + /// Catch-all. + Unknown { reason: String }, +} +``` + +### Storage + +Two core-storage slots hold references, and neither is visible to products. `ProfileDisclosure` is wallet-owned and +holds the disclosing product id and the reference. `ProfileReferencesReceived { product_id }` holds, per chat product, +the newest reference each contact disclosed with its discloser; clearing the product clears it with the roster it +belongs to. Hosts treat both as secret material. + +### Relay + +A disclosure travels as a new Chat v2 content type, `ProfileReference { discloser_product_id, reference: Option }`, +where `None` withdraws. The Chat actor seals it to each ready peer's devices through the same host-private outbox that +carries payments and rich files, so the chat product submits and retries opaque ciphertext it cannot read, and cannot +prepare the content type itself. A per-peer watermark records what was last sent; each reconcile sends the current +disclosure to every peer whose watermark differs, which covers the first share, a new contact, a replacement and a +withdrawal. On receipt the host screens the frame, stores it for that peer and removes it from the plaintext returned to +the product. Frames from compacted history are dropped. + +Stability comes from the reference format rather than the relay: a reference that names a mutable record, such as a +registry slot, keeps working when the record changes, so a relay happens only when the reference itself changes. + +### Presentation + +`present_contact` looks up the caller's received reference for the named peer, screens it again, and hands it to +`ProfilePlatform::present_profile`. Host adapters are unchanged: they see a `present` whichever method produced it. + +## Trade-offs + +- One reference for all contacts, so withdrawing it from one contact means rotating it for all of them. +- A retraction cannot make a contact's host forget a reference it already resolved. +- The watermark advances when the message is queued, so a message that never arrives is not resent until the + disclosure changes. +- Dropped: carrying the reference in ordinary chat content, which puts a bearer capability in product hands. + +## Open questions + +- The content-type index. The prototype uses V2 index 21, which native Chat has to agree to. +- Several disclosing products. There is one `ProfileDisclosure` slot, so the last product to disclose replaces the + others and the earlier one can no longer retract. The alternative is one slot per product, with the host relaying the + one from a product the user designates, as RFC 0024 designates a personhood provider. +- Consent. `disclose` has no prompt; the alternative is a prompt-once authorization beside `ChatAuthority`. +- Devices. Only the host that took `disclose` knows the disclosure, so contacts that reach the user's other devices are + not sent it. +- Reconcile timing. The relay runs when the chat product initializes, not when `disclose` returns. +- Resolution. Hosts parse references today; a shared resolver in the core would need the reference format specified + here rather than by the publishing product. diff --git a/rust/crates/truapi-chat-v2/src/lib.rs b/rust/crates/truapi-chat-v2/src/lib.rs index 583df5882..03ffbc28b 100644 --- a/rust/crates/truapi-chat-v2/src/lib.rs +++ b/rust/crates/truapi-chat-v2/src/lib.rs @@ -380,6 +380,8 @@ pub enum V2ChatMessageContent { /// A profile reference the sender's host discloses to this contact, or /// `None` to withdraw it. Host-originated and host-consumed: products /// never send or see it. V2 wire enum index 21. + /// + /// Known gap (docs/rfcs/profile-disclosure.md): index 21 is not yet agreed with native Chat. ProfileReference { discloser_product_id: String, reference: Option, diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 22a70100d..38d8fb2cb 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -267,6 +267,9 @@ export type CoreStorageKey = * The profile reference the user disclosed to their chat contacts, with * the product that disclosed it. Wallet-owned: one per user, whichever * product wrote it. The reference is a bearer capability. + * + * Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces + * the others. */ | { tag: "ProfileDisclosure"; value?: undefined } /** diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index c539a3146..d7e758a8a 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1915,6 +1915,9 @@ pub enum CoreStorageKey { /// The profile reference the user disclosed to their chat contacts, with /// the product that disclosed it. Wallet-owned: one per user, whichever /// product wrote it. The reference is a bearer capability. + /// + /// Known gap (docs/rfcs/profile-disclosure.md): one slot, so the last product to disclose replaces + /// the others. #[codec(index = 17)] ProfileDisclosure, /// Profile references this product's chat contacts disclosed, newest per diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0d031535d..bdeb1ebdc 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1421,6 +1421,7 @@ impl Profile for ProductRuntimeHost { ) -> Result> { // The user's own profile is disclosed from where they manage it, an // App, not from a background Worker. + // Known gap (docs/rfcs/profile-disclosure.md): no consent prompt yet. if self.product.execution_kind != truapi_platform::ProductExecutionKind::App { return Err(CallError::Denied); } diff --git a/rust/crates/truapi-server/src/runtime/native_chat.rs b/rust/crates/truapi-server/src/runtime/native_chat.rs index 708bb5820..75a481a23 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat.rs @@ -392,6 +392,8 @@ impl NativeChatRegistry { match &mut request { Request::Initialize => { chat.drive_files(&context).await?; + // Known gap (docs/rfcs/profile-disclosure.md): the relay runs here only, not when + // `disclose` returns, and only on this device. chat.publish_profile_reference(&context).await?; } Request::Bind { username } => { diff --git a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs index 4097a5d10..e175b4646 100644 --- a/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs +++ b/rust/crates/truapi-server/src/runtime/native_chat/actor/profile.rs @@ -7,6 +7,9 @@ //! the initial share, a new contact, a replacement and a withdrawal are one //! reconcile: every peer whose watermark differs from the disclosure is sent //! the disclosure. The watermark advances when the message is queued. +//! +//! Known gap (docs/rfcs/profile-disclosure.md): advancing at queue time means a message that never +//! arrives is not resent until the disclosure changes. use super::*; use crate::runtime::native_chat::background::require_authorized;