Skip to content

Cryptid's Identity System

Instead of traditional accounts (like alice@server.com with a password), Cryptid uses a two-layer identity system. Each user has a persistent User Identity that’s shared across devices, while each device maintains its own Device Identity for MLS cryptographic operations. This separation enables seamless multi-device support while maintaining strong cryptographic guarantees.

  • User Account: alice@server.com
  • Password: hunter2
  • Server stores:
    • Username
    • Password hash
    • User data

The trust model essentially boils down to “Server vouches that alice@server.com is legitimate”.

User Identity: Persistent Ed25519 keypair shared across all your devices

  • Used for application-layer operations (contact exchange, group invitations)
  • Enables multi-device coordination
  • First device creates it, subsequent devices receive it via secure provisioning

Device Identity: Per-device Ed25519 keypair (distinct from User Identity)

  • Used exclusively for MLS cryptographic operations
  • Each device has its own Device ID and keypair
  • Enables independent operation and device-level revocation

The server stores nothing permanent about either identity.

Here, the trust model becomes “This message can be verified cryptographically at multiple layers”:

  • MLS Layer: Device signatures prove message authenticity
  • Application Layer: User signatures prove identity ownership

User-level identity shared across all devices controlled by a single user.

struct UserIdentity {
// Blake3 hash of user public key
user_id: [u8; 32],
// User-level Ed25519 keypair (for application layer only)
keypair: Ed25519KeyPair,
// All devices controlled by this user
devices: Vec<DevicePublicInfo>,
// Unix timestamp of initial creation
created_at: u64,
// Default persona
default_persona: Persona,
// Additional personas (indexed by NonZeroU16)
personas: HashMap<NonZeroU16, Persona>,
}
struct DevicePublicInfo {
// Blake3 hash of device's public key (permanent, for MLS layer only)
device_id: [u8; 32],
// The device's public key
public_key: Ed25519PublicKey,
// Address to initially contact this device
initial_delivery_address: DeliveryAddress,
// Server that hosts the KeyPackages for this device
keypackage_server: String,
// Timestamp of when it was linked by the user
linked_at: u64,
}
struct ProfilePicture {
// e.g., "image/png"
mime_type: String,
// Raw image bytes or hash/link if large
data: Vec<u8>,
// Timestamp
uploaded_at: u64,
// Signed for authenticity
signature: Ed25519Signature,
}
struct Persona {
// Display name for this persona
display_name: String,
// Profile picture for this persona
profile_picture: Option<ProfilePicture>,
// Bio for this persona
bio: Option<String>,
// Pronouns for this persona
pronouns: Option<String>,
}
pub enum PersonaId {
Default,
Id(NonZeroU16),
}
impl UserIdentity {
/// Generate a new user identity (only happens on first device setup)
fn generate() -> Self {
let keypair = Ed25519KeyPair::generate();
Self {
user_id: Blake3::hash(keypair.public_key().as_bytes()).into(),
keypair,
devices: Vec::new(),
created_at: current_timestamp(),
default_persona: Persona {
display_name: Some("My Name".to_string()),
profile_picture: None,
bio: None,
pronouns: None,
},
personas: HashMap::new(),
}
}
fn add_persona(&mut self, persona: Persona) -> Option<u16> {
// Find next available key
let next_key = (1..NonZeroU16::MAX)
.find(|k| !self.personas.contains_key(k))?;
self.personas.insert(next_key, persona);
Some(next_key)
}
pub fn persona_for(&self, id: PersonaId) -> Option<&Persona> {
match id {
PersonalId::Default => Some(&self.default_persona),
PersonalId::Id(id) => self.personas.get(&id),
}
}
pub fn default_persona(&self) -> &Persona {
&self.default_persona
}
pub fn remove_persona(&mut self, id: NonZeroU16) -> Result<Persona, &'static str> {
self.personas.remove(&id)
.ok_or("Persona not found")
}
fn create_info_package(&self) -> Result<InfoPackageUploadRequest> {
// Create identity package content
let identity_package = IdentityInfoPackage {
user_id: self.user_id,
user_public_key: self.keypair.public_key(),
devices: self.devices.clone(),
default_persona: self.default_persona.clone(),
personas: self.personas.clone(),
profile_picture: None,
created_at: current_timestamp(),
};
// Prepare upload request with TTL and usage limits
Ok(InfoPackageUploadRequest {
package_type: InfoPackageType::Identity,
content: InfoPackageContent::Identity(identity_package),
ttl_seconds: 24 * 3600, // 24 hours default
max_uses: Some(100), // Up to 100 scans
})
}
/// Sign data with user identity key (application-level signatures)
fn sign(&self, data: &[u8]) -> Ed25519Signature {
self.keypair.sign(data)
}
/// Add a linked device to this user account
fn add_linked_device(&mut self, device_info: DevicePublicInfo) {
self.devices.push(device_info);
}
/// Remove a device (for revocation)
fn remove_device(&mut self, deviceid: &[u8; 32]) {
self.devices.retain(|d| &d.deviceid != deviceid);
}
}

Personas allow users to maintain multiple presentation identities and switch between them contextually.

Use Cases:

  • Plural systems: Different headmates with distinct profiles
  • Role-based switching: Moderator/Admin modes with enhanced visibility

Structure:

  • default_persona: Always exists, cannot be deleted
  • personas: Optional additional personas indexed by NonZeroU16 (1-65535)
  • PersonaId: Enum for referencing personas (Default or Id(NonZeroU16))

Protocol Responsibility:

The protocol only carries persona data for rendering. Clients are responsible for how they use them. Some example client-side features might include:

  • Proxy tag detection (e.g., PluralKit-style [text] patterns)
  • Persona switching UI/UX
  • Role indicators and badges

Per-device identity used by each device for all MLS operations.

/// Device-level identity (MLS Layer)
/// Each device has its own keypair, even within the same user identity
struct DeviceIdentity {
// Core cryptographic identity (permanent, MLS layer only)
device_id: [u8; 32],
// Device keypair used exclisively for MLS operations
keypair: Ed25519KeyPair,
// Delivery addresses (ephemeral, rotatable)
delivery_addresses: Vec<DeliveryAddress>
// Unix timestamp of creation
created_timestamp: u64,
}
struct DeliveryAddress {
// Address identifier (e.g., "a1b2c3d4e5...")
prefix: String,
// Server domain (e.g., "chat.example.com")
server: String,
// Timestamp of when the address was generated
created_at: u64,
// Can be deactivated without deletion
active: bool,
}
impl DeliveryAddress {
fn full_address(&self) -> String {
format!("{}@{}", self.prefix, self.server)
}
}
impl DeviceIdentity {
/// Generate a new device identity without any external dependencies
fn generate() -> Self {
// Modern elliptic curve cryptography
let keypair = Ed25519KeyPair::generate();
Self {
// Derive device_id from public key
device_id: Blake3(keypair.public_key().as_bytes()).into(),
keypair,
created_timestamp: current_unix_timestamp(),
// User can set this later, e.g., "Alice's Phone"
display_name: None,
// Initially create empty delivery addresses
delivery_addresses: Vec::new(),
}
}
/// Sign arbitrary data with this device's private key
fn sign(&self, data: &[u8]) -> Ed25519Signature {
self.keypair.sign(data)
}
/// Create MLS credential for group operations
fn create_mls_credential(&self) -> MLSCredential {
MLSCredential::basic(self.device_id, self.keypair.public_key())
}
/// Create a new delivery address for your device
fn create_delivery_address(&mut self, server: &str) -> String {
let addr = DeliveryAddress {
prefix: Uuid::new_v4(),
server: server.to_string(),
created_at: current_unix_timestamp(),
active: true,
};
self.delivery_addresses.push(addr);
// Return full address
format!("{}@{}", prefix, server)
}
/// Deactivate a delivery address
fn burn_address(&mut self, full_address: &str) {
if let Some(addr) = self.delivery_addresses.iter_mut()
.find(|a| a.full_address() == full_address) {
addr.active = false;
}
}
/// Get all active delivery addresses
fn active_addresses(&self) -> Vec<String> {
self.delivery_addresses.iter()
.filter(|a| a.active)
.map(|a| a.full_address())
.collect()
}
/// Get all active addresses for a specific server
fn active_addresses_for_server(&self, server: &str) -> Vec<String> {
self.delivery_addresses.iter()
.filter(|a| a.active && a.server == server)
.map(|a| a.full_address())
.collect()
}
/// Generate MLS KeyPackages for others to add this device to groups.
/// Implementation uses OpenMLS KeyPackage::builder() API.
fn generate_key_packages(&mut self, count: usize) -> Vec<KeyPackageBundle> {
// Implementation note: Uses MLS RFC 9420 KeyPackage format
// with cipher suite MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519
//
// See: reference/contact-exchange-and-trust for usage details
(0..count).map(|_| {
// OpenMLS implementation
KeyPackage::builder()
.build(ciphersuite, provider, &self.keypair, self.create_mls_credential())
// Returns KeyPackageBundle with:
// - key_package: Public part (uploaded to server)
// - private_key: Secret part (stored locally for welcome decryption)
}).collect()
}
/// Verify device_id is correctly derived from public key
fn verify_device_id(&self) -> bool {
let expected = Blake3(self.keypair.public_key().as_bytes());
self.device_id == expected
}
}

Ed25519 is a modern elliptic curve signature scheme that provides:

  • High security: 128-bit security level (equivalent to 3072-bit RSA).
  • Performance: Fast signature generation and verification.
  • Simplicity: No parameter choices, no pitfalls.
  • Deterministic signatures: Same message + key = same signature (important for MLS).
  • Small keys: 32-byte public keys, 64-byte signatures.

Cryptid requires Ed25519 as defined in RFC 8032. This is also known as Ed25519-IETF.

  • MLS security proofs require SUF-CMA (Strong Unforgeability under Chosen Message Attack)
  • RFC 8032 Ed25519 provides SUF-CMA security
  • The original Ed25519 paper from 2011 only provides EUF-CMA (Existential Unforgeability), which is insufficient for us.

User IDs and Device IDs are deterministically derived from their respective public keys using Blake3 hashing.

Formula:

  • user_id = Blake3(user_public_key)
  • device_id = Blake3(public_key)

We do this for the following reasons:

  • Cryptographically bound: The ids cannot be forged independently of the keypair
  • Verifiable: Anyone can verify that a specific id matches the public key
  • Prevents squatting: Attackers cannot claim arbitrary user/device IDs
  • Single source of truth: The public key uniquely determines the id.

Properties:

  • Both produce 32-byte (64 hex character) identifiers
  • User ID is the same across all of a user’s devices
  • Device ID is unique to each device, even for the same user
  • No central authority needed for ID assignment

For contact exchange (QR codes, invite links), use InfoPackages instead of directly sharing identity bundles. InfoPackages provide the same functionality with added privacy and security benefits.

An InfoPackage wraps your identity information in an encrypted, server-stored package that expires automatically. When someone scans your QR code or clicks your invite link, they receive:

  • Your user_id (permanent user identity)
  • Your user_public_key (for verifying user-level signatures)
  • A list of ALL your devices with their individual device IDs, public keys, and initial delivery addresses

The QR code contains only a compact 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/info-package/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: [u8; 32] },
}

When decrypted, an InfoPackage contains the encrypted identity data:

struct IdentityInfoPackage {
user_id: [u8; 32],
user_public_key: [u8; 32],
// 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,
}

When you upload an InfoPackage and someone scans your QR code, the InfoPackage system ensures:

  1. Encryption: Server never sees your identity data (encrypted with info_package_key)
  2. Expiration: Package automatically expires after configured TTL
  3. Revocation: You can manually revoke access at any time
  4. One-time capable: Can limit to single-use or multiple scans
  5. Compact: QR code contains only a tiny reference, not full identity

Recipients scanning your code:

  • Receive your user_id (permanent user identity)
  • Receive your user_public_key (for verifying user-level signatures)
  • See a list of ALL your devices
  • Can add all your devices to a group in a single MLS operation
  • Can inspect individual device fingerprints if desired

See Info Packages for a more detailed specification.

Cryptid separates user identity, device identity, and routing into three different concepts:

  • Permanent cryptographic identity representing you across all devices
  • Used for contact exchange and group invitations
  • Shared across all your devices via secure provisioning
  • Visible to group members (they see “Alice”, not “Alice’s Phone”)
  • Never changes unless you create an entirely new user account
  • Permanent cryptographic identity representing a specific device
  • Used in MLS groups for member authentication and message encryption
  • Each device has its own, even within the same user account
  • Visible to group members (if they inspect user’s device list)
  • Never changes unless you create an entirely new device identity
  • Ephemeral routing endpoints (e.g., a1b2c3d4...@chat.example.com)
  • Used by servers for message delivery
  • Can be created, rotated, and burned (deleted) freely
  • Hidden from message recipients (only routing metadata visible to servers)
  • Not used for blocking (recipients block by user_id, not address)
  1. Two Keypairs: Users have ONE User Identity keypair (shared across devices) and each device has ONE Device Identity keypair used for MLS only.
  2. Multiple Addresses: Devices can maintain multiple active delivery addresses simultaneously.
  3. Server-Specific: Each delivery address belongs to a specific server.
  4. Multi-Homing: Devices can have addresses on multiple servers (e.g., primary + backups).
  • Multi-Device UX: User Identity provides consistent identity across devices while Device Identity enables independent MLS operations.
  • Spam Prevention: Proactively burn addresses that receive spam and create new ones.
  • Privacy: Rotate addresses to prevent long-term tracking by network observers.
  • Flexibility: Different addresses for different contexts (work, general, public, etc).
  • Stability: Contacts know your user_id and will be notified of address changes IF they share a chat with you.

Delivery address prefixes are randomly generated UUIDv4s:

  • They have no relationship to device_id
  • They are not derived from the main keypair
  • Pure randomness for maximum privacy

This ensures that address rotation cannot be linked through cryptographic analysis.

Devices generate KeyPackages (one-time use cryptographic material) for MLS group additions. KeyPackages are uploaded to a designated server and fetched when adding the device to groups.

When inviting a user to a group, the inviting device fetches KeyPackages for all of the user’s devices and adds them simultaneously in a single MLS commit.

For complete details on KeyPackage management, contact exchange, and trust establishment, see Contact Exchange and Trust.

User Identity vs. Device Identity: When Each Is Used

Section titled “User Identity vs. Device Identity: When Each Is Used”

Understanding which identity is used for what operation:

OperationUses User IdentityUses Device Identity
Contact exchange (QR codes)Embedded in bundle
Group invitationsAll devices invited
MLS encryption/decryption
MLS group membership
Message signing (MLS)
Display name/avatar
Multi-device linking✅ Same keypair on all devices✅ Unique per device
Device revocation✅ Signs revocationDevice being revoked

Application Layer: Uses UserIdentity for user-facing operations

  • “Alice wants to add Bob to the group”
  • Fetch Bob’s ShareableIdentityBundle (contains user_id + all their devices)
  • Fetch KeyPackages for all of Bob’s devices

MLS Layer: Uses DeviceIdentity for cryptographic operations

  • “Alice’s Phone adds Bob’s Phone, Bob’s Laptop, and Bob’s Tablet to the MLS group”
  • Three separate MLS add operations in a single commit
  • Each device independently encrypts/decrypts using its own DeviceIdentity keypair
AspectTraditional Authentication SystemsCryptid’s Device-Centric System
Account CreationServer registration requiredLocal key generation only
Identity ProofServer password verificationCryptographic signature
Trust Anchor”Server says alice@server is legitimate""Alice’s signature proves ownership”
Multi-DeviceShared account across devicesUser Identity shared across devices; Device Identities independent
Server CompromiseAll user accounts affectedIndividual devices unaffected
PrivacyUsername/email requiredNo PII needed