diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8808c..f267056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # bedrock-tokenization ChangeLog +## 23.4.0 - 2026-06-dd + +### Added +- Add a `ttl: -1` option to signal that document registration record TTL (and + related entity and pairwise token TTL) should be set to a maximum allowable + value (long-lived persistence is presently implemented via a year 9000 + expiration date but that could perhaps become indefinite persistence with a + future update that might require new API surface and that would not result + in deoptimized existing queries). To register a document that will result in + a non-expiring registration, and entity records, set `ttl: -1` in the document + registration options passed to + `documents.register()` or `tokens.registerDocumentAndCreate()`. + ## 23.3.0 - 2026-06-21 ### Changed diff --git a/lib/documents.js b/lib/documents.js index f834175..4440afe 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -4,6 +4,7 @@ import * as bedrock from '@bedrock/core'; import * as database from '@bedrock/mongodb'; import * as entities from './entities.js'; +import {assertTtl, getExpires} from './helpers.js'; import assert from 'assert-plus'; import canonicalize from 'canonicalize'; import {Cipher} from '@digitalbazaar/minimal-cipher'; @@ -75,6 +76,9 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { indexes.push({ // automatically expire registrations using `expires` date field collection: 'tokenization-registration', + // if `registration.expires` does not exist in a record, that record will + // not be affected by this index, but currently all records have this + // field fields: {'registration.expires': 1}, options: { unique: false, @@ -89,7 +93,9 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { /** * Extends the expiration period for any registration records that match the - * given `externalIdHash` that would otherwise expire sooner. + * given `externalIdHash` that would otherwise expire sooner. Note that any + * record without `expires` set will be unaffected, but presently all records + * carry this field. * * @param {object} options - Options to use. * @param {Buffer} options.externalIdHash - Previously hashed (tokenized) @@ -109,7 +115,9 @@ export async function refreshAll({ } = {}) { const query = { 'registration.externalIdHash': externalIdHash, - // only extend expiration period, do not shorten it + // only extend expiration period, do not shorten it; + // if `registration.expires` does not exist, the query will not match, but + // currently all records carry this field 'registration.expires': {$lt: expires} }; if(internalId) { @@ -167,7 +175,7 @@ export async function getRegistration({internalId, explain = false} = {}) { } let record = await collection.findOne(query); - if(record) { + if(record && record.registration.expires !== undefined) { // explicitly check `expires` against current time to handle cases where // the database record just hasn't been expunged yet const now = new Date(); @@ -217,6 +225,14 @@ export async function getRegistration({internalId, explain = false} = {}) { * reason why the `internalId` is generated randomly as opposed to computed as * an HMAC value from the `externalId`. * + * A `ttl` parameter of `-1` will cause the system to attempt to make the + * document registration record (and the related entity and any pairwise token + * records) persist indefinitely. This option is usually best coupled with + * the `store: false` feature to prevent storage of encrypted documents for + * tokenization use cases that do not need to preserve document content + * and/or that do some form of "blind tokenization" (tokenization of documents + * that themselves contain hashes of other content). + * * Note: Using an HMAC value for `internalId` would help enforce preventing * duplicate entities from being concurrently generated for the same * `externalId`, and it would still prevent revealing correlation in the @@ -258,7 +274,10 @@ export async function getRegistration({internalId, explain = false} = {}) { * be stored. * * @param {number} options.ttl - The number of milliseconds until the - * document should expire. + * document registration record should expire; a value of -1 indicates + * that the system should attempt to persist the record indefinitely -- and + * notably, if this option is used, it will also cause any pairwise tokens + * to last indefinitely as well. * @param {number} [options.minAssuranceForResolution] - Minimum level of * assurance required for token resolution. This will default to `2` for * new entities. @@ -282,7 +301,7 @@ export async function register({ minAssuranceForResolution, ttl, creator, tokenizer, externalIdHash, documentHash, creatorHash, newRegistration } = {}) { - assert.number(ttl, 'ttl'); + assertTtl({ttl}); assert.optionalArrayOfObject(recipients, 'recipients'); assert.optionalArrayOfArray(recipientChain, 'recipientChain'); assert.optionalBool(store, 'store'); @@ -562,7 +581,7 @@ export async function _getRegistrationRecord( } let record = await collection.findOne(query, {projection}); - if(record) { + if(record && record.registration.expires !== undefined) { // explicitly check `expires` against current time to handle cases where // the database record just hasn't been expunged yet const now = new Date(); @@ -600,13 +619,15 @@ async function _insertRegistration({ internalId, externalIdHash, documentHash, - tokenizerId, - expires: new Date(now + ttl) + tokenizerId } }; if(jwe) { record.registration.jwe = jwe; } + if(ttl !== undefined) { + record.registration.expires = getExpires({now, ttl}); + } if(creatorHash) { record.registration.creatorHash = [creatorHash]; } @@ -626,6 +647,9 @@ async function _insertRegistration({ return record; } +// note: calling this with `ttl: undefined` will not update `expires` on an +// existing record, but if it is called on an existing record that did not +// previously expire, it will cause that record to expire based on the `ttl` export async function _refresh({ externalIdHash, documentHash, ttl, creatorHash, explain = false } = {}) { @@ -633,13 +657,17 @@ export async function _refresh({ 'registration.externalIdHash': externalIdHash, 'registration.documentHash': documentHash }; + // perform find + update... const now = Date.now(); const update = { - // only extend expiration period, do not shorten it; must use `$max` - // because we want to find the document even if we don't update `expires` - $max: {'registration.expires': new Date(now + ttl)}, $set: {'meta.updated': now} }; + if(ttl !== undefined) { + // only extend expiration period, do not shorten it; must use `$max` + // to accomplish this; note: we cannot include `expires` in the query + // because we want to find the document even if we don't update `expires` + update.$max = {'registration.expires': getExpires({now, ttl})}; + } if(creatorHash) { update.$addToSet = {'registration.creatorHash': creatorHash}; } diff --git a/lib/entities.js b/lib/entities.js index 4433ae0..ebe44b8 100644 --- a/lib/entities.js +++ b/lib/entities.js @@ -1,8 +1,9 @@ /*! - * Copyright (c) 2021-2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2021-2026 Digital Bazaar, Inc. */ import * as bedrock from '@bedrock/core'; import * as database from '@bedrock/mongodb'; +import {assertTtl, getExpires} from './helpers.js'; import assert from 'assert-plus'; const {util: {BedrockError}} = bedrock; @@ -35,6 +36,9 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { indexes.push({ // automatically expire entities using `expires` date field collection: 'tokenization-entity', + // if `entity.expires` does not exist in a record, that record will + // not be affected by this index, but currently all records have this + // field fields: {'entity.expires': 1}, options: { unique: false, @@ -71,7 +75,7 @@ export async function get({internalId, explain = false} = {}) { } let record = await collection.findOne(query, {projection}); - if(record) { + if(record && record.entity.expires !== undefined) { // explicitly check `expires` against current time to handle cases where // the database record just hasn't been expunged yet const now = new Date(); @@ -376,7 +380,7 @@ export async function _setOpenTokenBatchId({ }; const update = {$set}; - // update entity expiration date to new max + // if `expires` given, update entity expiration date to new max if(expires !== undefined) { update.$max = {'entity.expires': expires}; } @@ -404,9 +408,10 @@ export async function _setOpenTokenBatchId({ * @param {object} options - Options to use. * @param {Buffer} options.internalId - The internal ID for the entity. * @param {number} options.ttl - The number of milliseconds until the - * entity should expire; this should be synchronized as much as possible - * with the entity's open token batches ensuring that when tokens expire, - * the entity record also expires. + * entity record should expire; a positive value should be synchronized as + * much as possible with the entity's open token batches ensuring that when + * tokens expire, the entity record also expires; a value of -1 indicates + * that the system should attempt to persist the record indefinitely. * @param {Buffer} [options.externalIdHash] - Optionally previously hashed * (tokenized) externalId, to bind entity to document registrations and * enable fast look ups and refreshes without needing an `internalId` index. @@ -423,14 +428,14 @@ export async function _upsert({ explain = false } = {}) { assert.buffer(internalId, 'internalId'); - assert.number(ttl, 'ttl'); + assertTtl({ttl}); assert.optionalBuffer(externalIdHash, 'externalIdHash'); assert.optionalNumber(minAssuranceForResolution, 'minAssuranceForResolution'); const now = Date.now(); const collection = database.collections['tokenization-entity']; const meta = {created: now, updated: now}; - const expires = new Date(now + ttl); + const expires = getExpires({now, ttl}); const entity = { internalId, batchInvalidationCount: 0, diff --git a/lib/helpers.js b/lib/helpers.js new file mode 100644 index 0000000..8a97f13 --- /dev/null +++ b/lib/helpers.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import assert from 'assert-plus'; + +export const MAX_EXPIRATION_DATE = new Date('9000-01-01T00:00:00Z'); + +export function assertTtl({ttl} = {}) { + assert.number(ttl, 'ttl'); + if(ttl <= 0 && ttl !== -1) { + throw new Error( + '"ttl" must be a positive number or -1 (indicating the maximum TTL).'); + } +} + +export function getExpires({now, ttl} = {}) { + assertTtl({ttl}); + return ttl === -1 ? MAX_EXPIRATION_DATE : new Date(now + ttl); +} diff --git a/lib/tokens/batches.js b/lib/tokens/batches.js index 713ea5a..41f4fc8 100644 --- a/lib/tokens/batches.js +++ b/lib/tokens/batches.js @@ -9,6 +9,7 @@ import * as entities from '../entities.js'; import {create as _createToken} from './format.js'; import assert from 'assert-plus'; import crypto from 'node:crypto'; +import {MAX_EXPIRATION_DATE} from '../helpers.js'; import pLimit from 'p-limit'; import {promisify} from 'node:util'; import {tokenizers} from '@bedrock/tokenizer'; @@ -73,11 +74,13 @@ export async function createTokens({ internalId, attributes = new Uint8Array(), tokenCount, minAssuranceForResolution = 2, tokenizer, batchVersion, - registerPromise, newRegistration + registerPromise, registerOptions, newRegistration } = {}) { assert.buffer(internalId, 'internalId'); assert.number(tokenCount, 'tokenCount'); assert.number(minAssuranceForResolution, 'minAssuranceForResolution'); + assert.optionalObject(registerOptions, 'registerOptions'); + assert.optionalBool(newRegistration, 'newRegistration'); if(!(attributes instanceof Uint8Array)) { throw new TypeError('"attributes" must be a Uint8Array.'); @@ -119,7 +122,7 @@ export async function createTokens({ // 2. Get an open batch for creating tokens. const {tokenBatch, startIndex, claimedTokenCount} = await _getOpenBatch({ internalId, batchVersion, tokenCount: target, minAssuranceForResolution, - registerPromise, newRegistration + registerPromise, registerOptions, newRegistration }); // 3. Create tokens in parallel with concurrency limit. @@ -350,7 +353,7 @@ export async function _claimTokens({ async function _createBatch({ internalId, batchVersion, tokenCount = 0, batchInvalidationCount, - minAssuranceForResolution, externalIdHash + minAssuranceForResolution, externalIdHash, pairwiseTokenExpires }) { // _randomBytesAsync is not declared higher up at the module level to support // stubbing `crypto.randomBytes` in the test suite @@ -363,17 +366,24 @@ async function _createBatch({ // determine expiration date for the batch record; this will also be used // when updating the entity record to ensure it lasts as long as the token // batch record (provided it ends up being a valid token batch; if not, the - // entity record will not be updated using the `expires` value) + // entity record will not be updated using the `expires` value); the + // expiration date for the batch will also be used for any pairwise tokens + // generated from it, unless another pairwise expiration value is given const expires = _getTokenBatchExpires({batchVersion}); + if(pairwiseTokenExpires === undefined) { + pairwiseTokenExpires = expires; + } else if(pairwiseTokenExpires instanceof Date) { + pairwiseTokenExpires = new Date(Math.max(pairwiseTokenExpires, expires)); + } /* Note: We insert the token batch and also update the entity record's open - token batch ID and `expires` value concurrently. We do the entity record + token batch ID and any `expires` value concurrently. We do the entity record update regardless of whether the token batch is entirely consumed in order to - ensure that the entity record's `expires` value is always at least as long as - the valid token batches associated with it. We also ensure that any - associated document registration records are refreshed (`expires` extended) - prior to setting the open batch ID -- ensuring that no tokens will be issued - from an open batch that could expire prior to document registration records. + ensure that the entity record will not expire before the valid token batches + associated with it. We also ensure that any associated document registration + records are refreshed (`expires` extended as needed) prior to setting the + open batch ID -- ensuring that no tokens will be issued from an open batch + that could expire prior to document registration records. It is safe to run these two operations in parallel and the latency reduction is more valuable than the rare degenerate cases. The possible outcomes from @@ -397,7 +407,7 @@ async function _createBatch({ const [record] = await Promise.all([ _insertBatch({ id, internalId, batchVersion, tokenCount, minAssuranceForResolution, - expires, batchInvalidationCount + expires, batchInvalidationCount, pairwiseTokenExpires }), _refreshDocumentRegistrationsThenSetOpenBatchId({ externalIdHash, expires, internalId, batchId: id, batchInvalidationCount, @@ -409,7 +419,7 @@ async function _createBatch({ async function _getOpenBatch({ internalId, batchVersion, tokenCount, minAssuranceForResolution, - registerPromise, newRegistration + registerPromise, registerOptions, newRegistration }) { // loop trying to claim tokens in an unfilled batch... competing against // concurrent processes trying to claim tokens in the same unfilled batch @@ -538,22 +548,34 @@ async function _getOpenBatch({ 'creation.'); } - /* Note: `_createBatch` always updates the entity record's `expires` - value and, if an entity record already exists, any associated document - registration records' `expires` values. This ensures all these records will - persist at least as long as any new valid token batch. Note that in the - case that no entity record exists, registration must be occurring + /* Note: `_createBatch` always attempts to increment the entity record's + `expires` value to ensure it persists at least as long as the created batch, + and, if an entity record already exists, any associated document registration + records' `expires` values are similarly updated for the same purpose. Also, + the `expires` field from an existing entity record is stored along with the + token batch to inform the initial `expires` field of any pairwise tokens + later created during resolution. + + In the case that no entity record exists, registration must be occurring concurrently as checked above, which means at least one document registration - record with a sufficiently long `expires` value will be inserted imminently - so no update to existing records is required. This also allows other - registered documents for the same `externalIdHash` to expire in the cases - where a replacement document is known to be getting registered. */ + record with a sufficiently long `expires` value is being inserted. The TTL + for this registration is also passed along, allowing it to be used to compute + the an `expires` field to be stored with the token batch, again, to be used + with any new pairwise tokens. */ + + // use `MAX_EXPIRATION_DATE` for pairwise tokens if the entity record or + // registration also indicates it; otherwise use `undefined` to let + // `_createBatch()` set the value according to the batch parameters + const pairwiseTokenExpires = ( + entityRecord?.entity.expires.getTime() === MAX_EXPIRATION_DATE.getTime() || + registerOptions?.ttl === -1) ? MAX_EXPIRATION_DATE : undefined; // create the new batch const {tokenBatch} = await _createBatch({ internalId, batchVersion, tokenCount, minAssuranceForResolution, batchInvalidationCount: originalBatchInvalidationCount, - externalIdHash: entityRecord?.entity.externalIdHash + externalIdHash: entityRecord?.entity.externalIdHash, + pairwiseTokenExpires }); const claimedTokenCount = tokenBatch.maxTokenCount - tokenBatch.remainingTokenCount; @@ -626,7 +648,7 @@ async function _getUnfilledBatch({ } const key = '' + minAssuranceForResolution; if(!entityRecord || !entityRecord.entity.openBatch[key]) { - // entity does not exist, either it is being created concurrently, or it + // if entity does not exist, either it is being created concurrently, or it // has expired; if entity does exist but batch ID is null/undefined then // there is no open batch -- in all cases here, there is no open batch return {unfilledRecord: null, entityRecord}; @@ -688,7 +710,7 @@ async function _getUnfilledBatch({ // no usable batch record = null; - // scheduling and update to set open token batch to `null` is only an + // scheduling an update to set open token batch to `null` is only an // optimization; don't run it when `minAssuranceForResolution=1` to avoid // clearing the last created open batch for unpinned tokens -- which is // used to determine whether the entity's `minAssuranceForResolution` needs @@ -712,7 +734,7 @@ async function _getUnfilledBatch({ async function _insertBatch({ id, internalId, batchVersion, tokenCount, batchInvalidationCount, - expires, minAssuranceForResolution = -1 + expires, minAssuranceForResolution = -1, pairwiseTokenExpires }) { // create bitstring to store whether individual tokens have been // revolved or not @@ -736,7 +758,8 @@ async function _insertBatch({ remainingTokenCount, expires, batchInvalidationCount, - minAssuranceForResolution + minAssuranceForResolution, + pairwiseTokenExpires } }; try { @@ -774,6 +797,8 @@ async function _refreshDocumentRegistrationsThenSetOpenBatchId({ }) { // before marking the open token batch ID as available for use, ensure // that any associated document registration record `expires` are updated + // note: any registration record w/o `expires` set will be unaffected, but + // this is presently no records if(externalIdHash) { await documents.refreshAll({externalIdHash, internalId, expires}); } diff --git a/lib/tokens/index.js b/lib/tokens/index.js index 16ab4fa..0483851 100644 --- a/lib/tokens/index.js +++ b/lib/tokens/index.js @@ -1,10 +1,11 @@ /*! - * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import * as batchVersions from '../batchVersions.js'; import * as documents from '../documents.js'; import {createTokens as _createTokens} from './batches.js'; import assert from 'assert-plus'; +import {assertTtl} from '../helpers.js'; import {tokenizers} from '@bedrock/tokenizer'; // expose public functions @@ -122,6 +123,7 @@ export async function registerDocumentAndCreate({ minAssuranceForResolution = 2 } = {}) { assert.object(registerOptions, 'registerOptions'); + assertTtl({ttl: registerOptions.ttl}); assert.number(tokenCount, 'tokenCount'); assert.optionalNumber(minAssuranceForResolution, 'minAssuranceForResolution'); @@ -179,10 +181,10 @@ export async function registerDocumentAndCreate({ // ensure register options TTL sufficiently covers batch version TTL const {options: {ttl: batchVersionTtl}} = batchVersion; - registerOptions = { - ...registerOptions, - ttl: Math.max(batchVersionTtl, registerOptions.ttl) - }; + registerOptions = {...registerOptions}; + if(registerOptions.ttl !== -1) { + registerOptions.ttl = Math.max(batchVersionTtl, registerOptions.ttl); + } while(true) { // try to obtain an existing `internalId` for given registration options @@ -215,7 +217,8 @@ export async function registerDocumentAndCreate({ registerPromise, _createTokens({ internalId, attributes, tokenCount, minAssuranceForResolution, - tokenizer, batchVersion, registerPromise, newRegistration + tokenizer, batchVersion, registerPromise, registerOptions, + newRegistration }) ]); diff --git a/lib/tokens/pairwise.js b/lib/tokens/pairwise.js index 6652758..26fac62 100644 --- a/lib/tokens/pairwise.js +++ b/lib/tokens/pairwise.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import * as bedrock from '@bedrock/core'; import * as database from '@bedrock/mongodb'; @@ -47,8 +47,7 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { collection: 'tokenization-pairwiseToken', fields: {'pairwiseToken.expires': 1}, options: { - // the `expires` field was optional in previous versions, using a - // partial filter express accounts for that here + // the `expires` field is optional partialFilterExpression: {'pairwiseToken.expires': {$exists: true}}, unique: false, // grace period of 24 hours; see `documents.js` for grace period note @@ -97,11 +96,11 @@ export async function get({ } let record = await collection.findOne(query, {projection}); - if(record) { + if(record && record.pairwiseToken.expires !== undefined) { // explicitly check `expires` against current time to handle cases where // the database record just hasn't been expunged yet const now = new Date(); - if(record.pairwiseToken.expires && now > record.pairwiseToken.expires) { + if(now > record.pairwiseToken.expires) { record = null; } } @@ -126,9 +125,9 @@ export async function upsert({ exists by running an `update` operation and the first `get` operation concurrently. We ensure the `update` completes successfully before returning the result of the first `get` operation, ensuring that the pairwise token - record's expiration is updated. If the first `get` operation fails to find - a result, we create one, looping if another process is running that creates - one first. */ + record's expiration, if set, is updated. If the first `get` operation fails + to find a result, we create one, looping if another process is running that + creates one first. */ updateOp = _update({internalId, requester, expires}).catch(e => e); } @@ -177,10 +176,12 @@ async function _create({internalId, requester, expires}) { pairwiseToken: { internalId, requester, - value, - expires + value } }; + if(expires !== undefined) { + record.pairwiseToken.expires = expires; + } try { await collection.insertOne(record); } catch(e) { @@ -203,11 +204,11 @@ async function _update({internalId, requester, expires}) { 'pairwiseToken.requester': requester }; const update = { - $set: { - 'meta.updated': Date.now(), - 'pairwiseToken.expires': expires - } + $set: {'meta.updated': Date.now()} }; + if(expires !== undefined) { + update.$max = {'pairwiseToken.expires': expires}; + } // return `true` if the update occurred const collection = database.collections['tokenization-pairwiseToken']; diff --git a/lib/tokens/resolve.js b/lib/tokens/resolve.js index 28f9778..39d35d5 100644 --- a/lib/tokens/resolve.js +++ b/lib/tokens/resolve.js @@ -59,7 +59,9 @@ export async function resolve({ while(true) { // get batch document const {tokenBatch} = await _getBatch({id: batchId}); - const {internalId, expires} = tokenBatch; + // default missing `pairwiseTokenExpires` to the batch expiration to + // handle backwards compatibility for batches + const {internalId, pairwiseTokenExpires = tokenBatch.expires} = tokenBatch; // determine token pinned/unpinned status const isUnpinned = tokenBatch.minAssuranceForResolution === -1; @@ -125,8 +127,9 @@ export async function resolve({ pairwise tokens, it's possible for the token batch to be updated prior to the pairwise token being created -- which means we must upsert one here. */ - tokenRecord = await _upsertPairwiseToken( - {internalId, requester, expires}); + tokenRecord = await _upsertPairwiseToken({ + internalId, requester, expires: pairwiseTokenExpires + }); } } } @@ -173,7 +176,8 @@ export async function resolve({ try { ({pairwiseToken} = await _markTokenResolved({ batchId, index, internalId, requester, compressed, - encodedRequester, requesterList, resolvedList, expires + encodedRequester, requesterList, resolvedList, + expires: pairwiseTokenExpires })); } catch(e) { if(e.name === 'InvalidStateError') { @@ -216,7 +220,7 @@ export async function resolve({ // finally, return pairwise token, internal ID, and other token info return { pairwiseToken, internalId, isUnpinned, minAssuranceForResolution, - validUntil: expires + validUntil: pairwiseTokenExpires }; } } diff --git a/test/mocha/10-documents.js b/test/mocha/10-documents.js index 6a8bfb8..aaf8cc1 100644 --- a/test/mocha/10-documents.js +++ b/test/mocha/10-documents.js @@ -31,6 +31,8 @@ const key2 = new X25519KeyAgreementKey2020({ privateKeyMultibase: 'z3web9AUP49zFCBVEdQ4ksbSmzgi6JqNCA84XNxUAcMDZgZc' }); +const MAX_EXPIRATION_DATE = new Date('9000-01-01T00:00:00Z'); + describe('Documents', function() { describe('documents.getRegistration()', () => { it('should retrieve a registration for an internalId', async () => { @@ -190,6 +192,17 @@ describe('Documents', function() { }); isRegistration(result); }); + + it('should register a document with max TTL', async () => { + const result = await documents.register({ + externalId: 'did:test:register:with:max:ttl', + document: {}, + store: false, + ttl: -1 + }); + isRegistration(result); + result.registration.expires.should.eql(MAX_EXPIRATION_DATE); + }); }); describe('documents._encrypt()', () => { diff --git a/test/mocha/20-tokens.js b/test/mocha/20-tokens.js index 6b7420c..f06bfe5 100644 --- a/test/mocha/20-tokens.js +++ b/test/mocha/20-tokens.js @@ -14,6 +14,8 @@ import crypto from 'node:crypto'; import {encode} from 'base58-universal'; import sinon from 'sinon'; +const DEFAULT_BATCH_TTL = 240 * 24 * 60 * 60 * 1000; +const MAX_EXPIRATION_DATE = new Date('9000-01-01T00:00:00Z'); const MAX_UINT32 = 4294967295; describe('Tokens', function() { @@ -354,6 +356,8 @@ describe('Tokens', function() { assertNoError(err); areTokens(tks); should.exist(result.pairwiseToken); + result.validUntil.should.be.lt( + new Date(Date.now() + DEFAULT_BATCH_TTL + 60000)); }); it('should resolve token when called twice with same "requester"', async function() { @@ -383,6 +387,9 @@ describe('Tokens', function() { should.exist(result2.pairwiseToken); result1.pairwiseToken.should.eql(result2.pairwiseToken); result2.internalId.should.eql(internalId); + const maxDate = new Date(Date.now() + DEFAULT_BATCH_TTL + 60000); + result1.validUntil.should.be.lt(maxDate); + result2.validUntil.should.be.lt(maxDate); }); it('should resolve token when pairwise token has expired w/same "requester"', async function() { @@ -458,6 +465,31 @@ describe('Tokens', function() { result1.pairwiseToken.should.eql(result2.pairwiseToken); result2.internalId.should.eql(internalId); }); + it('should resolve token with a max-TTL pairwise token', + 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: -1}); + + 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.eql(MAX_EXPIRATION_DATE); + }); it('should not resolve unpinned token when called twice with same ' + '"requester" if level of assurance is too low', async function() { const tokenCount = 1; @@ -1000,6 +1032,50 @@ describe('Tokens', function() { yesterday); } }); + it('should register + create and resolve with max TTL', async () => { + const dateOfBirth = '1987-05-01'; + const expires = '2021-05-01'; + const identifier = 'T99998765'; + 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 + }, + 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); + }); it('should register and upsert a pairwise token', async function() { const dateOfBirth = '2000-05-01'; const expires = '2021-05-01';