Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# bedrock-tokenization ChangeLog

## 23.5.0 - 2026-06-dd

### Added
- Add `resolutionMeta` feature. When registering a document, a `resolutionMeta`
object may be provided for storage along with the related entity. This
object will be be included when resolving a token to a pairwise token. The
information stored in the object MUST NOT be conditionally different based
on the document that is being registered. This is because it will be
stored in the associated entity record, and a subset of document registration
records (but not all of them) may expire over time while the entity persists
or a new additional document registration may refresh the entity record but
fail to be stored itself due to an arbitrary failure. If auditing via stored
encrypted documents is a requirement, no single individual document on its
own can be considered a viable basis for the origin of the information, and
either any document will suffice to generate the `resolutionMeta` or the
origin of this information must be from some other independent source.

## 23.4.0 - 2026-06-26

### Added
Expand Down
26 changes: 21 additions & 5 deletions lib/documents.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,18 +293,30 @@ export async function getRegistration({internalId, explain = false} = {}) {
* @param {boolean} [options.newRegistration] - Optionally specify whether or
* not a registration is expected to be new; if this is set to `false`,
* and the registration does not exist, then an error will be thrown.
* @param {object} [options.resolutionMeta] - Optionally specify an object
* of information to be included whenever a token is resolved to a pairwise
* token to refer to the entity associated with this document; this
* information must be sourced from *any* acceptable document that would
* match the same entity (not just the particular document being registered
* in this call) or be sourced from some other totally independent source --
* if an audit of the information via the later decryption of stored
* documents is of interest (this is because any additional document
* registration record may update an entity's `resolutionMeta` but fail to be
* stored due to an arbitrary error, or any older document might expire while
* the entity persists).
*
* @returns {Promise<object>} An object with the registration record.
*/
export async function register({
internalId, externalId, document, recipients, recipientChain, store,
minAssuranceForResolution, ttl, creator, tokenizer, externalIdHash,
documentHash, creatorHash, newRegistration
documentHash, creatorHash, newRegistration, resolutionMeta
} = {}) {
assertTtl({ttl});
assert.optionalArrayOfObject(recipients, 'recipients');
assert.optionalArrayOfArray(recipientChain, 'recipientChain');
assert.optionalBool(store, 'store');
assert.optionalObject(resolutionMeta, 'resolutionMeta');
if(store === false) {
if(recipientChain || recipients) {
throw new TypeError(
Expand Down Expand Up @@ -342,8 +354,10 @@ export async function register({
// the record matches the one passed in as the docs state
let upsertEntityPromise;
if(internalId) {
upsertEntityPromise = entities._upsert(
{internalId, ttl, externalIdHash, minAssuranceForResolution});
upsertEntityPromise = entities._upsert({
internalId, ttl, externalIdHash, minAssuranceForResolution,
resolutionMeta
});
}

// concurrently attempt to refresh an existing registration and upsert
Expand All @@ -364,7 +378,8 @@ export async function register({
approach to registration, whereas this would be the slowest. */
await entities._upsert({
internalId: record.registration.internalId,
ttl, externalIdHash, minAssuranceForResolution
ttl, externalIdHash, minAssuranceForResolution,
resolutionMeta
});
}
return record;
Expand Down Expand Up @@ -431,7 +446,8 @@ export async function register({
// avoid an unhandled promise rejection later. Check the resolved value
// to see if it is an error when awaiting.
const upsertEntityPromise = entities._upsert({
internalId, ttl, externalIdHash, minAssuranceForResolution
internalId, ttl, externalIdHash, minAssuranceForResolution,
resolutionMeta
}).catch(e => e);

// 6. Encrypt the document for storage, unless `store: false`.
Expand Down
11 changes: 10 additions & 1 deletion lib/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,16 @@ export async function _setOpenTokenBatchId({
* @param {number} [options.minAssuranceForResolution] - Minimum level of
* identity assurance required for token resolution. This will default to
* `2` for new entities.
* @param {object} [options.resolutionMeta] - Information to store and return
* whenever a token is resolved to a pairwise token referencing this entity;
* see `documents.register()` for more details.
* @param {boolean} [options.explain] - An optional explain boolean.
*
* @returns {Promise<object | ExplainObject>} Resolves with an object
* representing the entity record or an ExplainObject if `explain=true`.
*/
export async function _upsert({
internalId, ttl, externalIdHash, minAssuranceForResolution,
internalId, ttl, externalIdHash, minAssuranceForResolution, resolutionMeta,
explain = false
} = {}) {
assert.buffer(internalId, 'internalId');
Expand All @@ -445,6 +448,9 @@ export async function _upsert({
minAssuranceForResolution === undefined ? 2 : minAssuranceForResolution,
expires
};
if(resolutionMeta) {
entity.resolutionMeta = resolutionMeta;
}

const query = {'entity.internalId': entity.internalId};
// only update `expires` on update and only if it extends the record TTL
Expand All @@ -458,6 +464,9 @@ export async function _upsert({
'entity.minAssuranceForResolution': entity.minAssuranceForResolution,
'meta.created': meta.created
};
if(resolutionMeta) {
$setOnInsert['entity.resolutionMeta'] = entity.resolutionMeta;
}
const update = {$max, $set, $setOnInsert};
// include `externalIdHash` to enable finding registered document records
// note: if tokenizer rotation is used in the future, this value must be
Expand Down
53 changes: 34 additions & 19 deletions lib/tokens/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,15 @@ export async function resolve({
// determine token pinned/unpinned status
const isUnpinned = tokenBatch.minAssuranceForResolution === -1;

/* Note: If the token batch is unpinned, start fetching the entity record
to get the inherited `minAssuranceForResolution` to use. */
let entityRecordPromise;
if(isUnpinned) {
// resolve to error if this call fails to ensure that we do not have
// an unhandled promise rejection should another failure occur before
// we await this promise; then check the resolved value for an error
// and throw it when we do await later
entityRecordPromise = entities.get({internalId}).catch(e => e);
}
/* Note: Always start fetching the entity record. It will be needed to
get any `resolutionMeta` and, if the token batch is unpinned, to get the
the inherited `minAssuranceForResolution` to use.

Make sure to resolve to an error if this call fails to ensure that we do
not have an unhandled promise rejection should another failure occur before
we await this promise. Then check the resolved value for an error and throw
it when we do await later. */
const entityRecordPromise = entities.get({internalId}).catch(e => e);

/* Note: Always mark the token as resolved against the given party, even
if we will ultimately report that the assurance level was too low to
Expand Down Expand Up @@ -145,12 +144,15 @@ export async function resolve({
}
}

// await any parallel potential entity record lookup to check for
// unpinned token batch invalidation prior to resolution (promise will
// be undefined if token is pinned)
const entityRecord = await entityRecordPromise;
if(entityRecord instanceof Error) {
throw entityRecord;
// if the token batch is unpinned, we need to await any parallel potential
// entity record lookup first to check for unpinned token batch
// invalidation prior to resolution
let entityRecord;
if(isUnpinned) {
entityRecord = await entityRecordPromise;
if(entityRecord instanceof Error) {
throw entityRecord;
}
}

// unless resolving invalid tokens is permitted, ensure that an unpinned
Expand Down Expand Up @@ -204,8 +206,9 @@ export async function resolve({
// lowering entity's `minAssuranceForResolution`
if(isUnpinned) {
const {entity} = entityRecord;
await entities._setLastAssuranceFailedTokenResolution(
{entity, tokenBatch, date: new Date()});
await entities._setLastAssuranceFailedTokenResolution({
entity, tokenBatch, date: new Date()
});
}
throw new BedrockError(
'Could not resolve token; minimum level of assurance not met.',
Expand All @@ -217,11 +220,23 @@ export async function resolve({
});
}

// resolve the entity record to get any `resolutionMeta`
if(entityRecord === undefined) {
entityRecord = await entityRecordPromise;
if(entityRecord instanceof Error) {
throw entityRecord;
}
}

// finally, return pairwise token, internal ID, and other token info
return {
const result = {
pairwiseToken, internalId, isUnpinned, minAssuranceForResolution,
validUntil: pairwiseTokenExpires
};
if(entityRecord.entity.resolutionMeta) {
result.resolutionMeta = entityRecord.entity.resolutionMeta;
}
return result;
}
}

Expand Down
78 changes: 78 additions & 0 deletions test/mocha/20-tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,37 @@ describe('Tokens', function() {
result.validUntil.should.be.lt(
new Date(Date.now() + DEFAULT_BATCH_TTL + 60000));
});
it('should resolve token with `resolutionMeta`',
async function() {
const tokenCount = 1;
const internalId = await documents._generateInternalId();
const attributes = new Uint8Array([1]);
const requester = 'requester';
let err;
let result;

// upsert mock entity the token is for
await entities._upsert({
internalId, ttl: 60000,
resolutionMeta: {a: 1, b: 2, c: 'three'}
});

const tks = await tokens.create(
{internalId, attributes, tokenCount});
const token = tks.tokens[0];
try {
result = await tokens.resolve({requester, token});
} catch(e) {
err = e;
}
assertNoError(err);
areTokens(tks);
should.exist(result.pairwiseToken);
result.validUntil.should.be.lt(
new Date(Date.now() + DEFAULT_BATCH_TTL + 60000));
should.exist(result.resolutionMeta);
result.resolutionMeta.should.eql({a: 1, b: 2, c: 'three'});
});
it('should resolve token when called twice with same "requester"',
async function() {
const tokenCount = 1;
Expand Down Expand Up @@ -1076,6 +1107,53 @@ describe('Tokens', function() {
should.exist(result.pairwiseToken);
result.validUntil.should.eql(MAX_EXPIRATION_DATE);
});
it('should register + create and resolve with `resolutionMeta`', async () => {
const dateOfBirth = '1980-06-01';
const expires = '2031-05-01';
const identifier = 'T00008765';
const issuer = 'VA';
const type = 'DriversLicense';
const tokenCount = 1;
// canonicalize object then hash it then base58 encode it
const externalId = encode(crypto.createHash('sha256')
.update(canonicalize({dateOfBirth, identifier, issuer}))
.digest());

let tokenResult;
let err;
try {
tokenResult = await tokens.registerDocumentAndCreate({
registerOptions: {
externalId,
document: {dateOfBirth, expires, identifier, issuer, type},
store: false,
ttl: -1,
resolutionMeta: {foo: 'bar'}
},
tokenCount
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(tokenResult);
tokenResult.should.include.keys(['tokens', 'validUntil']);
tokenResult.validUntil.should.be.a('Date');

const [token] = tokenResult.tokens;
const requester = 'requester';
let result;
try {
result = await tokens.resolve({requester, token});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result.pairwiseToken);
result.validUntil.should.eql(MAX_EXPIRATION_DATE);
should.exist(result.resolutionMeta);
result.resolutionMeta.should.eql({foo: 'bar'});
});
it('should register and upsert a pairwise token', async function() {
const dateOfBirth = '2000-05-01';
const expires = '2021-05-01';
Expand Down