Info Packages
InfoPackages are ephemeral, on-demand tokens for sharing identity or inviting people to groups. They enable QR code-based contact exchange and group invites without storing persistent identity data on servers.
Core Properties
Section titled “Core Properties”- Ephemeral: Expire automatically based on TTL
- One-time capable: Can limit to single use
- Revocable: User can delete at any time
- Encrypted: Server cannot decrypt contents
- Compact: Fit in QR codes (Version 6-10)
- Polymorphic: Can share identity OR invitation to groups
CompactInfoQR: What Goes in the QR Code
Section titled “CompactInfoQR: What Goes in the QR Code”The QR code contains only a tiny reference, not the full identity data.
Structure
Section titled “Structure”struct CompactInfoQR { // Server URL to fetch encrypted data from info_package_url: String, // e.g., "https://chat.example.com/api/v1/infopackage/abc123xyz"
// Decryption key (client holds this, never sent to server) info_package_key: [u8; 32],
// Type of package (for display before fetching) package_type: InfoPackageType,
// Display name (for UI, e.g., "Alice" or "Team Chat") display_name: String,}
#[serde(tag = "type")]enum InfoPackageType { #[serde(rename = "identity")] Identity,
#[serde(rename = "group_invite")] GroupInvite { group_id: GroupId },}Field Specification
Section titled “Field Specification”info_package_url: HTTPS URL to fetch encrypted package from. Server returns blob only.info_package_key: Random 32-byte key. Client uses to decrypt fetched package. Server never sees this.package_type: Enum indicating what’s inside (identity or group invite). Determines parsing after decryption.display_name: Human-readable name. Used by QR scanner UI to show “Add contact ‘Alice’” or “Join ‘Team Chat’”.
InfoPackage Content: What’s Encrypted
Section titled “InfoPackage Content: What’s Encrypted”The server stores encrypted InfoPackage blobs. Only holders of info_package_key can decrypt.
IdentityInfoPackage
Section titled “IdentityInfoPackage”For sharing contact information.
struct IdentityInfoPackage { user_id: UserId, user_public_key: Ed25519PublicKey,
// Default persona (always present) default_persona: Persona,
// Additional personas personas: HashMap<NonZeroU16, Persona>,
// User's current devices devices: Vec<DevicePublicInfo>,
// Profile picture reference (fetched from the blob store) profile_picture: Option<ProfilePicture>,
// Metadata created_at: u64,}GroupInviteInfoPackage
Section titled “GroupInviteInfoPackage”For inviting people to groups.
struct GroupInviteInfoPackage { group_id: GroupId, group_name: String, group_description: Option<String>, group_avatar: Option<ProfilePicture>,
// MLS welcome message (group admission credential) // Recipient uses this to join the group welcome_message: Vec<u8>,
// Creator info (for "invited by" display) invited_by: UserId, invited_by_name: String,
// Metadata created_at: u64,}Serialization
Section titled “Serialization”Both package types serialized as JSON, then:
- Encrypted with
info_package_key - Stored on server (server cannot decrypt)
- Returned to client on fetch
- Client decrypts and parses
InfoPackage Upload
Section titled “InfoPackage Upload”Request
Section titled “Request”struct InfoPackageUploadRequest { ciphertext: Vec<u8>,
// User-controlled restrictions ttl_seconds: u32, // How long valid in seconds max_uses: Option<u32>, // How many times fetchable}
#[serde(untagged)]enum InfoPackageContent { Identity(IdentityInfoPackage), GroupInvite(GroupInviteInfoPackage),}Response
Section titled “Response”struct InfoPackageUploadResponse { url: String, // HTTPS URL for download revocation_token: [u8; 32], // Secret. Proves ownership for DELETE expires_at: u64, // Unix timestamp when expires max_uses: Option<u32>,}- Alice POSTS to
/api/v1/infopackageswith:
{ "ciphertext": "<base64>", "ttl_seconds": 3600, "max_uses": 5}-
Server generates:
- Random URL segment: “abc123xyz”
- Random revocation_token: [32 random bytes]
- Stores the ciphertext it was given, unread
-
Server returns:
{ "url": "https://server.com/api/v1/infopackages/abc123xyz", "revocation_token": "...", "expires_at": 1730400000, "max_uses": 5}- Alice’s client:
- Stores revocation_token locally, alongside the key it already holds
- Builds a CompactInfoQR from the url and its key
- Displays QR for scanning
Server Implementation
Section titled “Server Implementation”async fn upload_info_package( req: InfoPackageUploadRequest) -> Result<InfoPackageUploadResponse> { // Validate TTL if req.ttl_seconds < 60 || req.ttl_seconds > 30 * 86400 { return Err("TTL must be 60 seconds to 30 days"); }
// Validate max_uses if let Some(max) = req.max_uses { if max < 1 || max > 1000 { return Err("max_uses must be 1-1000"); } }
// The client encrypted this before sending. The server never held the // key and cannot read what it is storing let url_segment = random_bytes(16).to_hex(); let revocation_token = random_32_bytes();
let expires_at = now() + req.ttl_seconds as u64;
// Store encrypted package let package = EncryptedInfoPackage { ciphertext: req.ciphertext, created_at: now(), expires_at, max_uses: req.max_uses, uses_remaining: req.max_uses, revocation_token, deleted: false, };
packages.insert(&url_segment, package);
Ok(InfoPackageUploadResponse { url: format!("https://{}/api/v1/infopackages/{}", DOMAIN, url_segment), revocation_token, expires_at, max_uses: req.max_uses, })}InfoPackage Download
Section titled “InfoPackage Download”Request
Section titled “Request”GET /api/v1/infopackages/{segment}No authentication required. Server returns encrypted blob only.
Response
Section titled “Response”struct InfoPackageDownloadResponse { // Encrypted, base64-encoded ciphertext: Vec<u8>,
// Metadata (no decryption needed) created_at: u64,}- Bob scans Alice’s QR and gets a CompactInfoQR
{ "url": "https://server.com/api/v1/infopackages/abc123xyz", "key": "...", "type": "identity", "name": "Alice"}-
Bob fetches:
GET https://server.com/api/v1/infopackages/abc123xyz -
Server:
- Finds package by url_segment
- Checks if expired -> Reject if so
- Checks if deleted -> Reject if so
- Checks use limit -> Decrement, delete if 0
- Returns encrypted blob
-
Bob’s client:
- Decrypts with info_package_key
- Parses as IdentityInfoPackage
- Extracts user_id, personas, devices
- Shows “Add Alice as contact?”
-
Bob accepts and adds Alice as a contact
Server Implementation
Section titled “Server Implementation”async fn get_info_package( url_segment: String,) -> Result<InfoPackageDownloadResponse> { let mut pkg = packages.get_mut(&url_segment)?;
// Check if expired if pkg.expires_at < now() { packages.delete(&url_segment); return Err("InfoPackage expired"); }
// Check if revoked if pkg.deleted { return Err("InfoPackage revoked"); }
// Check use limit if let Some(uses) = &mut pkg.uses_remaining { if *uses <= 0 { packages.delete(&url_segment); return Err("InfoPackage usage limit exceeded"); } *uses -= 1;
// Delete if no uses remaining if *uses == 0 { pkg.deleted = true; } }
Ok(InfoPackageDownloadResponse { ciphertext: pkg.ciphertext.clone(), created_at: pkg.created_at, })}InfoPackage Revocation
Section titled “InfoPackage Revocation”Request
Section titled “Request”DELETE /api/v1/infopackages/{segment}X-Revocation-Token: {32-byte token from the upload response}Must present the revocation token issued when the package was uploaded. The server compares it against the stored value and learns nothing about who owns the package.
- Alice decides that she doesn’t want any more people using that QR
- Calls
DELETE /api/v1/infopackages/abc123xyzwith her revocation token - Server:
- Compares the presented token against the stored one in constant time
- Bob tries to scan already-scanned QR -> error “Already redeemed or revoked”
Server Implementation
Section titled “Server Implementation”async fn revoke_info_package( url_segment: String, presented_token: [u8; 32],) -> Result<()> { let pkg = packages.get_mut(&url_segment)?;
// The capability is the token. The server has no idea who uploaded this. if !constant_time_eq(&pkg.revocation_token, &presented_token) { return Err("Invalid revocation token"); }
pkg.deleted = true; packages.delete(&url_segment);
Ok(())}Usage Examples
Section titled “Usage Examples”Example 1: Identity Share (Contact QR)
Section titled “Example 1: Identity Share (Contact QR)”Alice wants to share her contact:
let alice_identity = IdentityInfoPackage { user_id: alice_id, user_public_key: alice_pubkey, default_persona: Persona { display_name: Some("Alice".to_string()), pronouns: Some("she/her".to_string()), bio: Some("Software engineer".to_string()), profile_picture: Some(ProfilePicture { ... }), }, personas: HashMap::new(), devices: vec![alice_device], profile_picture: Some(ProfilePicture { ... }), created_at: now(),};
// Alice's client generates the key and encrypts locally. The server is// never given either the key or the plaintextlet info_package_key = random_32_bytes();let ciphertext = seal(&info_package_key, &serialize(&alice_identity));
let response = client.upload_info_package( InfoPackageUploadRequest { ciphertext, ttl_seconds: 24 * 3600, // 24 hours max_uses: Some(100), }).await?;
// Response:// { // "url": "https://chat.example.com/api/v1/infopackage/abc123", // "revocation_token": "...", // "expires_at": 1730400000, // "max_uses": 100// }
// The client builds the QR, because only it knows the key and the namedisplay_qr(&CompactInfoQR { info_package_url: response.url, info_package_key, package_type: InfoPackage::Identity, display_name: "Alice".to_string(),});Bob scans the QR:
// Bob's QR scanner captures CompactInfoQRlet qr = scan_qr()?;
// Bob's client fetches encrypted packagelet response = client.fetch_info_package(&qr.info_package_url).await?;
// Bob's client decryptslet identity = decrypt_and_parse::<IdentityInfoPackage>( &response.ciphertext, &qr.info_package_key,)?;
// Bob sees: "Add Alice as contact?"println!("Add {} as contact?", identity.default_persona.display_name);
// Bob confirmsclient.add_contact(identity).await?;Example 2: Group Invite
Section titled “Example 2: Group Invite”Alice invites people to “Team Chat”:
let group_invite = GroupInviteInfoPackage { group_id: team_chat_group.id, group_name: "Team Chat".to_string(), group_description: Some("Engineering team".to_string()), group_avatar: Some(ProfilePicture { ... }), welcome_message: team_chat_group.create_welcome_message()?, invited_by: alice_id, invited_by_name: "Alice".to_string(), created_at: now(),};
let info_package_key = random_32_bytes();let ciphertext = seal(&info_package_key, &serialize(&group_invite));
let response = client.upload_info_package( InfoPackageUploadRequest { ciphertext, ttl_seconds: 7 * 24 * 3600, // 1 week max_uses: Some(10), // Max 10 people }).await?;
// Print QR for team to scanprintln!("Join \"Team Chat\"?");display_qr(&CompactInfoQR { info_package_url: response.url, info_package_key, package_type: InfoPackageType::GroupInvite { group_id: team_chat_group.id, }, display_name: "Team Chat".to_string(),});Bob scans and joins:
let qr = scan_qr()?;let response = client.fetch_info_package(&qr.info_package_url).await?;
let invite = decrypt_and_parse::<GroupInviteInfoPackage>( &response.ciphertext, &qr.info_package_key,)?;
// Bob sees: "Join 'Team Chat' invited by Alice?"println!("Join '{}' invited by {}?", invite.group_name, invite.invited_by_name);
// Bob confirmsclient.join_group_from_invite(&invite).await?;// Uses MLS welcome_message to joinSecurity Properties
Section titled “Security Properties”Privacy
Section titled “Privacy”Server cannot see:
- Identity contents (all encrypted)
- Who scanned which QR (fetch is anonymous)
- Contact relationships
- Group invites
Server can observe:
- URL segment created (no semantic meaning)
- How many times fetched (usage count)
- When expired or revoked
Denial of Service Protection
Section titled “Denial of Service Protection”Limits:
- Max 1000 uses per package
- Max 30-day TTL
- Min 60-second TTL (prevents spam)
- Server can rate-limit uploads per user
Storage:
- Automatic cleanup on expiration
- Automatic deletion on use limit reached
- Soft-delete on revocation (can hard-delete later)
Revocation and Safety
Section titled “Revocation and Safety”User can revoke:
- “Oops, shared QR publicly, let me disable it”
- Immediate effect (future fetches rejected)
- Existing contacts still have information (can’t revoke retroactively)
Server Implementation Requirements
Section titled “Server Implementation Requirements”Storage
Section titled “Storage”Store one encrypted blob per InfoPackage:
struct EncryptedInfoPackage { ciphertext: Vec<u8>, // Encrypted content created_at: u64, expires_at: u64, max_uses: Option<u32>, uses_remaining: Option<u32>,}Cleanup
Section titled “Cleanup”Automatic:
- Delete packages where
expires_at < now() - Delete packages where
uses_remaining == 0 - Run cleanup job every hour
Manual:
- Users can call DELETE endpoint to soft-delete
Client Guidelines
Section titled “Client Guidelines”Upload Best Practices
Section titled “Upload Best Practices”- Identity shares: 24 hour TTL, unlimited uses (default)
- One-time links: 1 hour TTL, 1 use (for secure channels)
- Group invites: 1 week TTL, limited uses (e.g., 10 for 10 people)
- Revoke immediately if shared by accident
Download Best Practices
Section titled “Download Best Practices”- Display display_name immediately (from QR, no fetch needed)
- Fetch in background while showing UI
- Show thumbnail from profile_picture if available
- Fetch the full image only if the user is interested
- Reject if corrupted (invalid decryption or wrong format)