Class: SubjectKeys
Defined in: packages/persistence/src/personal/subject-keys.ts:246
Per-subject data keys: each subject that holds personal data has its own
AES-256-GCM key, stored wrapped in subject_key (D1.9). This class gets or creates
one, unwraps it through KeyWrap, and keeps it in a small in-memory cache
(TTL 60 s by default, bounded, keyed by subject).
No port call happens inside a transaction (28, section 3). get reads the row in one short transaction, closes it, unwraps, and for a missing key wraps before the insert transaction opens. A caller that needs the key inside its own transaction resolves it first, or uses prepare and insert.
Erasure is eventually consistent across processes: destroySubjectKey evicts
this process's cache once the destruction commits; another replica serves a cached
key for up to the TTL. There
is one credential replica through slice 6, and an event covers a second.
The table is subject_key, in the module's schema (the role's search_path
supplies it), joined to subject for the scope check, so a scope reaches only
subjects in its subtree. A cached key carries its subject's path and is served only to
a minted scope that reaches it, so the cache never widens what a scope can read.
Constructors
Constructor
new SubjectKeys(
options):SubjectKeys
Defined in: packages/persistence/src/personal/subject-keys.ts:256
Parameters
options
Returns
SubjectKeys
Accessors
activeKekId
Get Signature
get activeKekId():
string
Defined in: packages/persistence/src/personal/subject-keys.ts:272
Returns
string
The id of the key encryption key new data keys are wrapped under, and the one rewrap moves keys onto. It names a key and holds no secret.
cached
Get Signature
get cached():
number
Defined in: packages/persistence/src/personal/subject-keys.ts:264
Returns
number
How many keys are cached now, expired ones included until they are touched.
Methods
countInSoftwareCustody()
countInSoftwareCustody<
DB>(db,scope):Promise<number>
Defined in: packages/persistence/src/personal/subject-keys.ts:545
Counts the live keys that are in software custody (soft: ids), across every
tenant. For one use: a process whose KeyWrap is on a token cannot unwrap those,
so it says at start how many people's data it cannot read until the re-wrap has
run. One query, no port call, and it returns a number: no key id leaves it.
Type Parameters
DB
DB
Parameters
db
Database<DB>
The module's pool.
scope
A platform scope minted for SUBJECT_KEY_REWRAP_GRANT.
Returns
Promise<number>
How many live keys carry a soft: id.
Throws
forged_scope when the scope was not minted, or was minted for another grant.
destroySubjectKey()
destroySubjectKey<
DB>(tx,subjectId,audit):Promise<DestroyOutcome>
Defined in: packages/persistence/src/personal/subject-keys.ts:395
Erases a subject's personal data by destroying its key: sets wrapped_dek to NULL
and destroyed_at, and runs audit in the same transaction, so the destruction and
its record commit together or not at all. Calls no port: the caller opened tx with
withScope, and nothing here wraps or unwraps.
The cache is touched only for a key that was destroyed, and only once tx has
committed (TenantTransaction.afterCommit): the cached key is evicted and
its handle revoked, and the subject is held out of the cache for one TTL, so a read
that saw the live row before the commit cannot put it back. absent,
already_destroyed and a destruction that rolls back leave the cache and every
handle as they were: a key that still opens is never reported as erased. Until the
commit, a concurrent read is served the key, which is still live.
Type Parameters
DB
DB
Parameters
tx
A transaction from withScope; its scope must reach the subject.
subjectId
string
The subject.
audit
(tx) => Promise<void>
Writes the subject-key.destroy record to the module's outbox in tx (AuditClient.record). It runs only when a key was destroyed, and must not call a port.
Returns
Promise<DestroyOutcome>
destroyed; already_destroyed (idempotent, nothing recorded); or absent (no key, or not in scope: the same answer).
evict()
evict(
subjectId):void
Defined in: packages/persistence/src/personal/subject-keys.ts:564
Drops a subject's cached key and revokes the handle, so one a caller still holds
answers erased rather than opening anything.
Parameters
subjectId
string
The subject.
Returns
void
get()
get<
DB>(db,scope,subjectId,options?):Promise<SubjectKey|ErasedSubjectKey|undefined>
Defined in: packages/persistence/src/personal/subject-keys.ts:287
Resolves a subject's key.
Type Parameters
DB
DB
Parameters
db
Database<DB>
The module's pool.
scope
A scope minted by messaging; the subject must be in its subtree.
subjectId
string
The subject.
options?
create: true makes the key when the subject has none.
create?
boolean
Returns
Promise<SubjectKey | ErasedSubjectKey | undefined>
The key; { erased: true } when it was destroyed; undefined when there is none and create is not set.
Throws
outside_scope when the subject is not in the scope's subtree (on create); forged_scope.
Throws
key_unavailable when custody could not unwrap the stored key.
insert()
insert<
DB>(tx,subjectId,prepared):Promise<SubjectKey>
Defined in: packages/persistence/src/personal/subject-keys.ts:359
Stores a prepared key in the caller's transaction: SQL only, no port call, so it commits with the subject it belongs to. Nothing is cached; a rollback leaves no trace, and the first get after commit caches it. The returned handle is for sealing the subject's first fields in the same transaction.
Type Parameters
DB
DB
Parameters
tx
A transaction from withScope.
subjectId
string
The subject, which must exist and lie within the scope.
prepared
From prepare; usable once.
Returns
Promise<SubjectKey>
The key, for sealing in this transaction.
Throws
key_exists when the subject already has a key row.
Throws
outside_scope when the subject is absent or not in the subtree.
keyState()
keyState<
DB>(tx,subjectId):Promise<"destroyed"|"absent"|"live">
Defined in: packages/persistence/src/personal/subject-keys.ts:430
Whether a subject's key has been destroyed, read in the caller's transaction (no
cache): the check a caller makes before acting on an erased answer it got from a
handle, which is final only once the destruction committed.
Type Parameters
DB
DB
Parameters
tx
A transaction from withScope; its scope must reach the subject.
subjectId
string
The subject.
Returns
Promise<"destroyed" | "absent" | "live">
destroyed; live; or absent (no key, or not in scope: the same answer).
prepare()
prepare():
Promise<PreparedSubjectKey>
Defined in: packages/persistence/src/personal/subject-keys.ts:336
Generates a data key and wraps it, storing nothing. Call it before opening the transaction that will insert it.
Returns
Promise<PreparedSubjectKey>
A key ready to store.
Throws
Custody refused the wrap.
rewrap()
rewrap<
DB>(db,scope,options?):Promise<RewrapBatch>
Defined in: packages/persistence/src/personal/subject-keys.ts:481
Moves one batch of live keys that are not under the active key encryption key onto it: the re-wrap from software custody to a token (D3.14), and later rotation. The data keys do not change, only what protects them at rest, so every sealed field stays readable and no cache anywhere goes stale.
It runs across every tenant, which is why it takes a platform scope, and only one
minted for SUBJECT_KEY_REWRAP_GRANT. A caller loops, passing each batch's
cursor as the next after, until done or stopped.
What a batch does. It reads up to limit keys in one short transaction and
closes it. For each key, with no transaction open (28, section 3), it unwraps under
the key the row names, wraps under the active one, unwraps the result again and
compares it with what it started from, then zeroes the data key. A plaintext data
key exists for those three calls and no longer. The stored value is the only copy of
a person's key, so the comparison is what stands between a faulty module and data
nobody can ever read again; it costs one call to the token for each key. Last, one
transaction stores the whole batch, each row only if it still holds the value that
was read and has not been destroyed, and runs options.record so the batch's audit
records commit with it. A run killed at any point therefore leaves no moved key
without its record. Damaged keys are recorded the same way, even when the batch
moved nothing.
A batch finishes or writes nothing. A stop of any kind, or a kill, leaves every row of the batch as it was, and the next call takes the same rows. A second run over finished work reads nothing and reports nothing, which is what makes the command idempotent. A destroyed key is never read and could not be written: migration 0103's trigger refuses it whatever this code does.
Concurrent runs are safe without a lock. The loser's updates match no row and are
counted as changed; no key is wrapped under a value that was not read.
The read finds its rows by a scan in key order, which is fine for the estates slice 3
runs. Before rotation at national scale it wants an index on live keys by kek_id.
Type Parameters
DB
DB
Parameters
db
Database<DB>
The module's pool.
scope
A platform scope minted for SUBJECT_KEY_REWRAP_GRANT.
options?
RewrapOptions<DB> = {}
The reader, the cursor, the batch size and a signal.
Returns
Promise<RewrapBatch>
What the batch did, as counts and subject ids.
Throws
forged_scope when the scope was not minted, or was minted
for another grant; invalid_input for a limit outside 1 to REWRAP_BATCH_MAX.