Updated September 13, 2026

Cryptographic Specification

This page is a complete technical reference for the cryptographic primitives, key derivation paths, and encryption boundaries used in the Whenary production service. All claims are sourced from the deployed frontend and backend source code. Where the server can decrypt a field, this page says so.

Guarantee summary

Whenary uses two distinct encryption regimes. Server-side application encryption protects data at rest against database breaches. The server holds the key and can decrypt these fields when operating normally. Client-side end-to-end encryption gives users the key. The server stores only ciphertext it cannot read, including under compulsion.

DataEncrypted byKey held byServer can decrypt
Event title, description, location, URL, conference links (E2EE calendar)Client (browser/app)User onlyNo
Space and event chat messages (E2EE calendar or space)Client (browser/app)User onlyNo
Username, email, display namesServerServerYes
Space names (E2EE space chat)Client (browser/app)User onlyNo
Space names (server-readable space)ServerServerYes
Event cover images (E2EE calendar)Client (browser/app)User onlyNo
Calendar names and descriptions (E2EE calendar)Client (browser/app)User onlyNo
Calendar names and descriptions (server-readable calendar)ServerServerYes
Event titles etc. (non-E2EE calendar)ServerServerYes
Event timing, recurrence, timezone (all calendars including E2EE)Plaintext in DBN/AYes
OPAQUE password (the credential itself)Never storedN/ANever receives it
OPAQUE exportKey (key derivation seed)Client onlyUser deviceNo
Vault master keyClient, wrapped under KEKUser (KEK)No
Identity private key (X25519)Client, wrapped under master keyUser onlyNo
Photos and mediaPlaintext during upload processingN/AYes (during processing)
Attendee lists, RSVP status, space membershipsPlaintextN/AYes

Password authentication (OPAQUE)

Whenary uses the OPAQUE asymmetric password-authenticated key exchange protocol via the @serenity-kit/opaque library. OPAQUE ensures the server never receives the account password in any form, even in transit.

Registration

  1. Client calls opaque.client.startRegistration(password), producing a registrationRequest and a local clientRegistrationState.
  2. Client sends registrationRequest to the server.
  3. Server calls opaque.server.createRegistrationResponse using the OPAQUE_SERVER_SETUP secret and the user identifier. Returns a registrationResponse. Stores nothing yet.
  4. Client calls opaque.client.finishRegistration(clientRegistrationState, registrationResponse, password, keyStretching: 'memory-constrained'). Produces a registrationRecord and an exportKey.
  5. Client sends registrationRecord to the server. The server stores it. The exportKey never leaves the client.

Login

  1. Client calls opaque.client.startLogin(password).
  2. Server calls opaque.server.startLogin using the stored registrationRecord. Produces a loginResponse and a short-lived serverLoginState stored in Redis (5-minute TTL).
  3. Client calls opaque.client.finishLogin(clientLoginState, loginResponse, password). Produces a finishLoginRequest and an exportKey.
  4. Server calls opaque.server.finishLogin(serverLoginState, finishLoginRequest). A sessionKey is derived (used only for mutual authentication proof). The server issues a session cookie. The exportKey on the server side is discarded. Only the client holds it.

Key stretching is set to 'memory-constrained' on both registration and login. This tunes the Argon2id parameters inside OPAQUE for memory-hard brute-force resistance at the cost of some client CPU time.

The exportKey is a cryptographically strong pseudorandom value derived from the password and the server-side OPAQUE state. It is available in JavaScript memory only for the duration of the vault unlock sequence, then zeroed. It is never sent to the server.

Encryption vault

Each account holds a vault. The vault is created on first login after OPAQUE registration and holds the user's master key and identity keypair in wrapped form. All vault operations happen on the client.

Master key

A 32-byte (256-bit) random master key is generated with crypto.getRandomValues at vault creation. It is never stored in plaintext. It is wrapped (AES-256-GCM encrypted) before upload and unwrapped on each login.

Password key-encryption key (KEK)

The KEK for wrapping the master key under the password path is derived from the OPAQUE exportKey:

HKDF-SHA-256(
  ikm  = exportKey          (32 bytes, from OPAQUE login)
  salt = kdfSalt            (16 random bytes, stored on server)
  info = 'whenary.e2ee.kek.password.v1'
  len  = 32 bytes
)

The exportKey is zeroed after derivation. The kdfSalt is stored in the vault record on the server in plaintext. It is a public nonce, not a secret.

Recovery key-encryption key

A second KEK is derived from a BIP39 recovery phrase generated at vault creation:

HKDF-SHA-256(
  ikm  = recoveryPhraseSeed  (bytes derived from mnemonic)
  salt = kdfSalt             (same salt as above)
  info = 'whenary.e2ee.kek.recovery.v1'
  len  = 32 bytes
)

The master key is wrapped independently under both KEKs and stored on the server. Either path can unlock the vault. Losing both means the vault cannot be opened unless a WebAuthn PRF wrap was also stored for a remaining passkey.

WebAuthn PRF key-encryption key

Passkey registration and authentication request the WebAuthn PRF extension. When the authenticator returns a PRF secret, the client derives a third KEK and stores a wrap on that passkey row. The server cannot compute the PRF output.

HKDF-SHA-256(
  ikm  = WebAuthn PRF first output
  salt = kdfSalt
  info = 'whenary.e2ee.kek.prf.v1'
  len  = 32 bytes
)
eval salt = 'whenary.e2ee.kek.prf.v1:{userId}'

After that wrap exists, a locked vault can be opened with the same passkey without re-entering the password. Tab persist still uses the device wrapping key so a reload does not prompt for the authenticator.

Master key wrapping

Master key wrapping uses AES-256-GCM with a 12-byte random IV and domain-separated additional authenticated data (AAD):

wrappedMasterKeyPassword = AES-256-GCM(
  key   = passwordKEK
  data  = masterKey (32 bytes)
  iv    = 12 random bytes
  aad   = 'whenary:vault:master:password:{userId}'
)
format: 'e1.{base64_iv}.{base64_ciphertext+tag}'

wrappedMasterKeyRecovery = AES-256-GCM(
  key   = recoveryKEK
  data  = masterKey (32 bytes)
  iv    = 12 random bytes
  aad   = 'whenary:vault:master:recovery:{userId}'
)
format: 'e1.{base64_iv}.{base64_ciphertext+tag}'

wrappedMasterKeyPrf = AES-256-GCM(
  key   = prfKEK
  data  = masterKey (32 bytes)
  iv    = 12 random bytes
  aad   = 'whenary:vault:master:prf:{userId}:{credentialId}'
)
format: 'e1.{base64_iv}.{base64_ciphertext+tag}'

The GCM authentication tag (16 bytes) is appended to the ciphertext by the WebCrypto API before base64 encoding. Decryption fails if the ciphertext, IV, or AAD is tampered with.

Identity keypair (X25519)

An X25519 keypair is generated on the client at vault creation. The private key is wrapped under the master key:

wrappedPrivateKey = AES-256-GCM(
  key   = masterKey
  data  = x25519PrivateKey (32 bytes)
  iv    = 12 random bytes
  aad   = 'whenary:vault:identity:{userId}'
)
format: 'e1.{base64_iv}.{base64_ciphertext+tag}'

The X25519 public key is stored in plaintext on the server and used by other users to seal calendar or space keys for sharing. The private key is unwrapped in client memory during vault unlock and used for decrypting incoming grants.

What is stored on the server

FieldValue typeSecret?
kdfSalt16 random bytes (base64)No (public nonce)
wrappedMasterKeyPasswordAES-GCM ciphertextYes (protected by OPAQUE exportKey)
wrappedMasterKeyRecoveryAES-GCM ciphertextYes (protected by recovery phrase)
publicKeyX25519 public key (base64)No (intentionally public)
wrappedPrivateKeyAES-GCM ciphertextYes (protected by master key)
recoveryConfirmedAtTimestamp or nullNo

In-memory storage

After unlocking, the master key is held in memory as a hex string in the Zustand vault store. On web, the key is not written to sessionStorage in the clear. Instead, a 256-bit AES-GCM wrapping key is generated per device and stored in IndexedDB with extractable: false. The master key is encrypted under that device key and the resulting ciphertext is written to sessionStorage under whenary:e2ee:master. On reload, the device key is retrieved from IndexedDB and used to unwrap the master key from sessionStorage, avoiding a re-prompt. If IndexedDB is unavailable the raw bytes fall back to sessionStorage directly. On Android (Capacitor), the key is stored in Capacitor Preferences under whenary.e2ee.master. In all cases the key is cleared on logout or vault lock.

Calendar and space keys

Each end-to-end encrypted calendar and each E2EE-enabled space chat has its own 32-byte AES-256 symmetric key. Multiple key versions are tracked. The current version is the highest seen in the user's grants.

Key generation

When E2EE is enabled on a calendar or space chat, the owner generates a 32-byte random key and wraps it under their master key:

calendarKey = randomBytes(32)

ownerGrant = AES-256-GCM(
  key   = masterKey
  data  = calendarKey
  iv    = 12 random bytes
  aad   = 'whenary:calendar:key:{calendarId}'           (version 1)
       or 'whenary:calendar:key:{calendarId}:v{n}'     (version n > 1)
)
stored as GrantRecord { wrapMethod: 'master', wrappedKey, keyVersion: 1 }

Space chat keys use the same structure with AAD whenary:space:key:{spaceId}.

Sharing keys with other users (ECDH seal)

When the calendar owner shares with another user, the calendar key is sealed for their identity public key using an ephemeral X25519 ECDH exchange:

ephemeralPrivateKey = randomBytes(32)
ephemeralPublicKey  = x25519.getPublicKey(ephemeralPrivateKey)

sharedSecret = x25519(ephemeralPrivateKey, recipientPublicKey)

sealKey = HKDF-SHA-256(
  ikm  = sharedSecret
  salt = randomBytes(32)    (stored alongside as 'salt')
  info = 'whenary.e2ee.kek.ecdh.v1'
  len  = 32 bytes
)

sealedCalendarKey = AES-256-GCM(
  key   = sealKey
  data  = calendarKey (raw bytes)
  iv    = 12 random bytes
  aad   = 'whenary:calendar:grant:{calendarId}:{recipientUserId}'
       or 'whenary:calendar:grant:{calendarId}:{recipientUserId}:v{n}'
)

stored as GrantRecord {
  wrapMethod: 'ecdh',
  wrappedKey: sealedCalendarKey,
  ephemeralPublicKey,
  salt,
  keyVersion
}

Recipients decrypt by computing the same ECDH shared secret using their private key and the stored ephemeral public key, re-deriving the seal key via HKDF, and decrypting with the matching AAD. Because the ephemeral key is fresh for each grant, each sealed copy is independent. The grant request must include the recipient's current public key and identityEpoch. The server rejects the write if either no longer matches the stored vault, so a substituted public key cannot silently receive a newly shared calendar key.

Space grants use the same pattern with AAD whenary:space:grant:{spaceId}:{userId}.

Key rotation

The owner can rotate a calendar key to revoke access from removed members. Rotation:

  1. Generates a new 32-byte key (version = oldVersion + 1).
  2. Wraps the new key under owner's master key.
  3. Seals the new key for each kept member via ECDH.
  4. Re-encrypts all existing events under the new key.
  5. Re-wraps all media file keys under the new key.
  6. Atomically submits new grants, re-encrypted events, and re-wrapped media keys to the server in a single request.

Rotation prevents future decryption by removed members. It does not retract content already decrypted and copied before removal.

Event field encryption

On an E2EE calendar, the following fields of each event are encrypted on the client before upload using the calendar key for that event's key version:

  • Title
  • Description
  • Location
  • URL
  • Conference link URIs (each URI individually)
  • Event chat message bodies

Encryption format

ciphertext = AES-256-GCM(
  key   = calendarKey[version]
  data  = UTF-8(plaintext)
  iv    = 12 random bytes (fresh per field per save)
  aad   = length-prefixed context:
          purpose | protocol | resource | scopeId | recordId | field | keyVersion

stored as: 'e2ee2.' + keyVersion + '.' + base64(iv) + '.' + base64(ciphertext+tag)
legacy:    'e2ee1.' + 'e1.' + base64(iv) + '.' + base64(ciphertext+tag)

New writes use the e2ee2. envelope, which binds ciphertext to a specific record so values cannot be swapped between events, messages, or files. Legacy e2ee1. values remain readable and are upgraded on edit or rotation. Timing metadata is authenticated with a separate integrity tag. Push workers store a derived notification key and decrypt e2ee2n. titles only. Guest RSVP, availability-poll, and public calendar links for encrypted calendars carry a fragment key in the URL hash. The server stores ciphertext and never sees that key.

Field names and AAD

Field stored on serverAAD info string (v1)AAD info string (v2+)
titlewhenary:event:{calendarId}:titlewhenary:event:{calendarId}:title:v{n}
descriptionwhenary:event:{calendarId}:descriptionwhenary:event:{calendarId}:description:v{n}
locationwhenary:event:{calendarId}:locationwhenary:event:{calendarId}:location:v{n}
urlwhenary:event:{calendarId}:urlwhenary:event:{calendarId}:url:v{n}
conferences[i].uriwhenary:event:{calendarId}:conferenceswhenary:event:{calendarId}:conferences:v{n}
message body (event chat)whenary:event:{calendarId}:messagewhenary:event:{calendarId}:message:v{n}

The AAD binds each ciphertext to its calendar, field type, and key version. A ciphertext cannot be moved to a different calendar or field without causing decryption to fail.

What is not encrypted

The following event fields are stored in plaintext even on E2EE calendars, because the server must read them to operate correctly:

  • Start time, end time, all-day flag
  • Timezone
  • Recurrence rule (RRULE), excluded dates (EXDATE)
  • Color
  • Attendee user IDs and RSVP status
  • External attendee email addresses
  • e2eeKeyVersion (which version of the calendar key encrypted this event)

Space chat encryption

When a space owner enables E2EE on a space's chat, message bodies are encrypted using the space key rather than a calendar key. The algorithm and format are identical to event field encryption:

messageBody = AES-256-GCM(
  key   = spaceKey[version]
  data  = UTF-8(messageText)
  iv    = 12 random bytes
  aad   = 'whenary:event:{spaceId}:message'           (version 1)
       or 'whenary:event:{spaceId}:message:v{n}'      (version n > 1)
)

stored as: 'e2ee2.{keyVersion}.{base64_iv}.{base64_ciphertext+tag}'
legacy:    'e2ee1.e1.{base64_iv}.{base64_ciphertext+tag}'

Note: the AAD uses the literal field name message and the spaceId in the same position as calendarId in the event path. Event chat on an E2EE calendar uses the calendar key and the calendarId.

Server-side application encryption

Fields that are not end-to-end encrypted are still encrypted at rest by the server before writing to the database. The server holds the key and can decrypt these fields when operating normally. This protects against raw database access (e.g., a backup exfiltration) but does not protect against a compromised server process.

Key derivation

ENC_KEY         = scrypt(ENCRYPTION_KEY,    'whenary.aes256gcm',       len=32)
MAC_KEY         = scrypt(SESSION_SECRET,    'whenary.hmac.sha256',     len=32)
BLIND_INDEX_KEY = scrypt(BLIND_INDEX_SECRET,'whenary.blind-index.sha256', len=32)

Optional DEK (key rotation):
DATA_KEY = AES-256-GCM-decrypt(WRAPPED_DATA_KEY, ENC_KEY)
           (unwrapped once at startup, held in process memory)

ENCRYPTION_KEY, SESSION_SECRET, and BLIND_INDEX_SECRET are environment variables set by the operator. Key derivation uses Node.js crypto.scryptSync with the domain string as the salt.

Ciphertext format

v1 format (direct ENC_KEY):
  'v1.{base64url_iv}.{base64url_tag}.{base64url_ciphertext}'

v2 format (wrapped DEK, if WRAPPED_DATA_KEY is configured):
  'v2.{base64url_iv}.{base64url_tag}.{base64url_ciphertext}'

ale2 format (context-bound keyring writes):
  'ale2.{keyId}.{base64url_iv}.{base64url_tag}.{base64url_ciphertext}'

algorithm: AES-256-GCM, 12-byte IV, 16-byte auth tag (explicit, separate from ciphertext)
v1/v2 have no AAD. ale2 AAD binds purpose, model, field, and row id.

Dual-read still accepts v1, v2, and ale2. New application-layer writes use ale2 with AAD bound to the model, field, and row so those values cannot be copied onto a different record. Legacy unbound ale1 and unbound ale2 rows still decrypt.

Fields encrypted server-side

All of the following are encrypted before database write and decrypted in server memory when needed. The server can always read these:

  • Username, email address
  • Space names, display names, passkey labels
  • Calendar names and descriptions (non-E2EE calendars)
  • Event titles, descriptions, locations, URLs, tags, and conference links (non-E2EE calendars)
  • Event message bodies (non-E2EE calendars)
  • List and checklist labels, poll text (non-E2EE calendars and spaces)
  • Guest RSVP names, aliases, notes, and guest poll names (non-E2EE calendars)
  • Photo captions on non-E2EE calendars, subscription labels, public link labels and tokens
  • TOTP secrets
  • BYOS credentials (access key, secret key, bucket, endpoint)
  • OAuth access and refresh tokens (Google Calendar, Microsoft Outlook)
  • Push subscription endpoint and key material, in-app notification title and body
  • Guest event token labels
  • OIDC identity linkage
  • Contact form messages and reply addresses

Blind indexes

Searchable encrypted fields also have a blind index stored alongside them. A blind index is an HMAC-SHA256 of the normalized plaintext, keyed separately from the encryption key. It allows exact-match lookups without decrypting all rows:

blindIndex(scope, value) = HMAC-SHA256(BLIND_INDEX_KEY, '{scope}\0{value}').base64url

Indexes in use:
  user.email         -> emailBlindIndex  (normalized lowercase)
  user.username      -> usernameBlindIndex  (normalized lowercase)
  push.endpoint      -> pushEndpointBlindIndex
  oidc.issuer-subject -> oidcIssuerSubjectBlindIndex  ('issuer\0subject')
  attendee.email     -> externalAttendeeEmailBlindIndex  ('eventId\0email')

Identity verification

Users can verify each other's identity keys out-of-band using safety words or a safety number, similar to Signal's safety numbers. These are derived from the pair of X25519 public keys.

Derivation

pairDigest = SHA-256(
  'whenary.safety.v1'  (UTF-8)
  || sort(publicKeyA, publicKeyB)[0]  (32 bytes, lower of the two)
  || sort(publicKeyA, publicKeyB)[1]  (32 bytes, higher of the two)
)

Safety words (8):
  for i in 0..7:
    index = ((pairDigest[i*2] << 8) | pairDigest[i*2+1]) % 2048
    word  = BIP39_wordlist[index]

Safety number (60 digits):
  for each byte in pairDigest:
    append (byte % 10) as a decimal digit
  format as groups of 5 separated by spaces

Key fingerprint (single key):
  SHA-256(publicKey)[0:16] as uppercase hex groups of 4

The keys are lexicographically sorted before hashing so both parties compute the same digest regardless of who initiates the comparison. The BIP39 wordlist has 2048 entries. 8 words provides approximately 88 bits of comparison space.

Key pinning

The browser stores seen public keys for each user ID in localStorage under the key whenary:e2ee:pinned-keys. Each entry records the public key, when it was first seen, and optionally when it was verified by the user. If a contact's key changes, the UI shows a warning. This is a trust-on-first-use (TOFU) model. Users who verify out-of-band can mark a key as confirmed.

Primitive reference

PrimitiveAlgorithmParametersUsage
Symmetric encryptionAES-256-GCM256-bit key, 12-byte IV, 16-byte tagAll AES operations (client and server)
Key derivation (client)HKDF-SHA-256IKM = exportKey or shared secret, 16-byte salt, info stringKEKs, ECDH seal key
Key derivation (server)scryptN=32768, r=8, p=1 (Node defaults), domain string as saltENC_KEY, MAC_KEY, BLIND_INDEX_KEY
Key exchangeX2551932-byte scalar, Curve25519ECDH seal for cross-user key sharing
Authentication codeHMAC-SHA25632-byte keyBlind indexes, OPAQUE proof tokens
Password protocolOPAQUEmemory-constrained key stretchingLogin / registration
Identity fingerprintSHA-256StandardSafety words, safety number, key fingerprint
Recovery phraseBIP3912-24 wordsRecovery KEK seed

Ciphertext format reference

PrefixFormatUsed for
e1.e1.{base64_iv}.{base64_ciphertext+tag}Vault key wrapping (client WebCrypto)
e2ee1.e1.e2ee1.e1.{base64_iv}.{base64_ciphertext+tag}Legacy event/message field values (E2EE)
e2ee2.e2ee2.{keyVersion}.{base64_iv}.{base64_ciphertext+tag}Record-bound event, chat, caption, and list fields (E2EE)
e2ee2n.e2ee2n.{keyVersion}.{base64_iv}.{base64_ciphertext+tag}Notification-scoped E2EE titles for push workers
ale1.v1. / ale1.v2.ale1.{v1|v2}.{base64url_iv}.{base64url_tag}.{base64url_ciphertext}Legacy server-side field encryption without row AAD (read-only dual-read)
ale2.ale2.{keyId}.{base64url_iv}.{base64url_tag}.{base64url_ciphertext}Server-side field encryption bound to model, field, and row

Client-side ciphertexts use standard base64 (RFC 4648) and e1., e2ee1., or e2ee2. prefixes. Server-side ciphertexts use URL-safe base64 (base64url) under ale1. or bound ale2.. Decrypt tries the field AAD first, then unbound ale2, then legacy v1/v2. The GCM tag is appended to the ciphertext on the client side (WebCrypto behavior) and stored separately on the server side (Node.js crypto behavior). Both are 16 bytes.

What the server can and cannot read

The following summarizes the access model for the official hosted service. A compromised server process or operator with database access can:

  • Decrypt all server-side application-encrypted fields (username, email, names, non-E2EE event content, etc.).
  • Read all event timing, recurrence, and attendee data, including on E2EE calendars.
  • See the relationship graph: who belongs to which space, who is friends with whom.
  • Observe push notification dispatch timing.
  • Read photos and media during the upload processing window.

The server cannot, by design:

  • Recover the OPAQUE exportKey (it is never transmitted).
  • Decrypt the vault master key (it is stored only as an AES-GCM ciphertext whose key is the exportKey-derived KEK, which the server never has).
  • Decrypt the identity private key (wrapped under the master key).
  • Decrypt event titles, descriptions, locations, URLs, or conference links on E2EE calendars.
  • Decrypt E2EE chat message bodies.
  • Recover the original account password (OPAQUE ensures this).

Source maps for the production browser bundle are published to allow independent inspection of client-side E2EE behavior in browser developer tools.

Open-source protocol package

The cryptographic layer is published as a standalone package, @whenary/protocol, under the Apache 2.0 license (which includes a patent grant relevant for cryptographic implementations). It has no dependencies beyond @noble/curves, @noble/hashes, and the WebCrypto API. The package includes machine-readable known-answer test vectors in test-vectors/protocol-v2.json cross-validated against RFC 7748 reference values and independent Python implementations of every primitive.

Any language implementation can import the vectors and run them as a conformance check without pulling in the Whenary application stack. The XEdDSA signing variant and the AAD construction rules for record binding are fully specified by the package source and test vectors.

Bundle integrity attestation

At each production deploy, CI generates a manifest of the SHA-256 hashes of every deployed JavaScript and CSS bundle. The manifest is PGP-signed with the same Whenary Releases key that signs the warrant canary, and published at /integrity.json.asc.

To verify independently: open your browser's network tab, find the E2EE-related chunk (look for a chunk whose URL contains e2ee or primitives), compute its SHA-256, fetch /integrity.json.asc, verify the PGP signature against the Releases public key (fingerprint EEBF 890C 975A A1D6 4331 5962 7C83 207D D1A2 2E53), and compare the hash against the signed manifest. If the file is absent or its signature does not verify, treat it as a signal that the deployed code may not match the published source.

Command-line verification

curl https://whenary.com/integrity.json.asc | gpg --verify

Import the Releases key first if needed:

curl https://whenary.com/security-pgp.asc | gpg --import

Scope limits and known gaps

  • Timing metadata on E2EE calendars (start time, duration, recurrence) is readable by the server. An observer can tell you have an event at a given time, but not what it is.
  • Attendee identity (user IDs) and RSVP status are not encrypted, even on E2EE calendars.
  • External attendee email addresses are always readable by the server.
  • Space names use server-side encryption. Encrypted-calendar names and descriptions are sealed on the device under the calendar key. Checklists, poll questions, photo captions, and space lists follow the encryption of their calendar or space. Guest poll names and comments on encrypted calendars use the same fragment-key path as guest RSVP fields. Public calendar links include encrypted calendars only when the creating vault can wrap those calendar keys under a client-only #gk= fragment. Without the fragment, those events render as busy. The server never receives the fragment key.
  • The TOFU key-pinning model (localStorage) provides a local change-detection mechanism, not a cryptographic chain-of-custody. The server can serve a different public key to a different device and the affected device would show a warning only on next comparison.
  • Photos and attachments on encrypted calendars are encrypted on-device in chunks bound to the file id. The server stores ciphertext it cannot read.

See also: Security and Data Practices, Warrant Canary, Privacy Policy.

Cryptographic Specification · Whenary