Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add (encrypted) tasks API. #3

Open
wants to merge 9 commits into
base: add-sync-api
Choose a base branch
from
Open
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
25 changes: 17 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
# bedrock-vc-issuer-coordinator-storage ChangeLog

## 1.1.0 - 2025-mm-dd
## 2.0.0 - 2025-mm-dd

### Added
- Expose `syncCredentialStatus()` API for syncing the status
(and, optionally, reference information) of VCs that an
issuer coordinator references with some set of enumerable
status updates. The enumerable status updates are returned
from a function, `getStatusUpdates()`, that the caller of
`syncCredentialStatus()` provides. See the documentation
for `syncCredentialStatus()` for more details.
- Expose `syncCredentialStatus()` API for syncing the status (and, optionally,
reference information) of VCs that an issuer coordinator references with
some set of enumerable status updates. The enumerable status updates are
returned from a function, `getStatusUpdates()`, that the caller of
`syncCredentialStatus()` provides. See the documentation for
`syncCredentialStatus()` for more details.
- Add `tasks` API for storing, retrieving, and deleting arbitrary issuer
coordinator tasks. An application can be configured to optionally encrypt
all secrets in the task records and/or to set expiration dates for the
task records for increased security.

### Changed
- **BREAKING**: Changed `vc-reference` collection name to
`vc-issuer-coordinator-vc-reference` to better namespace the collection in
the event that it is shared with a wider set of collections in a top-level
application.

## 1.0.0 - 2024-11-24

Expand Down
25 changes: 24 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved.
*/
import {config} from '@bedrock/core';

Expand All @@ -12,3 +12,26 @@ cfg.caches = {
maxSize: 1000
}
};

cfg.tasks = {
// used to encrypt task secrets that are stored in task records
recordEncryption: {
// HMAC key for producing task IDs
hmacKey: null,
/*
hmacKey: {
id: '<a key identifier>',
secretKeyMultibase: '<multibase encoding of an AES-256 secret key>'
}*/
// current key encryption key for wrapping randomly-generated content
// encryption keys used to encrypt task secrets at task record creation
// time; existing task records w/o task secrets encryption will be
// unaffected by a configuration change here
kek: null,
/*
kek: {
id: '<a key identifier>',
secretKeyMultibase: '<multibase encoding of an AES-256 secret key>'
}*/
}
};
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import './config.js';

export * from './sync.js';
export * as syncRecords from './syncRecords.js';
export * as tasks from './tasks.js';
export * as vcReferences from './vcReferences.js';
export {zcapClient} from './zcapClient.js';
9 changes: 8 additions & 1 deletion lib/syncRecords.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
*/
import * as bedrock from '@bedrock/core';
import * as database from '@bedrock/mongodb';
import assert from 'assert-plus';

const {util: {BedrockError}} = bedrock;

export const COLLECTION_NAME = 'vc-reference-sync';
export const COLLECTION_NAME = 'vc-issuer-coordinator-vc-reference-sync';

bedrock.events.on('bedrock-mongodb.ready', async () => {
await database.openCollections([COLLECTION_NAME]);
Expand Down Expand Up @@ -46,6 +47,8 @@ export async function create({id} = {}) {
* database record or an ExplainObject if `explain=true`.
*/
export async function get({id, explain = false, create = false} = {}) {
assert.string(id, 'id');

const query = {'sync.id': id};
const collection = database.collections[COLLECTION_NAME];
const projection = {_id: 0};
Expand Down Expand Up @@ -90,6 +93,10 @@ export async function get({id, explain = false, create = false} = {}) {
* success or an ExplainObject if `explain=true`.
*/
export async function update({sync, explain = false} = {}) {
assert.object(sync, 'sync');
assert.string(sync.id, 'sync.id');
assert.number(sync.sequence, 'sync.sequence');

// build update
const now = Date.now();
const update = {};
Expand Down
255 changes: 255 additions & 0 deletions lib/taskEncryption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/*!
* Copyright (c) 2019-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import {generalDecrypt, GeneralEncrypt} from 'jose';
import {createContentId} from './util.js';
import {logger} from './logger.js';

const {util: {BedrockError}} = bedrock;

const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();

const NON_SECRET_PROPERTIES = new Set(['id', 'sequence', 'expires']);

/* Multikey registry IDs and encoded header values
aes-256 | 0xa2 | 256-bit AES symmetric key
*/
const SUPPORTED_KEY_TYPES = new Map([
['aes-256', {header: new Uint8Array([0xa2, 0x01]), size: 32}]
]);

// load all HMAC keys and KEKs from config
const HMAC_KEYS = new Map();
const KEKS = new Map();
bedrock.events.on('bedrock.init', () => {
_loadKeys();
});

// pass `task` from a task `record`
export async function decryptTaskSecrets({task} = {}) {
if(task.encrypted === undefined) {
// nothing to unwrap, return early
return task;
}

try {
// decrypt encrypted task
const {kekId, jwe} = task.encrypted;
const secretKey = _getKek(kekId);
const {plaintext} = await generalDecrypt(jwe, secretKey);
const secrets = JSON.parse(TEXT_DECODER.decode(plaintext));

// new task object w/decrypted secrets
task = {...task, ...secrets};
delete task.encrypted;
return task;
} catch(cause) {
throw new BedrockError('Could not decrypt record secrets.', {
name: 'OperationError',
cause,
details: {
public: true,
httpStatusCode: 500
}
});
}
}

// pass `task` from a task `record`
export async function encryptTaskSecrets({task} = {}) {
try {
if(task.encrypted !== undefined) {
// should not happen; bad call
throw new Error(
'Could not encrypt record secrets; ' +
'record secrets already encrypted.');
}

// get current KEK ID
const cfg = _getConfig();
const kekId = cfg.kek?.id;
if(!kekId) {
// no KEK config; return early
return task;
}

// separate record task's non-secret / secret properties
const nonSecrets = new Map();
const secrets = new Map();
for(const prop in task) {
const value = task[prop];
if(NON_SECRET_PROPERTIES.has(prop)) {
nonSecrets.set(prop, value);
continue;
}
secrets.set(prop, value);
}

// encrypt task secrets
const plaintext = _mapToBuffer(secrets);
const secretKey = _getKek(kekId);
const jwe = await new GeneralEncrypt(plaintext)
.setProtectedHeader({enc: 'A256GCM'})
.addRecipient(secretKey)
.setUnprotectedHeader({alg: 'A256KW', kid: kekId})
.encrypt();

// return new task object w/encrypted secrets
return {
...Object.fromEntries(nonSecrets.entries()),
encrypted: {kekId, jwe}
};
} catch(cause) {
throw new BedrockError('Could not encrypt record secrets.', {
name: 'OperationError',
cause,
details: {
public: true,
httpStatusCode: 500
}
});
}
}

// create a task record ID using the task `request`
export async function createTaskId({request} = {}) {
// get prefix and current HMAC key
let prefix;
let hmacKey;
const cfg = _getConfig();
const hmacKeyId = cfg.hmacKey?.id;
if(hmacKeyId) {
// an HMAC key is required to keep any task secrets confidential
hmacKey = _getHmacKey(hmacKeyId);
prefix = `urn:hmac:${encodeURIComponent(hmacKeyId)}:`;
} else {
prefix = 'urn:hash:';
}

const {id} = createContentId({content: request, secret: hmacKey});
return {id: prefix + id};
}

function _getHmacKey(id) {
const secretKey = HMAC_KEYS.get(id);
if(secretKey) {
return secretKey;
}
throw new BedrockError(`HMAC key "${id}" not found.`, {
name: 'NotFoundError',
details: {
public: true,
httpStatusCode: 400
}
});
}

function _getKek(id) {
const secretKey = KEKS.get(id);
if(secretKey) {
return secretKey;
}
throw new BedrockError(`Key encryption key "${id}" not found.`, {
name: 'NotFoundError',
details: {
public: true,
httpStatusCode: 400
}
});
}

function _getConfig() {
const cfg = bedrock.config['vc-issuer-coordinator-storage'];
return cfg.tasks.recordEncryption;
}

function _loadKey(secretKeyMultibase) {
if(!secretKeyMultibase?.startsWith('u')) {
throw new BedrockError(
'Unsupported multibase header; ' +
'"u" for base64url-encoding must be used.', {
name: 'NotSupportedError',
details: {
public: true,
httpStatusCode: 400
}
});
}

// check multikey header
let keyType;
let secretKey;
const multikey = Buffer.from(secretKeyMultibase.slice(1), 'base64url');
for(const [type, {header, size}] of SUPPORTED_KEY_TYPES) {
if(multikey[0] === header[0] && multikey[1] === header[1]) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side note: good candidate to extract into some more general "add kek to bedrock thingies" module

keyType = type;
if(multikey.length !== (2 + size)) {
// intentionally do not report what was detected because a
// misconfigured secret could have its first two bytes revealed
throw new BedrockError(
'Incorrect multikey size or invalid multikey header.', {
name: 'DataError',
details: {
public: true,
httpStatusCode: 400
}
});
}
secretKey = multikey.subarray(2);
break;
}
}
if(keyType === undefined) {
throw new BedrockError(
'Unsupported multikey type; only AES-256 is supported.', {
name: 'NotSupportedError',
details: {
public: true,
httpStatusCode: 400
}
});
}

return secretKey;
}

// exported for testing purposes only
export function _loadKeys() {
HMAC_KEYS.clear();
KEKS.clear();
const {hmacKey, kek} = _getConfig();
if(!(hmacKey && kek)) {
logger.info('Task record encryption is disabled.');
} else {
if(!(hmacKey.id && typeof hmacKey.id === 'string')) {
throw new BedrockError(
'Invalid HMAC key configuration; key "id" must be a string.', {
name: 'DataError',
details: {
public: true,
httpStatusCode: 400
}
});
}
if(!(kek.id && typeof kek.id === 'string')) {
throw new BedrockError(
'Invalid key encryption key configuration; ' +
'key "id" must be a string.', {
name: 'DataError',
details: {
public: true,
httpStatusCode: 400
}
});
}
HMAC_KEYS.set(hmacKey.id, _loadKey(hmacKey.secretKeyMultibase));
KEKS.set(kek.id, _loadKey(kek.secretKeyMultibase));
logger.info('Task record encryption is enabled.');
}
}

function _mapToBuffer(m) {
return TEXT_ENCODER.encode(JSON.stringify(Object.fromEntries(m.entries())));
}
Loading