Encryption & data handling
An in-depth reference for how the platform encrypts data, what it deliberately stores in plaintext (and why), and how an SDK keeps data on your own machines. This is the deep version of the raw encryption spec — read this to understand the full model, the asymmetry between person data and company data, and the durability rules behind the changes pump.
Zero-knowledge & keys
Before the byte-level detail, the mental model. Zero-knowledge here means private/person data is end-to-end encrypted: the platform stores only ciphertext and cannot read it. The plaintext exists only on the device that wrote it and on the machine that holds the matching private key, where it is decrypted client-side / off-platform, in memory. The platform is a transport and config surface, not a reader of protected data.
Three distinct keypairs are in play, each held by a different party for a different purpose. Knowing which one you hold tells you exactly what you can decrypt.
Per-user keypair (allme app users)
Every allme app user gets an RSA-2048 keypair, generated client-side on first login.
- The public key is shareable — connections fetch it to encrypt values for the user.
- The private key is encrypted with the user's password (PBKDF2-HMAC-SHA256, ~100k iterations → AES-256-GCM) and the platform stores only that encrypted blob. The server never sees the plaintext private key; it is unwrapped in memory on the user's device after they enter their password.
This is what protects a person's own private fields and the per-recipient encrypted copies of those private fields they share with connections. A person's ordinary (non-private) values are stored once, sealed with a per-person symmetric sharing key (AES-256-GCM) that is wrapped to the public key of each person they share with. A company integration touches none of these: the sharing key is never sealed to a service key, and everything a service reads is encrypted to the service key (below).
Per-service keypair (company-data & SDKs)
The keypair you care about as an integrator. Each service has its own RSA-2048 keypair, generated in the portal browser at service creation, and it is zero-knowledge:
- The private key is encrypted client-side with a random passphrase shown once, which never reaches the server in the clear; the platform stores only the encrypted blob plus an OpenSSL-compatible PEM. The portal also keeps a copy of the passphrase sealed to your company's recovery key, which only your account password opens.
- The public key is served to connecting persons (
GET /api/keys/{companyCode}/{serviceCode}) so their clients can encrypt person→service values to your service key. - Only a holder of the service private key — your backend or an SDK configured with it — can decrypt that data. See The service keypair & PEM for the exact PBES2 PEM format.
Company account key
A separate, server-held keypair, unrelated to the per-service keypair that decrypts field values. It encrypts the optional webhook payload envelope (the encrypt_payload toggle — see Webhooks), and it is the key another company encrypts to when your company is a customer of its service: the public-key endpoints (/api/keys/*) serve it for your company's share code. You download its private half in the portal under Settings → Account key.
The one deliberate exception
Not everything is encrypted. Your company's business-card data — service share values and the company profile — is stored in plaintext by explicit design, because it flows the other way (company → person) and there is no benefit to hiding it from the platform. That asymmetry, and the three reasons behind it, are covered in What is NOT encrypted — and why.
The encryption scheme
Person data is protected with hybrid encryption: a fresh symmetric key per value (fast, for arbitrary-length data) whose key is in turn wrapped with the recipient's RSA public key (so only the holder of the matching private key can unwrap it). Concretely, for every value:
- Generate a random AES-256 key.
- Encrypt the plaintext with AES-256-GCM using a fresh random 12-byte IV; GCM produces a 16-byte authentication tag that is appended to the ciphertext.
- Wrap (encrypt) the AES key with RSA-OAEP (2048-bit) using the recipient's public key.
- Pack the three pieces into the self-describing JSON wrapper below.
To decrypt, the holder of the private key reverses it: RSA-OAEP-decrypt k → the 32-byte AES key, then AES-256-GCM-decrypt d with that key and iv (the GCM tag is verified as part of the decrypt). A tampered or truncated value fails the GCM tag check and is rejected.
The {"_enc":1,…} wrapper
Every encrypted value your service receives — a person→service field, a private binary blob — is the same compact JSON wrapper, as is every per-recipient copy of a private field between persons. The literal _enc:1 marker is how clients detect "this is ciphertext, not plaintext" (a plain JSON.parse(value)._enc === 1 check). Inside the allme apps a person's own non-private values carry a second marker, _enc:2 (sealed with their sharing key, no k); it never appears on the company-data surface.
{
"_enc": 1,
"k": "base64( RSA-OAEP( aes_key, recipient_public_key ) )",
"iv": "base64( 12-byte AES-GCM IV )",
"d": "base64( AES-256-GCM ciphertext with the 16-byte tag appended )"
}
k— the AES key, RSA-OAEP-wrapped for the recipient. Base64.iv— the 12-byte GCM IV. Base64.d— the AES-256-GCM ciphertext with its 16-byte tag appended (the last 16 bytes ofdare the tag). Base64.
Two OAEP digests are in play
OAEP is parameterized by a hash. The platform uses two different ones deliberately — don't reuse one routine for both:
| Where | OAEP digest | Why |
|---|---|---|
Per-field / per-service values (all person→service data, all /api/company-data values, the {"_enc":1,…} wrapper above) | SHA-256 (MGF1-SHA256) | The platform's primary encryption contract. Pin the hash explicitly per language — most crypto libraries default OAEP to SHA-1, which will not interoperate. |
Webhook account-key envelope only (the optional encrypt_payload outer envelope — see Webhooks) | SHA-1 (MGF1-SHA1) | This envelope is produced server-side with OpenSSL's openssl_public_encrypt, whose default OAEP padding is SHA-1. It is the only place SHA-1 appears, and it wraps the whole webhook body, not a field value. |
encrypt_payload webhook envelope, where you can decrypt with openssl pkeyutl -decrypt -pkeyopt rsa_padding_mode:oaep (OpenSSL's default SHA-1). The SDKs handle both correctly and you never see the difference.The service keypair & PEM
Each service has its own RSA-2048 keypair, generated client-side in the portal browser at service creation — the platform never sees the service private key in plaintext:
- The public key is stored on the platform and served to connecting persons (
GET /api/keys/{companyCode}/{serviceCode}, plus its SHA-256 fingerprint) so they can encrypt to your service. - The private key is zero-knowledge: its passphrase is shown once at creation and never sent to the server. It is downloadable as an OpenSSL-compatible encrypted PKCS#8 PEM, protected with PBES2 (PBKDF2-HMAC-SHA256, 100k iterations → AES-256-CBC). You decrypt it with the passphrase to get a usable key; the SDK loads it once in memory at startup and never writes the decrypted key back to disk.
# decrypt the downloaded service PEM with its passphrase
openssl pkcs8 -in service-CRM.pem -passin pass:"$ALLUS_KEY_PASSPHRASE"
# → -----BEGIN PRIVATE KEY----- … (use this to RSA-OAEP-SHA256-decrypt the "k" field)
Binary values — the _enc_file reference
Photos, documents and legal documents are encrypted exactly like any other value (the same {"_enc":1,…} wrapper), but because the ciphertext is large it is stored as a file on disk rather than inline in a row. The stored value is then a small reference:
{ "_enc_file": "enc_{ownerId}_{uuid}.json" }
The file's content is the same wrapper — an {"_enc":1,"k":…,"iv":…,"d":…} blob. You fetch a binary answer's bytes lazily from the slot-keyed file endpoint and decrypt it the same way. Once decrypted, the inner plaintext is a JSON file-envelope: {"full":"data:…","thumb":"data:…"} for photos, {"file":"data:…","original_name":…,…} for documents.
This applies only when the person's source field is private. A binary answer whose source field is not private is never encrypted and never copied — the file endpoint serves the person's own stored file as raw bytes with its own Content-Type, and there is nothing to decrypt. That is a deliberate storage decision, not an oversight: a per-service copy of every shared file would multiply with every share, and encrypting a value the person publishes in the clear would buy nothing. Branch on the response's Content-Type; see API → binary file.
Company documents — per-person is encrypted to the person
For a company document, encryption is keyed on the target, not on is_private. Every per-person document (ANY is_private) is end-to-end encrypted with the exact same RSA-OAEP-SHA256 + AES-256-GCM {"_enc":1,…} wrapper as person field values — but the recipient is the person, so the AES key is wrapped for the person's public key (resolved via GET /api/keys/{shareCode} using the share_code on the connection), not your service key. The platform therefore never holds the plaintext of a per-person document, and only that person's app can decrypt it. A per-person json document stores the wrapper inline in value; a per-person file document stores it as an _enc_file reference, exactly like a binary field value above. The SDK's encrypt_for_public_key produces this wrapper for you.
A contract (a signable / acceptable document) is the same mechanism — it must be per-person, so it is always end-to-end encrypted to the recipient with this exact wrapper. No new crypto applies; the contract layer only adds the sign/accept flow on top.
Broadcast documents are plaintext — you can't single-key-encrypt one payload to every connection (the deliberate exception above). is_private is a separate, device-display-only flag: it governs how the recipient's app reveals the document (lock-on-reveal vs decrypt-on-load), exactly like a contact field's is_private — it does not decide whether the document is encrypted. Since a plaintext broadcast can't be locked, is_private=true requires a per-person target and a broadcast keeps is_private=false.
Zero-knowledge: person → service
Every value a person provides to your service — a mapped live field, a one-time typed answer, a custom field — is encrypted for your service public key on the person's own device, before it leaves it (the non-private photo or document above excepted). The consequences:
- The platform stores and serves only ciphertext for person data. The API has no way to read it.
- The API actively rejects plaintext for encryptable (non-binary) person values — sending an unencrypted value returns
400 contact_data.encryption_required. - The only thing that can decrypt it is a holder of your service private key — i.e. your own backend (or an SDK) configured with that key. Decryption happens off-platform, in memory.
- The platform server never shows person data. In the portal, only a delegate sees the values — decrypted in their own browser with a service key the company handed them; the company admin view shows that a person connected and what slots they filled, never the values.
Live-mapped values stay current: when the person edits the underlying field, their client re-encrypts a fresh copy for your service key and the change surfaces on the change feed as field_updated — as does the field gaining or losing its verification, value unchanged. One-time answers are frozen snapshots: they never re-encrypt and keep the verification they were shared with.
What is NOT encrypted — and why
There is a deliberate asymmetry between the two directions of data:
| Data | Direction | At rest | Who can read it |
|---|---|---|---|
| Person field values (mapped / one-time / custom) | person → service | Ciphertext (for the service key) | Only you (with the service private key), off-platform |
| Your service share values | service → person | PLAINTEXT | The platform, the connected person, you |
| Your company profile values | company → all connections | PLAINTEXT | The platform, every connection, you |
Your company's business-card data — the values you share on a service, and your company profile — is stored in plaintext by explicit design. This is the mirror image of person data, and it is intentional for three reasons:
- No passphrase / key-sync UX. Company data has no client-side crypto to manage — there is nothing to unlock, no per-recipient re-encryption to keep in sync. The portal stays a plain config surface.
- Instant visibility. A new connection sees your support email, address, website and other shared values immediately on connect — no decryption step, no waiting on a key exchange.
- Server-side type validation. Because the platform can read these values, it can validate them by field type (a real email, a parseable URL, a structured address). That is impossible on person data, which is opaque ciphertext — the platform cannot type-check what it cannot read.
How data is stored on clients — and why
When you consume the change feed through an SDK, the SDK keeps a small durable buffer on disk. The defining rule of that buffer is:
value / _enc_file reference — the same {"_enc":1,…} wrapper the API returned. It is never decrypted on disk. The SDK decrypts only at delivery time, in memory, one event at a time. No plaintext PII is ever written to disk.Why a durable buffer exists at all
The change feed is drain-on-fetch: GET /api/company-data/changes?limit=N returns up to N events and deletes exactly those rows in the same transaction. There is no cursor, no offset, and the API keeps no copy once a batch is fetched. So the moment a batch comes back over the wire, your local copy is the only copy. If the process crashed after the fetch but before processing, those events would be gone forever.
The SDK pump closes that gap by persisting the fetched batch durably before delivering anything, then acking each event only after your handler succeeds:
- Drain — fetch one batch (≤
batch_size, ≤ 500) and write it to the durable buffer (temp file → fsync → atomic rename → dir fsync) before handing anything out. A crash never leaves a half-written file. - Deliver one-by-one — for each buffered event, oldest-first: decrypt its value at delivery (never on disk), build the typed record, call your handler.
- Ack — on handler success, remove the event from the buffer. On failure, retry with backoff, then dead-letter (or halt). An undecryptable event is dead-lettered immediately.
- Replay on restart — re-opening the buffer recovers whatever is on disk and re-delivers it oldest-first; that recovery is the crash replay.
Because a batch is durably buffered before delivery and acked only after the handler succeeds, delivery is at-least-once: a crash between a handler's success and its ack re-delivers that event. Your handler must be idempotent — dedup on the stable change-row id (captured before the server delete).
The on-disk buffer
Under the SDK's configured cache_dir:
<cache_dir>/pending/<seq>_<change_id>.json # un-acked events, oldest-first (ciphertext)
<cache_dir>/deadletter/<seq>_<change_id>.json # events that exhausted retries (ciphertext)
<seq>is a zero-padded, monotonically increasing sequence, so lexicographic filename order equals oldest-first — stable even when event timestamps tie or are missing.- Stored events keep their ciphertext
value/value_url. Decryption happens only at delivery; nothing on disk is ever plaintext PII. - Dead letters live only in the local store — they are never re-fetched from the API (it already deleted them) and never silently dropped, which is exactly why the buffer must be durable.
Keys live in the config you control
The SDK takes no key, passphrase, or secret as a method argument. Everything sensitive — the service private-key PEM + its passphrase, the optional account key, webhook secrets, the cache_dir — is read from a config file the operator owns. The decrypted service key exists only in process memory; it is never written back to disk in plaintext. See the SDK config reference for the exact fields.
How the apps cache data (iOS · Android · web)
The SDK is not the only place the platform persists data on a client. The end-user allme apps — iOS, Android and web — each keep a local cache so the UI is instant and works offline. Unlike the SDK's buffer, an app cache is mostly plaintext: a user's own app legitimately needs to read and display their data, so it caches it in the clear — with one deliberate exception, the private fields, which stay encrypted at rest. This section makes that boundary explicit.
Cache-first, push-driven
Every app uses one strategy: cache-first reads + push-driven updates. Reads come from the local cache first (instant, offline-capable) and fall back to the API on a miss. Live changes arrive as data-only push notifications whose handlers write the new data straight into the cache from the push payload — they do not invalidate the cache or re-fetch from the API. Every payload carries enough to reconstruct the cached object, so the UI stays consistent without a network round-trip.
Per-platform storage
| Platform | Manager | Storage |
|---|---|---|
| Android | UserCacheManager | JSON files under files/cache/{userId}/ (one file per item) |
| iOS | CacheManager | Files under Library/Application Support/cache/{userId}/, excluded from backup |
| web | cache.js | localStorage keys cache:{userId}:{name} (arrays, not per-item files) |
The cache is scoped per user and covers profile, groups, contact data, connections, messages and related data. Data carries a 27-day TTL; the contact-field catalog expires sooner on web (1 day) and Android (7 days). The iOS and Android apps support expired-fallback: if the API call fails, the app serves stale cached data rather than erroring, so it keeps working offline.
What is encrypted at rest in the cache — and what isn't
{"_enc":1,…} wrapper the API returned (or an {"_enc_file":…} reference for a private photo/document) and is decrypted in memory only when the user unlocks it — its plaintext is never written to disk. Everything else — non-private text, emails, phone numbers, addresses, and regular photos/documents — is cached in the clear, except that the web app keeps a connection's values as ciphertext and decrypts them in memory on read.- Non-private fields → plaintext. Although the apps store non-binary values sealed with the owner's sharing key, each app decrypts its own non-private values on load and caches them in readable form, so the UI is instant (web:
decryptOwnNonPrivate; iOS/Android do the same). iOS and Android also cache a connection's non-private values decrypted; the web keeps a connection's values as ciphertext in its cache and decrypts them in memory on read. - Private fields → ciphertext. They sit in the cache as
{"_enc":1,…}/{"_enc_file":…}exactly as they arrived; the app cannot read them until the user unlocks, and the decrypted value lives only transiently in memory. - Photos & documents → never encrypted, unless the field is private. A regular photo or document is a plain file served normally; only a photo/document whose field is marked private is an encrypted blob (decrypted on unlock).
Revealing a private field in the app (lock & unlock)
Because a private field is ciphertext at rest and its plaintext is never placed in the page until asked, the apps gate it behind an explicit unlock — a second authentication step at view time:
- Locked by default. A private field renders as a lock button with a "Private" badge instead of its value — no plaintext is in the page until the user acts.
- Unlock needs the password (or biometrics). Tapping it prompts for the user's account password — which is what decrypts their password-wrapped private key (PBKDF2 → AES-GCM). On iOS/Android, device biometrics (Face ID / fingerprint) can stand in for the password. Simply being logged in is not enough to read private fields; this is effectively a 2nd-factor check to view them.
- Grace window. After a successful unlock the key stays in memory for a short grace period (~5 min), so other private fields reveal instantly without re-entering the password; when it expires the session re-locks (keys are dropped from the reveal-ready state).
- Per-field auto-lock. Each revealed field re-hides itself after 3 minutes (back to the lock button); a fresh tab/session starts locked.
- Private documents use the same gate: shown locked, decrypted in memory on unlock, never written to disk in the clear. A private photo is decrypted in memory and shown without a lock button, with the keys already loaded.
In summary
- Scheme: hybrid — per-value AES-256-GCM (12-byte IV, 16-byte tag) + an RSA-OAEP-2048 wrapped key, in the
{"_enc":1,"k","iv","d"}wrapper. - Two OAEP digests: SHA-256 for field/service values (all data-API values); SHA-1 only for the optional webhook account-key envelope.
- Person → service: zero-knowledge ciphertext, encrypted for your service public key, decryptable only by you off-platform; the API rejects plaintext.
- Company → person: share values and company profile are PLAINTEXT by design — instant visibility, no passphrase UX, server-side type validation.
- Client storage: the SDK's crash-safe pump buffers events as ciphertext at rest and decrypts only in memory at delivery — never plaintext on disk — because the drain-on-fetch feed leaves the local buffer as the only copy.
- App caches: the iOS, Android and web apps are cache-first + push-driven and store contact data as plaintext, except fields marked private (kept as the
{"_enc":1,…}wrapper, decrypted in memory only on unlock — behind a password/biometric check + a short grace window). The web app also keeps a connection's values as ciphertext, decrypted in memory on read. Regular photos/documents are never encrypted; only private ones are.
Next
- Reference → raw encryption spec — the condensed contract for non-SDK integrators.
- Service API → the change feed — the drain-on-fetch semantics the buffer is built around.
- Webhooks → the account-key envelope — the one place OAEP-SHA1 is used.
- SDKs — all of the above handled for you: config in, decrypted typed conclusions out.