Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/profile-disclose.md
Original file line number Diff line number Diff line change
@@ -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.
161 changes: 161 additions & 0 deletions docs/rfcs/profile-disclosure.md
Original file line number Diff line number Diff line change
@@ -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<HostProfilePresentResponse, CallError<HostProfilePresentError>> {
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<HostProfileDiscloseResponse, CallError<HostProfileDiscloseError>> {
Err(CallError::unavailable())
}

/// Withdraw the reference this product disclosed.
#[wire(id = 2)]
async fn retract(
&self,
_cx: &CallContext,
_request: HostProfileRetractRequest,
) -> Result<HostProfileRetractResponse, CallError<HostProfileRetractError>> {
Err(CallError::unavailable())
}

/// Show the profile a chat contact disclosed.
#[wire(id = 3)]
async fn present_contact(
&self,
_cx: &CallContext,
_request: HostProfilePresentContactRequest,
) -> Result<HostProfilePresentContactResponse, CallError<HostProfilePresentContactError>> {
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.
81 changes: 81 additions & 0 deletions rust/crates/truapi-chat-v2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,15 @@ 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.
///
/// Known gap (docs/rfcs/profile-disclosure.md): index 21 is not yet agreed with native Chat.
ProfileReference {
discloser_product_id: String,
reference: Option<String>,
},
/// 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 },
Expand Down Expand Up @@ -977,6 +986,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<Vec<u8>, 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,
Expand Down Expand Up @@ -1272,6 +1304,23 @@ pub fn decode_message(data: &[u8]) -> Result<V2ChatMessage, ChatError> {
},
}
}
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,
},
Expand Down Expand Up @@ -3598,6 +3647,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();
Expand Down
Loading
Loading