PrivacyPass Authentication
Cryptid authenticates actions, not identities. A device proves its entitlement to perform an action without revealing its own identity. This replaces session tokens entirely: there is no long-lived credential that ties a stream of actions together.
Why Not Sessions
Section titled “Why Not Sessions”A session token is a correlation handle by construction. Every action carrying it is attributable to one device for the token’s lifetime, which hands a server the social graph it is not supposed to have and it would undo the separation that Mailbox Keypair derivation establishes at the delivery layer.
PrivacyPass splits the two things a session conflates: proving entitlement and being identified.
sequenceDiagram
participant D as Device
participant S as Server
Note over D,S: Issuance - identity known, rate limited
D->>S: Authenticate, request N tokens (blinded)
S->>D: Blind signatures
D->>D: Unblind and store
Note over D,S: Redemption - identity unknown
D->>S: Action + one token
S->>S: Verify, check not already spent
S->>D: Action performed
The server sees both halves and cannot join them. At issuance it knows a device took ‘N’ tokens. At redemption it knows a valid token was spent. Because the client blinds each token before issuance, the value the server signs is not the value it later verifies.
/// A redeemable token, per RFC 9578. Issued blinded, so the server that/// verifies it cannot recognise it as one it issued.struct Token { // 0x0001 - privately verifiable (VOPRF over P-384). Issuer and verifier are the // same server, so public verifiability buys nothing here. token_type: u16,
// Client-chosen randomness, unknown to the server at issuance time. // This is what makes redemption unlinkable to issuance. nonce: [u8; 32],
// SHA-256 of this server's TokenChallenge. Binds the token to the server // that minted it. Tokens are fungible across actions: one token buys one // metered action, whichever it is. challenge_digest: [u8; 32],
// SHA-256 of the issuance key this token was minted under token_key_id: [u8; 32],
// VOPRF evaluation. 48 bytes is the output length for P-384. authenticator: [u8; 48],}Ciphersuite
Section titled “Ciphersuite”Cryptid uses Privacy Pass token type 0x0001 (RFC 9578): VOPRF over P-384 with SHA-384.
- Server MUST reject any token presenting a different
token_type authenticatoris 48 bytes, the VOPRF output length for P-384token_key_idis the SHA-256 of the issuance public key- Issuance keys MUST rotate on a fixed schedule, defaulting to 30 days
- Servers MUST continue accepting tokens under the previous key for a 7-day overlap after rotation, so tokens issued shortly before a rotation remain spendable
- The spent-nonce set for a retired key MAY be discarded once its overlap window closes
- Servers MUST publish the current issuance public key and its
token_key_id - Clients SHOULD refresh their token supply well inside the overlap window; a token is spendable for at most
rotation + overlapdays from issuance
Issuance
Section titled “Issuance”- Devices MUST authenticate to request tokens
- First-time announcement MUST additionally present a VDF proof
- Servers MUST enforce a per-device token bucket at issuance
- Servers MUST grant partial batches rather than rejecting when a bucket is short
- Servers MUST NOT record which tokens were issued to which device beyond a count
- Tokens SHOULD be requested in batches, so issuance frequency does not itself become a signal
Redemption
Section titled “Redemption”Tokens are fungible. One token buys one metered action, whatever that action might be. Metering is a matter of how many actions a device can take, not which.
- Each metered action MUST spend exactly one token
- Servers MUST reject a token whose
challenge_digestdoes not match the server’s ownTokenChallenge - Servers MUST maintain a spent-nonce set and reject replays
- The spent-nonce set MUST be retained at least as long as the issuance key is valid
- Servers MUST NOT log source addresses alongside redemptions
- Blob uploads MUST spend one token; blob fetches spend none
- Envelope retrieval spends none; it is authorized by mailbox ownership proof, not by a token
Why Blob Fetch Is Unmetered
Section titled “Why Blob Fetch Is Unmetered”Every other data-carrying operation costs a token. Blob fetch cannot, for the same reason tokens never cross the federation boundary: they redeem only at the issuing server, so a recipient fetching a file from a peer’s host has nothing that host would accept.
Authorization instead comes from holding the blob_handle, which travels only inside MLS, so possessing it evidences group membership. Hosts bound abuse with a TTL and a max_uses limit per handle rather than with tokens.
Tokens Are Server-Local
Section titled “Tokens Are Server-Local”A token is redeemable only at the server that issued it. Tokens never cross the federation boundary, and servers MUST reject tokens minted under another server’s key.
This is what makes the model survive hostile operators. Cryptid servers are self-hosted and the server implementation will be AGPL, so anyone may run a modified server that issues itself unlimited tokens. Server-local scope means that the blast radius of doing so is that operator’s own queues, bandwidth, and users. The tokens buy nothing anywhere else.
Cross-server abuse is therefore NOT a token concern. It is governed at the federation boundary:
| Boundary | Mechanism |
|---|---|
| Client -> Home Server | Token Redemption |
| Server -> Server | Per-origin rate limits, reputation, defederation |
| Recipient -> Sender | Personal blocklists, group moderation, device reports |
Abuse Handling Without Identity
Section titled “Abuse Handling Without Identity”Rate limiting moves from action time to issuance time. Scarcity replaces attribution: servers do not track per-device message volume, because they cannot attribute a message to a device.
Abuse reporting runs on two independent tracks. Neither resolves a mailbox back to a device.
Per origin server: A receiving server counts federated abuse against the origin that delivered it. Sustained abuse tightens per-origin limits and ultimately triggers defederation.
Per device, by disclosure: Group members see each other’s device_id in MLS credentials. A member MAY report one to that device’s home server, which acts only at issuance - where the device authenticates - and only after operator review. The server learns nothing it could have derived itself.
Two mechanisms are unaffected, despite appearing to depend on identity:
- Personal blocklists keyed by
device_idstill work. That check runs client-side after MLS decryption, wheredevice_idis visible to group members by design. It was never a server capability. - Unsolicited traffic can only reach a device’s contact mailbox, since group mailboxes are distributed exclusively through
AddressRotationinside MLS. A flood is structurally confined to a single mailbox, which can be rate limited or burned and re-derived without disturbing any group.
Blind KeyPackage Distribution
Section titled “Blind KeyPackage Distribution”KeyPackage distribution is where device identity would otherwise re-enter the protocol through the back door. Every axis the rest of the design removes it from, a naive KeyPackage store reintroduces:
| Stage | What leaks |
|---|---|
| Upload | Device authenticates under its device_id |
| Storage | Entries are indexed by device_id |
| Fetch | The request names the device_id being added |
| At rest | The MLS credential inside the KeyPackage contains device_id |
Fetch is the sharpest of these. A request for a specific device’s KeyPackage announces “someone is adding this device to a group right now” which is a social-graph edge that’s stated outright, rather than a timing inference.
Derived Handles
Section titled “Derived Handles”Rather than indexing by device_id, both the storage index and an encryption key are derived from it, on a rotating epoch:
const KP_EPOCH_SECS: u64 = 86_400;
fn kp_handle(device_id: &[u8; 32], epoch: u64) -> [u8; 32] { blake3::derive_key(&format!("cryptid kp handle v1 {}", epoch), device_id)}
fn kp_key(device_id: &[u8; 32], epoch: u64) -> [u8; 32] { blake3::derive_key(&format!("cryptid kp key v1 {}", epoch), device_id)}Anyone already holding a device’s device_id can derive both values independently without needing any distribution step or handle registry. That set is exactly the parties who would legitimately add the device to a group: contacts who fetched its InfoPackage, and fellow members of groups it already belongs to.
Nobody else can, because device_id is a 256-bit Blake3 hash of the device public key. It is not enumerable, so a server holding a handle cannot invert or brute-force its way back to the identity.
/// What the server stores. It holds no device_id in any field, and cannot/// read the credential inside the ciphertext.struct BlindKeyPackageEntry { // blake3::derive_key("cryptid kp handle v1 {epoch}", device_id) handle: [u8; 32],
// KeyPackage encrypted under a key derived the same way. Without this // the stored MLS credential would still expose the device_id ciphertext: Vec<u8>,
// Plaintext so the server can garbage-collect. Leaks nothing. expires_at: u64,}Upload
Section titled “Upload”fn upload_keypackages(device_id: &[u8; 32], kps: Vec<KeyPackage>, token: Token) { let epoch = now() / KP_EPOCH_SECS; let handle = kp_handle(device_id, epoch); let key = kp_key(device_id, epoch);
let entries = kps.into_iter().map(|kp| BlindKeyPackageEntry { handle, ciphertext: aead_seal(&key, &serialize(kp)), expires_at: kp.lifetime_end(), });
// One token per batch. The request carries no device_id. server.put(entries, token);}- Upload MUST spend one token
- The request MUST NOT carry
device_idin any field - KeyPackages MUST be encrypted under
kp_keybefore upload - Servers MUST NOT attempt to validate stored KeyPackages, which they cannot read
- Servers MAY enforce a per-handle entry cap to bound storage
fn fetch_keypackage(target: &[u8; 32], token: Token) -> Option<KeyPackage> { let epoch = now() / KP_EPOCH_SECS; let ct = server.take(kp_handle(target, epoch), token)?; let kp = deserialize(aead_open(&kp_key(target, epoch), &ct)?); Some(kp)}- Fetch MUST spend one token
- Servers MUST address entries by
handleonly, and MUST delete on read (one-time use) - Clients MUST verify the decrypted KeyPackage’s credential matches the
device_idthey derived from, since a malicious server could server any ciphertext it holds
Epoch Rotation
Section titled “Epoch Rotation”Handles roll every 24 hours, and both parties re-derive independently. Nothing is redistributed.
Two consequences implementers need to plan for:
Entries do not survive the boundary. KeyPackages uploaded under epoch N are stored at a handle nobody looks for during epoch N+1. Devices MUST re-upload their supply each epoch and let the old entries expire.
This is deliberate rather than incidental. Any server-side “migrate this handle to that one” operation would tell the server the two handles belong to the same device, collapsing the epoch reset entirely. Re-upload is the cost of the property.
Clocks disagree at boundaries. A fetcher whose clock sits a few seconds either side of the rollover derives the wrong handle and gets a miss.
- Devices SHOULD upload under the new epoch shorts before* the boundary, keeping both live briefly
- Clients SHOULD retry a miss against the adjacent epoch before treating it as exhaustion
- Servers MUST NOT link handles across an overlap; they cannot distinguish two epochs of one device from two different devices
This mirrors the overlap window used for mailbox rotation.
Depletion
Section titled “Depletion”Metering fetch prevents KeyPackage drain attacks where anyone who knows a device_id can drain a device’s entire KeyPackage supply and block it from beind added to any group. Charging a FetchKeyPackage token per fetch prices exhaustion.
Implementations SHOULD additionally publish an MLS last-resort KeyPackage, so a device that is drained degrades to a reusable fallback rather than becoming unaddable.
What the Server Learns
Section titled “What the Server Learns”| Sees | Does not see |
|---|---|
| An opaque 32-byte handle | The device_id behind it |
| Ciphertext and its size | They KeyPackage or its credential |
| Upload and fetch counts per handle, within one epoch | Any link between handles across epochs |
expires_at, for garbage collection | Who is adding whom to a group |
The residual is per-handle volume inside a single epoch: roughly how often an unidentified device is being added to groups that day. It does not accumulate, because the handle is discarded at the rollover.
Security Considerations
Section titled “Security Considerations”What this protects against
Section titled “What this protects against”- Action correlation. No credential ties a device’s actions together over time.
- Issuance-to-redemption linking. Blinding means the server cannot recognise a token it signed.
- Device identity at the KeyPackage layer.
device_idappears in no field the server stores. - Hostile issuers. A modified server’s over-issuance is confined to its own infrastructure.
- KeyPackage depletion. Fetch costs a token.
What this does not protect against
Section titled “What this does not protect against”- IP correlation. A server links redemptions to a source address, and thence to that device’s mailboxes. This requires network-level anonymity to prevent and that is out of scope at this time.
- Issuance-time volume. A server knows how many tokens a device took, and roughly when.
- Within-epoch KeyPackage volume. Fetches for one device share a handle for 24 hours, so consumption per pseudonym is visible within that window.
- Timing adjacency. A client that requests tokens immediately before spending them defeats the unlinkability itself.