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 (for fetching via device-to-device) 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 { // Which type to create package_type: InfoPackageType,
// The data to encrypt and store content: InfoPackageContent,
// 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 decryption_key: [u8; 32], // Key to include in QR code expires_at: u64, // Unix timestamp when expires max_uses: Option<u32>, qr_data: CompactInfoQR, // Pre-built QR payload (convenience)}- Alice POSTS to
/v1/info-packages/uploadwith:
{ "package_type": "identity", "content": { IdentityInfoPackage }, "ttl_seconds": 3600, "max_uses": 5}-
Server generates:
- Random URL segment: “abc123xyz”
- Random decryption_key: [32 random bytes]
- Encrypts content with decryption_key
- Stores encrypted blob
-
Server returns:
{ "url": "https://server.com/ip/abc123xyz", "decryption_key": "...", "expires_at": 1730400000, "max_uses": 5, "qr_data": CompactInfoQR { ... }}- Alice’s client:
- Stores decryption_key locally
- Encodes CompactInfoQR as QR code
- Displays QR for scanning
Server Implementation
Section titled “Server Implementation”async fn upload_info_package( user_id: UserId, req: InfoPackageUploadRequest) -> Result<InfoPackageUploadResponse> { // Validate user is authenticated let device_auth = authenticate_device()?;
// 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"); } }
// Generate random URL segment and key let url_segment = random_bytes(16).to_hex(); let decryption_key = random_32_bytes();
// Serialize content let content_json = serde_json::to_string(&req.content)?;
// Encrypt with decryption_key let nonce = random_12_bytes(); let ciphertext = chacha20poly1305_encrypt( &decryption_key, &nonce, content_json.as_bytes(), );
let expires_at = now() + req.ttl_seconds as u64;
// Store encrypted package let package = EncryptedInfoPackage { user_id, package_type: req.package_type.clone(), ciphertext, created_at: now(), expires_at, max_uses: req.max_uses, uses_remaining: req.max_uses, deleted: false, };
packages.insert(&url_segment, package);
// Build QR payload let qr_data = CompactInfoQR { info_package_url: format!("https://{}/ip/{}", DOMAIN, url_segment), info_package_key: decryption_key, package_type: req.package_type, display_name: get_display_name(&req.content), };
Ok(InfoPackageUploadResponse { url: qr_data.info_package_url.clone(), decryption_key, expires_at, max_uses: req.max_uses, qr_data, })}InfoPackage Download
Section titled “InfoPackage Download”Request
Section titled “Request”GET /v1/info-packages/{url-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) package_type: InfoPackageType, created_at: u64,}- Bob scans Alice’s QR and gets a CompactInfoQR
{ "url": "https://server.com/info-packages/abc123xyz", "key": "...", "type": "identity", "name": "Alice"}-
Bob fetches:
GET https://server.com/ip/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(), package_type: pkg.package_type.clone(), created_at: pkg.created_at, })}InfoPackage Revocation
Section titled “InfoPackage Revocation”Request
Section titled “Request”DELETE /v1/info-packages/{url_segment}Authorization: Bearer {user_id}+{signature}Must be authenticated as original uploader.
- Alice decides that she doesn’t want any more people using that QR
- Calls
DELETE /v1/info-packages/abc123xyz - Server:
- Verifies Alice owns this package
- Marks as deleted
- Rejects any future fetch attempts
- 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, user_id: UserId,) -> Result<()> { let pkg = packages.get_mut(&url_segment)?;
// Verify ownership if pkg.user_id != user_id { return Err("Unauthorized"); }
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: None, devices: vec![alice_device], profile_picture: Some(ProfilePicture { ... }), created_at: now(),};
let response = client.upload_info_package( InfoPackageUploadRequest { package_type: InfoPackageType::Identity, content: InfoPackageContent::Identity(alice_identity), ttl_seconds: 24 * 3600, // 24 hours max_uses: Some(100), // Unlimited scans }).await?;
// Response:// { // "url": "https://chat.example.com/info-package/abc123", // "decryption_key": "...", // "expires_at": 1730400000, // "qr_data": { ... }// }
// Display QR codedisplay_qr(&response.qr_data);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 response = client.upload_info_package( InfoPackageUploadRequest { package_type: InfoPackageType::GroupInvite { group_id: team_chat_group.id, }, content: InfoPackageContent::GroupInvite(group_invite), 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(&response.qr_data);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 { user_id: UserId, package_type: InfoPackageType,
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
- Request full image via device-to-device only if user interested
- Reject if corrupted (invalid decryption or wrong format)