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 (fetched from the blob store)
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
revocation_token: [u8; 32], // Secret. Proves ownership for DELETE
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]
    • Random revocation_token: [32 random bytes]
    • Encrypts content with decryption_key
    • Stores encrypted blob and revocation_token
  2. Server returns:

{
"url": "https://server.com/ip/abc123xyz",
"decryption_key": "...",
"revocation_token": "...",
"expires_at": 1730400000,
"max_uses": 5,
"qr_data": CompactInfoQR { ... }
}
  1. Alice’s client:
    • Stores decryption_key and revocation_token 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();
let revocation_token = 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,
revocation_token,
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,
revocation_token,
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}
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.

  1. Alice decides that she doesn’t want any more people using that QR
  2. Calls DELETE /v1/info-packages/abc123xyz with her revocation token
  3. Server:
    • Compares the presented token against the stored one in constant time
  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
  • Fetch the full image only if the user is interested
  • Reject if corrupted (invalid decryption or wrong format)