From 754ca95f46ac03191947b8e9be26f2a14f92e55b Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 26 Jun 2026 19:42:20 -0400 Subject: [PATCH 1/2] Add `resolutionMeta` feature. --- CHANGELOG.md | 17 ++++++++++++++ lib/documents.js | 26 +++++++++++++++++---- lib/entities.js | 11 ++++++++- lib/tokens/resolve.js | 53 +++++++++++++++++++++++++++---------------- 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf85f5e..09b16d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/documents.js b/lib/documents.js index 4440afe..9fff637 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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} 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( @@ -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 @@ -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; @@ -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`. diff --git a/lib/entities.js b/lib/entities.js index ebe44b8..f6e250e 100644 --- a/lib/entities.js +++ b/lib/entities.js @@ -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} 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'); @@ -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 @@ -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 diff --git a/lib/tokens/resolve.js b/lib/tokens/resolve.js index 39d35d5..216e0c3 100644 --- a/lib/tokens/resolve.js +++ b/lib/tokens/resolve.js @@ -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 @@ -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 @@ -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.', @@ -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; } } From e24bdf4a4ccd6f69ac80dca88470dc58b020cf41 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 26 Jun 2026 19:42:28 -0400 Subject: [PATCH 2/2] Add `resolutionMeta` tests. --- test/mocha/20-tokens.js | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/mocha/20-tokens.js b/test/mocha/20-tokens.js index f06bfe5..46177e0 100644 --- a/test/mocha/20-tokens.js +++ b/test/mocha/20-tokens.js @@ -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; @@ -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';