Skip to content
Registry StackDocsv0.46.0

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. An encrypted 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 (nfc, case-fold, 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.

For a name or any other text a person types, list nfc first. Without it, the composed and the decomposed spelling of the same name (é as one character or as e plus a combining accent) are different bytes, so they get different indexes: a lookup misses the record, and a unique index accepts both spellings as two records. nfc rewrites the value to Unicode normalization form C before the next step. case-fold applies NFC first, because the fold turns some combining marks into letters (the Greek iota subscript folds to ι): two spellings that order their marks differently would otherwise fold to different values. It then applies the full Unicode case fold, so ß, ẞ, and ss are one value, which neither lowercase nor uppercase achieves. It ends with NFC again, because folding some capitals leaves a decomposed sequence: Ϊ́ (capital iota with dialytika, then an acute accent) folds to ι plus two combining marks, and without the closing NFC it would not match ΐ. A typical name lookup is normalization: [nfc, case-fold, trim, collapse-whitespace]. Adding either step to the list of a lookup that already holds data changes every stored digest; the section on changing the normalization of an existing lookup below explains the reviewed migration that requires.

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 encrypted 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 encrypts 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. It runs every step in one transaction on the serving registry, and lifts FORCE ROW LEVEL SECURITY on each scanned Record type’s table with an ALTER TABLE, which holds an ACCESS EXCLUSIVE lock on that table until the preflight ends: that Record type’s reads and writes wait for the whole scan, so run it in a quiet window. Change an active registry walks through preflight, apply, and erase-history.

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

Change the normalization of an existing lookup

Section titled “Change the normalization of an existing lookup”

Editing the normalization list of an already encrypted field changes the digest of every stored value, so the old digests would stop matching new queries and a unique index would not protect the new spelling rules. BReg classifies that edit as a reviewed backfill, never as an automatic change, and refuses a package that changes the list without one. The descriptor covers the field’s FieldLookupChanged change and declares one step with type: field-encryption-backfill whose only object is the field’s blind-index column (the member <field>#lookup). It carries no history choice, because it rewrites no record, envelope, or revision. Only the engine derives digests: a reviewed plan whose authored SQL writes a blind-index column, beside a re-index or an encryption flip, is refused with migration.review.blind-index-write-refused.

At apply, the step opens each envelope under the active key, derives the digest under the new list, and writes only the blind-index column, chunk by chunk, with the same resumable cursor as the encryption backfill. Before the apply commits anything, ahead of the compiler DDL and of every step in the plan, it checks every re-index and every encryption flip. It refuses the whole package, naming up to 64 record ids and no values, when two records would share a digest under the new list on a unique lookup, which is the case when two spellings of one name already coexist; correct one of each pair first.

A record’s new digest may equal the old digest a record later in the walk still holds, because the two lists map different values onto one digest. That end state is valid, so the re-index does not refuse it: on a unique lookup each chunk first clears any such stale digest on the rows it has not reached, then writes the new digests. A cleared row gets its own new digest when the walk reaches it. If the apply stops partway, a cleared record is not found by lookup until the resumed step reaches it.

The registry stays in maintenance for the whole re-index and serves no Record reads or writes until the step completes, including while an interrupted step waits to resume. Once the first chunk commits, the target can no longer be reverted: bregctl migration reconcile reports it unresolvable, because the reviewed migration committed steps that only the same target can finish. Fix the cause and retry the same apply, which resumes the step after its last committed chunk.

The same check counts the rows each re-index step walks, valued or null, and refuses a step whose table holds more rows than its maxTotalRows, naming the step and both counts, because the step would otherwise stop partway with old and new digests mixed. Raise maxTotalRows in a reviewed package rehearsed against that count.

A refused apply leaves the active registry as it was: bregctl migration reconcile reports the target revertible, reverting it returns the registry to ready, and it serves reads again.

Changing only the list is supported. Adding or removing the lookup, or changing its unique flag, stays refused. bregctl field-encryption preflight scans a re-index step too: it opens the envelopes with the configured field-encryption key, reports how many rows the step walks beside its maxTotalRows, and names the records that would collide, so you can correct them before the apply. Its re-index scan holds the same ACCESS EXCLUSIVE lock on the Record type’s table until the preflight ends, so run it in a quiet window.