Released docs. You are viewing the documentation published with v0.46.0. Development docs are available at Latest.
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.
Declare the field
Section titled “Declare the field”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.
Give the runtime its key
Section titled “Give the runtime its key”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.
What a reader sees
Section titled “What a reader sees”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.
Encrypt a field that already holds data
Section titled “Encrypt a field that already holds data”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-rebaselineerases retained history so no plaintext copy remains in the database. Erasure removes whole record revisions, not only the field’s past values.retain-plaintext-historyencrypts 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.
- Control access and reads to decide who reads the field.
- Declare Actions and Proposals for writes that go through review.
- Threat model for what encryption defends against.
- Known limitations for the full list of BReg limits.
Cookies on Registry Stack
We use optional cookies to understand how you use the main Registry Stack website and this documentation. If you contact us, we may link this activity to your enquiry. You can change your choice at any time. Read the privacy policy.
Your browser asks sites not to track you, so optional tracking stays off.
We could not use or save that choice. Optional tracking remains off.