Theme
Open the portal

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.

The one-sentence model. Every value a person sends to your service is end-to-end encrypted for your service key and the platform can never read it (a photo or document from a non-private field is the one exception — it is served as the file itself); everything your company shares back (share values, company profile) is stored plaintext on purpose. Your SDK is the only thing that decrypts person data, off-platform, in memory.

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.

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:

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:

  1. Generate a random AES-256 key.
  2. 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.
  3. Wrap (encrypt) the AES key with RSA-OAEP (2048-bit) using the recipient's public key.
  4. 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 )"
}

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:

WhereOAEP digestWhy
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.
This trips everyone once. If your field values won't decrypt, the most common cause is an OAEP routine left on its SHA-1 default. Pin SHA-256 / MGF1-SHA256 for field values. Reserve SHA-1 strictly for the 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:

# 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:

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.

A note on the company account keypair: it is unrelated to the per-service keypair that decrypts field values — see Company account key.

What is NOT encrypted — and why

There is a deliberate asymmetry between the two directions of data:

DataDirectionAt restWho can read it
Person field values (mapped / one-time / custom)person → serviceCiphertext (for the service key)Only you (with the service private key), off-platform
Your service share valuesservice → personPLAINTEXTThe platform, the connected person, you
Your company profile valuescompany → all connectionsPLAINTEXTThe 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:

Treat your share / profile values as public-to-connections. They are not secret from the platform. Put only data you are comfortable exposing to every connected person there; keep anything sensitive out of share fields. Person data is the opposite — always ciphertext, never readable by the platform.

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:

Ciphertext at rest, plaintext only in memory. The buffer stores each event's encrypted 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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)

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.

This matters for an integrator only as context: the apps and your SDK both follow a "persist locally, refresh from events, never blindly invalidate" model. Your service never reads an app's cache — it is purely client-local state.

Per-platform storage

PlatformManagerStorage
AndroidUserCacheManagerJSON files under files/cache/{userId}/ (one file per item)
iOSCacheManagerFiles under Library/Application Support/cache/{userId}/, excluded from backup
webcache.jslocalStorage 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

The cache stores contact data as plaintext — except fields marked private (by the platform, or by the person on their own custom field). A private field is written to the cache as the same {"_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.
This is weaker than the SDK buffer — by design. The SDK's change-pump buffer is fully ciphertext at rest because every company-data value is encrypted for your service key and your SDK is its only holder — it never decrypts data for display. An app cache belongs to the data's owner, who must see their own data, so only the explicitly-private subset is kept encrypted at rest.

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:

In summary

Next