Skip to content

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.

  • 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

The QR code contains only a tiny reference, not the full identity data.

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 },
}
  • 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’”.

The server stores encrypted InfoPackage blobs. Only holders of info_package_key can decrypt.

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,
}

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,
}

Both package types serialized as JSON, then:

  1. Encrypted with info_package_key
  2. Stored on server (server cannot decrypt)
  3. Returned to client on fetch
  4. Client decrypts and parses

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),
}
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)
}
  1. Alice POSTS to /v1/info-packages/upload with:
{
"package_type": "identity",
"content": { IdentityInfoPackage },
"ttl_seconds": 3600,
"max_uses": 5
}
  1. Server generates:

    • Random URL segment: “abc123xyz”
    • Random decryption_key: [32 random bytes]
    • Encrypts content with decryption_key
    • Stores encrypted blob
  2. Server returns:

{
"url": "https://server.com/ip/abc123xyz",
"decryption_key": "...",
"expires_at": 1730400000,
"max_uses": 5,
"qr_data": CompactInfoQR { ... }
}
  1. Alice’s client:
    • Stores decryption_key locally
    • Encodes CompactInfoQR as QR code
    • Displays QR for scanning
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,
})
}

GET /v1/info-packages/{url-segment}

No authentication required. Server returns encrypted blob only.

struct InfoPackageDownloadResponse {
// Encrypted, base64-encoded
ciphertext: Vec<u8>,
// Metadata (no decryption needed)
package_type: InfoPackageType,
created_at: u64,
}
  1. Bob scans Alice’s QR and gets a CompactInfoQR
{
"url": "https://server.com/info-packages/abc123xyz",
"key": "...",
"type": "identity",
"name": "Alice"
}
  1. Bob fetches: GET https://server.com/ip/abc123xyz

  2. 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
  3. Bob’s client:

    • Decrypts with info_package_key
    • Parses as IdentityInfoPackage
    • Extracts user_id, personas, devices
    • Shows “Add Alice as contact?”
  4. Bob accepts and adds Alice as a contact

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,
})
}

DELETE /v1/info-packages/{url_segment}
Authorization: Bearer {user_id}+{signature}

Must be authenticated as original uploader.

  1. Alice decides that she doesn’t want any more people using that QR
  2. Calls DELETE /v1/info-packages/abc123xyz
  3. Server:
    • Verifies Alice owns this package
    • Marks as deleted
    • Rejects any future fetch attempts
  4. Bob tries to scan already-scanned QR -> error “Already redeemed or revoked”
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(())
}

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 code
display_qr(&response.qr_data);

Bob scans the QR:

// Bob's QR scanner captures CompactInfoQR
let qr = scan_qr()?;
// Bob's client fetches encrypted package
let response = client.fetch_info_package(&qr.info_package_url).await?;
// Bob's client decrypts
let 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 confirms
client.add_contact(identity).await?;

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 scan
println!("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 confirms
client.join_group_from_invite(&invite).await?;
// Uses MLS welcome_message to join

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

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)

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)

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>,
}

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

  • 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
  • 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)