Skip to content

Device-Centric Identity System

Instead of traditional accounts (like alice@server.com with a password), each device generates its own cryptographic identity that serves as both identification and authentication.

  • 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”.

  • Device Identity is a cryptographic keypair + random device ID
  • No passwords. Authentication happens entirely through digital signatures.
  • The server stores nothing permanent about the device.

Here, the trust model becomes “I can verify this message cryptographically”.

struct DeviceIdentity {
// Core cryptographic identity (permanent)
// Main public key for stable identity
device_id: [u8; 32],
// Main keypair that's going to be used for everything
keypair: Ed25519KeyPair,
// Delivery addresses (ephemeral, rotatable)
delivery_addresses: Vec<DeliveryAddress>
// Unix timestamp of creation
created_timestamp: u64,
// User-chosen, non-unique label
display_name: Option<String>,
// Also user-chosen
profile_picture: Option<ProfilePicture>
}
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 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 {
Self {
// 256 bits of entropy
device_id: generate_cryptographically_secure_random(32),
// Modern elliptic curve cryptography
keypair: Ed25519KeyPair::generate(),
created_timestamp: current_unix_timestamp(),
// User can set this later, e.g., "Alice's Phone"
display_name: None,
// This can be set later as well
profile_picture: 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 shareable identity bundle for contact exchange
fn create_share_bundle(
&self,
initial_delivery_address: String,
keypackage_server: String,
) -> Result<ShareableIdentityBundle> {
// Check if the provided address belongs to this device
if !self.active_addresses().contains(&initial_delivery_address) {
return Err("Cannot create share bundle: provided address is not an active delivery address for this device.");
}
Ok(ShareableIdentityBundle {
device_id: self.device_id,
public_key: self.keypair.public_key(),
created_at: self.created_timestamp,
initial_delivery_address,
keypackage_server,
display_name: self.display_name.clone(),
profile_picture: self.profile_picture.clone(),
proof_of_ownership: self.keypair.sign(&self.device_id),
})
}
/// Create a new delivery address for your device
fn create_delivery_address(&mut self, server: &str) -> String {
// Generate random 16-byte identifier
let random: [u8; 16] = generate_random();
let prefix = hex::encode(random);
let addr = DeliveryAddress {
prefix: prefix.clone(),
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_AES128GCM_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()
}
}

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.

The 32-byte device ID serves multiple purposes:

  1. Unique identification: 2^256 possible values make collisions astronomically unlikely.
  2. Delivery address derivation: First 16 bytes become a routing address.
  3. MLS group member identification: Used as member ID in MLS protocol.
  4. Cryptographic binding: Signed by device’s private key as proof of ownership.
device_id = [0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x17, 0x28, 0x39, 0x4a, 0x5b, 0x6c, 0x7d, 0x8e, 0x9f, 0x10, ...];
address_prefix = hex::encode(&device_id[..16]) = "a1b2c3d4e5f61728394a5b6c7d8e9f10";
delivery_address = "a1b2c3d4e5f61728394a5b6c7d8e9f10@chat.example.com"

Used for contact exchange (QR codes, invite links).

struct ShareableIdentityBundle {
device_id: [u8; 32],
public_key: Ed25519PublicKey,
created_at: u64,
// Initial delivery address for first contact
initial_delivery_address: String,
// Server where MLS keypackages are uploaded
keypackage_server: String,
display_name: Option<String>,
profile_picture: Option<ProfilePicture>,
// Self-signed proof that we own the private key for this device_id
proof_of_ownership: Ed25519Signature,
}

In order to contact you, the recipient needs:

  1. Your device_id (permanent identity for trust/blocking)
  2. An initial_delivery_address (where to send the first message)

Without the delivery address, the recipient wouldn’t know:

  • Which server you’re on
  • How to reach you for initial contact

The initial_delivery_address may become stale if you rotate addresses.

Recipients should:

  • Use it for initial contact
  • Update automatically via MLS group address rotation messages
  • Handle 404 errors by having user share new address out-of-band

Including a delivery address in the share bundle means anyone who scans your QR code learns one of your addresses.

For public sharing scenarios (posting QR code online), consider:

  • Creating a dedicated “public contact” address
  • Rotating it frequently
  • Never reusing it for private communications

Creating Share Bundles with Specific Addresses

Section titled “Creating Share Bundles with Specific Addresses”

Users control which delivery address appears in share bundle.

impl DeviceIdentity {
fn create_share_bundle(
&self,
initial_delivery_address: String,
keypackage_server: String,
) -> Result<ShareableIdentityBundle> {
// Check if the provided address belongs to this device
if !self.active_addresses().contains(&initial_delivery_address) {
return Err("Cannot create share bundle: provided address is not an active delivery address for this device.");
}
Ok(ShareableIdentityBundle {
device_id: self.device_id,
public_key: self.keypair.public_key(),
created_at: self.created_timestamp,
initial_delivery_address,
keypackage_server,
display_name: self.display_name.clone(),
profile_picture: self.profile_picture.clone(),
proof_of_ownership: self.keypair.sign(&self.device_id),
})
}

Cryptid separates identity from routing:

  • Permanent cryptographic identity (main Ed25519 public key)
  • Used in MLS groups for member authentication
  • Used by contacts to identify you
  • Revealed to message recipients (MLS protocol requirement)
  • 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 device_id, not address)
  1. One Keypair: Device has only ONE master keypair. All delivery addresses are signed using this keypair.
  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 + backup).
  • Spam Prevention: 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 device_id and will be notified of address changes IF they share a chat with you.

Delivery address prefixes are randomly generated (16 bytes):

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

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

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 devicesEach device is an independent entity
Server CompromiseAll user accounts affectedIndividual devices unaffected
PrivacyUsername/email requiredNo PII needed