Skip to main content

Sealed fields

A sealed field is a model attribute encrypted end-to-end to a user: the server writes it with the user's public key and can never read it again. Only the devices of the recipient, holding the matching private key, open it. A database dump, a backup or a curious operator sees ciphertext only.

import { OwnerModel, SealedField } from "@webda/core";

export class Note extends OwnerModel {
content: SealedField<{ title: string; body: string }>;

constructor(data?: Settable<Note>) {
super(data as any);
Object.assign(this, data);
(this as any).__hydrateBehaviors?.();
}
}

// Server side: seal to the owner, then save. The plaintext is not kept on the instance.
await note.content.seal({ title, body });
await note.save();

SealedField is a Behavior (@WebdaBehavior Webda/Sealed). Its UserKeys counterpart on the User model (user.keys, @WebdaBehavior Webda/UserKeys) stores the user public keys, the escrows and the encrypted keyring. The client side lives in the @webda/sealed-client package (KeyVault).

The Note model of the sample app and packages/core/src/models/sealed.e2e.spec.ts are a working end-to-end example.

SealedField or EncryptedField​

EncryptedFieldSealedField
Keyserver key (CryptoService)user key pair, private key only on user devices
Server can readyes (await field.get())no: there is no get()/open()
Protects againstdatabase dump, backupdatabase dump, backup, operator reading stored data
Server-side logiccan use the value (send an email, call an API)cannot use the value
Lost passwordnothing lostdata lost unless a device or a recovery code exists
Use forprovider tokens, API keys the server must useprivate notes, documents, health data

EncryptedField is unchanged. Choose SealedField only when the server does not need the value.

SealedField protects what the server stores, not a server that is compromised while running: with a password escrow, the server sees the login password at each login and can derive the password KEK (see Threat model and limits).

Key hierarchy​

  • The user owns an ECDH P-256 key pair per kid; the public keys are stored in clear on the user.
  • All the private keys form the keyring, encrypted by a random keyring key. The server stores the encrypted keyring.
  • The keyring key (32 bytes) is wrapped by several KEKs: the password escrow and the recovery code escrow (stored on the server), and one device blob per device (stored on the device only, never sent).
  • A KEK derived from a password or a code uses Argon2id (m=65536 KiB, t=3, p=1, 16-byte salt; PBKDF2-SHA-256 with 600 000 iterations as a fallback), the parameters travel in the blob. A platform KEK (WebAuthn PRF, OS keystore) wraps with AES-KW.
  • The password KEK is derived on the client with its own salt, it has nothing to do with the server login hash: from what it stores (the login hash, the escrow), the server cannot derive the KEK. It does see the login password at each login, though: a compromised or actively malicious server can derive the password KEK then (see the threat model).

Flows​

Enrollment (first device). vault.enroll({ password, device, deviceName, recovery }) generates the key pair and keyring, creates the escrows and calls User.Keys.Setup, keeps the keyring key wrapped by the device KEK, pins the public key and registers the device. The recovery code is returned once: show it to the user.

Seal (server). note.content.seal(value) resolves the recipient public key, generates an ephemeral key, derives a content key with ECDH + HKDF and encrypts with AES-256-GCM. The stored envelope is { v: 1, kid, epk, iv, ct }. The recipient is seal(value, { user }), else model.getSealedRecipient(field) (the owner of an OwnerModel), else the model owner. A recipient without a key fails with SealedKeyMissingError (412); a malformed key or envelope with SealedFormatError (400).

Seal (client). A client can seal the value itself and store the envelope, so the plaintext never reaches the server at all: const envelope = await vault.seal(value, { modelType, uuid, field }) then PUT <model>/{uuid}/<field>.set with { "envelope": envelope } (operation <Model>.<Field>.Set, for example Note.Content.Set). It is authorised like any behavior action by the model canAct("<field>.set") (the owner of an OwnerModel). The server checks the envelope shape (v: 1, a 12-byte iv, a ciphertext of at least 16 bytes, an epk P-256 public JWK) and that kid is the active key of the recipient (resolved as for seal()), otherwise it answers 400; then it saves this field only. The server cannot check that the value opens: a client sealing with a wrong AAD only hurts itself.

Open (client). vault.open(envelope, { modelType, uuid, field }) finds the private key of envelope.kid in the unlocked keyring and decrypts.

Add a device. The new device calls vault.unlockWithEscrow({ password }) (or { recoveryCode }): it fetches the escrow and the encrypted keyring (User.Keys.GetEscrow), unwraps the keyring key locally, verifies the keyring against the served public keys and the pinned fingerprint, wraps the keyring key with its own device KEK and registers itself (User.Keys.RegisterDevice).

Unlock (known device). vault.unlock(device) unwraps the keyring key with the device KEK and checks the served public keys against the pin. When another device rotated the keys meanwhile, the local keyring is stale: it is refreshed with User.Keys.GetKeyring and accepted only if it opens with the keyring key of this device and still holds the pinned private key. When the keyring key itself was replaced (rotate({ newKeyringKey }) or reset), the unlock fails with KeyringPinError: go through an escrow.

Change the password. vault.changePassword(newPassword) re-wraps the 32-byte keyring key with the new password KEK and replaces the escrow (User.Keys.SetEscrow). Existing devices keep working, nothing is re-encrypted. While unlocked, changePassword and setRecoveryCode first check that the served active key is still the unlocked and pinned one: if the keyring changed on another device since the unlock, they fail with KeyringPinError ("unlock again") instead of escrowing a keyring key that may have been replaced. The password escrow must follow the login password: change both together, otherwise the next device has to be added with the old password.

Rotate. vault.rotate() adds a new active key pair and re-encrypts the keyring for it; the old key is marked retiredAt and stays in the keyring, so values sealed to it stay readable. New seals use the active kid. Values are not re-sealed automatically: a client may open and re-seal them. vault.rotate({ newKeyringKey: ... }) also replaces the keyring key and rebuilds the escrows, the answer to a lost device.

Reset (keys lost). When no device is left and both the password escrow and the recovery code are lost, vault.reset({ password, recovery, device, deviceName }) starts over: a new key pair, a new keyring key and a keyring holding only the new private key, sent with User.Keys.Rotate (fresh authentication required, no unlock needed), new escrows, a new device blob and pin, and the device registration. Values sealed to the older keys stay unreadable; new values open. The other devices are cut off: their unlock fails with KeyringPinError and their pin refuses the new key, so they are added again with the new escrow after the user explicitly calls vault.forgetPin() on them (compare the new fingerprint out of band when you can).

Associated data (AAD) for clients​

A client that does not use @webda/sealed-client must authenticate the envelope with these exact AADs, otherwise decryption fails:

  • Field envelope: modelType + "|" + uuid + "|" + fieldName + "|" + kid, where
    • modelType is the model identifier (for example "WebdaDemo/Note"),
    • uuid is the primary key (a composite key is joined with the model separator, _ by default),
    • fieldName is the attribute name ("content"),
    • kid is the kid of the envelope.
  • Keyring: "webda-keyring|" + userId + "|" + kid + "|" + fingerprint(publicJwk), fingerprint being the base64url (no padding) SHA-256 of the raw uncompressed P-256 point.
  • Escrow: "webda-escrow|" + userId + "|" + kind (password or recovery).

All encodings are base64url without padding and every envelope or blob carries v: 1. The binding to the record, attribute and recipient key means a copy moved to another record, another field or relabelled with another kid is refused.

The binding also applies to legitimate changes: renaming the model or its namespace (the model identifier changes), renaming the attribute, or migrating records to new primary keys makes the existing values unreadable. A migration has to be done by the recipient devices: open with the old context, seal with the new one and store it (<field>.set).

Recovery code or password escrow​

  • The password escrow lets the user add a device with the password they already know. Its strength is the strength of the password: the escrow is an offline brute-force target (see below).
  • The recovery code is 26 Crockford base32 characters (130 random bits), displayed as five groups of 5 and a final group of 1 (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X; dashes, spaces and case are ignored when typed), shown once at enrollment (skippable) and re-created while unlocked with vault.setRecoveryCode(). It is as strong as a key: a good fit to print and keep, and the only way back when the password is lost and no device is left.
  • Either escrow adds a device. Without any escrow nor any device, the data is unrecoverable: that is the price of end-to-end encryption, make it explicit in your UI. The server refuses to remove the last escrow while no device is registered (409); vault.removeRecoveryCode() removes the recovery escrow and surfaces that refusal.

Fresh authentication​

Every key management operation (User.Keys.GetEscrow, Setup, SetEscrow, RemoveEscrow, GetKeyring, Rotate, RegisterDevice, RevokeDevice) requires a recent authentication: the session authAt younger than reauthMaxAge seconds (default 300), and session.mfa === "verified" when the user has MFA enabled. Otherwise the call fails with 401. A stolen idle session therefore cannot register an attacker key, nor fetch the escrow. Only the user themselves can call them (User.canAct).

GetEscrow is also throttled (5 fetches per 15 minutes by default, the counter decays and is not reset by a successful call: the unwrap happens on the client, so every fetch counts as a guess) and audited (AuditService, operation User.Keys.GetEscrow, with the success flag). GetKeyring serves the encrypted keyring and the public keys alone, without escrow: it is not throttled (it opens nothing without the keyring key) and lets an unlocked device refresh its local copy after a rotation done by another device.

Threat model and limits​

  • Protected: database dumps, backups, a curious operator reading stored data, a server that is read but not modified.
  • Not protected with a password escrow: a compromised or actively malicious server. The login password reaches the server at each login, so a server under attacker control can derive the password KEK, fetch the password escrow and open the keyring. Only the stored data is protected (the escrow alone is an offline brute-force target, see below). Setups without a password escrow avoid it: a recovery code only, or device KEKs only (enroll without password), at the cost of less convenient device additions.
  • Not protected: a malicious server shipping a modified web client. The usual web E2EE limit: the code that handles the keys comes from the server. Native apps and pinned or audited clients reduce the risk.
  • Public key substitution (TOFU). A compromised server could serve its own public key to the sealing code or to a new device. The client therefore pins the fingerprint of the active public key on first use (trust on first use) and refuses a different one unless the change comes from a rotation done or verified by this device (KeyringPinError); an explicit vault.reset() re-pins the device that runs it, and vault.forgetPin() lets the user trust a new key on first use again. Show the fingerprint to the user and compare it out of band for stronger guarantees. Signing a new key with the previous one is a follow-up.
  • Escrow brute force. A database dump gives the attacker the password escrow to attack offline: it is protected by Argon2id and by the quality of the password (apply a password policy). The recovery code has 130 bits and cannot be brute forced. The online path is throttled and needs fresh authentication.
  • No plaintext on the server. seal() never keeps the value on the instance; the server never receives the private key, the keyring key, the password KEK or the recovery code (it does receive the login password, see above). Do not log request bodies of key operations or the values you seal.
  • No query nor index. The stored value is an opaque envelope: it cannot be filtered or indexed with @webda/ql. The content mapper marks the property "x-webda-sealed": true in the model schema so tools and forms can tell.
  • Revoked devices. User.Keys.RevokeDevice removes the device from the server list and vault.unlock() on a revoked device deletes its local blob. It does not cancel what the device already knows: a device that kept its blob can still open the keyring with an escrow. To really lock a lost device out, call vault.rotate({ newKeyringKey }), which changes the keyring key and rebuilds the escrows; values sealed to older keys stay readable by the devices you keep, and are still exposed to whoever copied the old private key.
  • Password reset without a device. If the user forgot the password and has no unlocked device nor recovery code, the old values stay unreadable; vault.reset() creates a new key pair that makes new values readable only.
  • Offline and server-side processing. The server cannot search, validate, index or transform a sealed value. Offline caches must store the envelope and decrypt on read.