Skip to content
Registry StackDocsDevelopment (unreleased)

Encrypt a field

View as Markdown

If your registry holds a national identifier, a licence number, or another value your rules call restricted, encrypt that field. The database then stores only ciphertext, and Base Registry Engine (BReg) decrypts the value only when it answers a caller allowed to read it.

Set encrypted: true on the field in registry.yaml. The compiler accepts it only when the field’s classification is restricted and its type is string, text, date, decimal, or structured. A sealed value has a size ceiling: an encrypted string or text field may declare a maximumLength of at most 16,384 characters, and a structured field a maximumBytes of at most 64 KiB. A Record type may hold up to 128 encrypted fields. The facility template encrypts no field, so bregctl check on your own project is where these rules first show up.

The database cannot compare ciphertext, so bregctl check refuses an encrypted field anywhere the database would need the value: as a filterable, sortable, or row-boundary field in an access profile, in a storage constraint or authored index, in a native pattern, in an event projection or event condition, in derived SQL or a statistical dataset, in an Action’s commit guard, or in a Proposal’s review context.

To find a record by an encrypted value, add a lookup block to the field. BReg stores a blind index beside the ciphertext: an HMAC-SHA-256 of the value after the normalization steps you list (trim, collapse-whitespace, remove-separators, uppercase, lowercase, in your order, at most eight). Set unique: true to make the index unique; a second record with the same normalized value is refused as a conflict. Expose the lookup through a selector profile and the access profile’s lookups grant. A structured field cannot declare a lookup.

The index answers equality and nothing else, and it shows a database reader which rows hold a value and which rows share one, without the value itself. Keep the normalization narrow: for an identifier, trim and remove-separators usually say what “the same value” means; uppercase and lowercase follow Unicode case rules that can make unexpected values collide.

The BReg configuration reference lists every member and bound.

The deployment project’s runtime.yaml names the key under fieldEncryption.provider. With type: transit, BReg generates one data-encryption key per registry database and has HashiCorp Vault or OpenBao Transit wrap it, reached through a local Unix socket (unixSocketPath, mount, keyName); the database stores only the wrapped key. With type: local-file, dekRef names a secret:file/<name> holding a base64 key, and startup refuses it unless the same file sets identity.databaseInitializationEnvironment to local. Deploy a registry shows the block.

Each value is sealed with AES-256-GCM under a key derived from that data key, bound to its registry, Record type, field, and record, so ciphertext copied to another row does not open. One key version is active, and BReg cannot rotate it. If you lose the wrapping key, you lose the data: escrow it before the first encrypted field goes live, as the hardening checklist describes.

Startup refuses to serve when an active package declares an encrypted field and the key cannot be unwrapped; there is no plaintext fallback. The threat model covers the cryptography and its FIPS posture.

Encryption does not change who may read a field; the access profile does. A caller whose profile lists the field in readableFields receives the plaintext value in record, list, history, and revision reads. A caller whose profile does not list it gets a response without the field, as with any unreadable field, and a request that selects it explicitly is refused. No API response carries ciphertext. Someone reading the database directly sees ciphertext and the blind index.

If a stored value cannot be opened, the read answers HTTP 503 with runtime.field_encryption.unavailable; Record types without encrypted fields keep serving.

Proposals cannot change an encrypted field

Section titled “Proposals cannot change an encrypted field”

A Proposal stores its before and after values for review, and those must not hold plaintext. A Proposal may target a record that has encrypted fields, but its Plan may not set or clear one. bregctl check does not catch this. The runtime refuses when it evaluates the Plan at Proposal submission, with HTTP 400 request.invalid, before it locks or stores any target. If a restricted value must go through review, keep that field unencrypted. Callers granted the field in writableFields can still create and patch it directly.

Adding encrypted: true to an existing field is a reviewed migration. Its descriptor declares a step with type: field-encryption-backfill, which seals existing values chunk by chunk without authored SQL, and one history choice:

  • erase-and-rebaseline erases retained history so no plaintext copy remains in the database. Erasure removes whole record revisions, not only the field’s past values.
  • retain-plaintext-history encrypts from now on and leaves earlier revisions readable in plaintext to anyone with database access. Use it only as a recorded risk acceptance.

A descriptor without a choice is refused, and so is one that declares history for any other change. Every field of one Record type encrypted in the same package shares one backfill step. Backups taken before the migration still hold plaintext until you retire or re-encrypt them.

bregctl field-encryption preflight reports, without values, how many rows, revisions, and retained copies each covered field affects, and which records would collide on a unique lookup. Change an active registry walks through preflight, apply, and erase-history.

Three package changes are refused: removing encryption, changing the lookup of an encrypted field, and adding a required encrypted field to an existing Record type. For the last, add the field as optional, fill it through authorized writes, then make it required in a later package.