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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
50 changes: 39 additions & 11 deletions lib/documents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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];
}
Expand All @@ -626,20 +647,27 @@ 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
} = {}) {
const query = {
'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};
}
Expand Down
21 changes: 13 additions & 8 deletions lib/entities.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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};
}
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions lib/helpers.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading