From bb14d490207936cad737eb3f0741bd1a9a15eda6 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Wed, 24 Sep 2025 14:47:11 -0400 Subject: [PATCH 01/33] Refactor HTTP routes into separate files. --- lib/helpers.js | 26 +++ lib/http.js | 429 +------------------------------------------ lib/profileAgents.js | 326 ++++++++++++++++++++++++++++++++ lib/profiles.js | 98 ++++++++++ lib/zcapClient.js | 16 +- 5 files changed, 467 insertions(+), 428 deletions(-) create mode 100644 lib/helpers.js create mode 100644 lib/profileAgents.js create mode 100644 lib/profiles.js diff --git a/lib/helpers.js b/lib/helpers.js new file mode 100644 index 0000000..609c21c --- /dev/null +++ b/lib/helpers.js @@ -0,0 +1,26 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import {ZCAP_CLIENT} from './zcapClient.js'; + +export async function createMeter({controller, productId, capability} = {}) { + let url; + if(capability) { + url = capability.invocationTarget; + } else { + // only use `url` from config if `capability` is not provided + ({url} = bedrock.config['profile-http'].meterService); + } + + // create a meter + let meter = {controller, product: {id: productId}}; + ({data: {meter}} = await ZCAP_CLIENT.write({url, json: meter, capability})); + + // return fully qualified meter ID + const {id} = meter; + // ensure `URL` terminates at `/meters` -- in case zcap invocation target + // was attenuated + url = url.slice(0, url.indexOf('/meters') + '/meters'.length); + return {id: `${url}/${id}`}; +} diff --git a/lib/http.js b/lib/http.js index 4995bc5..34c3564 100644 --- a/lib/http.js +++ b/lib/http.js @@ -1,431 +1,6 @@ /*! * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ -import * as bedrock from '@bedrock/core'; -import * as schemas from '../schemas/bedrock-profile-http.js'; -import {profileAgents, profileMeters, profiles} from '@bedrock/profile'; -import {asyncHandler} from '@bedrock/express'; -import {ensureAuthenticated} from '@bedrock/passport'; -import {getAppIdentity} from '@bedrock/app-identity'; -import {createValidateMiddleware as validate} from '@bedrock/validation'; -import {ZCAP_CLIENT} from './zcapClient.js'; - -// include interactions routes +import './profiles.js'; +import './profileAgents.js'; import './interactions.js'; - -const {config, util: {BedrockError}} = bedrock; - -let APP_ID; -let EDV_METER_CREATION_ZCAP; -let WEBKMS_METER_CREATION_ZCAP; - -bedrock.events.on('bedrock.init', () => { - const {id} = getAppIdentity(); - APP_ID = id; - - const cfg = bedrock.config['profile-http']; - - const {edvMeterCreationZcap, webKmsMeterCreationZcap} = cfg; - if(edvMeterCreationZcap) { - EDV_METER_CREATION_ZCAP = JSON.parse(edvMeterCreationZcap); - } - if(webKmsMeterCreationZcap) { - WEBKMS_METER_CREATION_ZCAP = JSON.parse(webKmsMeterCreationZcap); - } -}); - -bedrock.events.on('bedrock-express.configure.routes', app => { - const cfg = config['profile-http']; - const {defaultProducts} = cfg; - const {basePath} = cfg.routes; - const profileAgentsPath = '/profile-agents'; - const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; - const routes = { - profiles: basePath, - profileAgents: profileAgentsPath, - profileAgent: profileAgentPath, - profileAgentClaim: `${profileAgentPath}/claim`, - profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`, - profileAgentCapabilitySet: `${profileAgentPath}/capability-set` - }; - - // create a new profile - app.post( - routes.profiles, - ensureAuthenticated, - validate({bodySchema: schemas.accountQuery}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account, didMethod, didOptions} = req.body; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - // create a new meter, edv options, and keystore options - const [{id: edvMeterId}, {id: kmsMeterId}] = await Promise.all([ - _createMeter({ - // controller of meter is the app that runs bedrock-profile-http - controller: APP_ID, - // use default EDV product; specifying in request not supported - productId: defaultProducts.edv, - // use zcap for edv meter creation; when undefined invoke root zcap - capability: EDV_METER_CREATION_ZCAP, - }), - _createMeter({ - // controller of meter is the app that runs bedrock-profile-http - controller: APP_ID, - // use default webkms product; specifying in request not supported - productId: defaultProducts.webkms, - // use zcap for webkms meter creation; when undefined invoke root zcap - capability: WEBKMS_METER_CREATION_ZCAP, - }) - ]); - const edvOptions = { - baseUrl: cfg.edvBaseUrl, - meterId: edvMeterId, - meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner - }; - // add any additionally configured EDVs - if(cfg.additionalEdvs) { - edvOptions.additionalEdvs = Object.values(cfg.additionalEdvs); - } - const keystoreOptions = { - meterId: kmsMeterId, - meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner - }; - - const profile = await profiles.create({ - accountId: account, - didMethod, - keystoreOptions: { - profileAgent: keystoreOptions, - profile: keystoreOptions - }, - edvOptions: { - profile: edvOptions - }, - didOptions - }); - - res.json(profile); - })); - - // creates a profile agent, optionally w/ account set - app.post( - routes.profileAgents, - ensureAuthenticated, - validate({bodySchema: schemas.profileAgent}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account, profile, token} = req.body; - - if(!accountId || (account && account !== accountId)) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - // create a new meter and keystore options - const {id: meterId} = await _createMeter({ - // controller of meter is app that runs bedrock-profile-http - controller: APP_ID, - // use default webkms product; specifying in request not supported - productId: defaultProducts.webkms, - // use zcap for webkms meter creation; when undefined invoke root zcap - capability: WEBKMS_METER_CREATION_ZCAP, - }); - const keystoreOptions = { - meterId, - meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner - }; - - const options = { - profileId: profile, - keystoreOptions, - store: true - }; - if(account) { - options.accountId = account; - } - if(token) { - options.token = token; - } - - const profileAgentRecord = await profileAgents.create(options); - const {meters} = await profileMeters.findByProfile({ - profileId: profile - }); - - res.json({ - ..._sanitizeProfileAgentRecord(profileAgentRecord), - profileMeters: meters - }); - })); - - // gets all profile agents associated with an account - app.get( - routes.profileAgents, - ensureAuthenticated, - validate({querySchema: schemas.profileAgents}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account, profile} = req.query; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - const profileAgentRecords = await profileAgents.getAll({ - accountId: account - }); - if(profile) { - const {meters} = await profileMeters.findByProfile({ - profileId: profile - }); - // Note: In the next major release, this API should not repeat the same - // set of meters for each profile. - const records = profileAgentRecords.filter(({profileAgent}) => { - return profileAgent.profile === profile; - }).map(r => ({...r, profileMeters: meters})); - return res.json(records); - } - - const profileMetersMap = new Map(); - const promises = profileAgentRecords.map(async record => { - const {profile} = record.profileAgent; - let promise = profileMetersMap.get(profile); - if(!promise) { - promise = profileMeters.findByProfile({profileId: profile}); - profileMetersMap.set(profile, promise); - } - - const {meters} = await promise; - return { - ...record, - profileMeters: meters - }; - }); - - // No concurrency protection due to the assumption that for a given - // aaccount there will be <= 10 profiles - const records = await Promise.all(promises); - res.json(records); - })); - - // gets a profile agent by its "id" - app.get( - routes.profileAgent, - ensureAuthenticated, - validate({querySchema: schemas.accountQuery}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account} = req.query; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - const {profileAgentId} = req.params; - const profileAgentRecord = await profileAgents.get({id: profileAgentId}); - - // if profile agent is claimed (has an `account`), ensure profile agent - // `account` matches session account - const {profileAgent} = profileAgentRecord; - if(profileAgent.account && profileAgent.account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - const {profile} = profileAgent; - const {meters} = await profileMeters.findByProfile({profileId: profile}); - - res.json({...profileAgentRecord, profileMeters: meters}); - })); - - // deletes a profile agent by its "id" - app.delete( - routes.profileAgent, - ensureAuthenticated, - validate({querySchema: schemas.accountQuery}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account} = req.query; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - const {profileAgentId} = req.params; - // require `account` to also match the profile agent - await profileAgents.remove({id: profileAgentId, account}); - res.status(204).end(); - })); - - // claims a profile agent using an account - app.post( - routes.profileAgentClaim, - ensureAuthenticated, - validate({bodySchema: schemas.accountQuery}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account} = req.body; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The account must match the authenticated user.', 'NotAllowedError', { - httpStatusCode: 400, - public: true, - }); - } - const {profileAgentId} = req.params; - const profileAgentRecord = await profileAgents.get( - {id: profileAgentId, includeSecrets: true}); - - const {profileAgent} = profileAgentRecord; - if(profileAgent.account === account) { - // account already set properly, send affirmative response - return res.status(204).end(); - } - - // cannot claim a profile agent that has a different account or has - // a token - if(profileAgent.account || profileAgentRecord.secrets.token) { - throw new BedrockError( - 'Profile agent cannot be claimed.', 'NotAllowedError', { - httpStatusCode: 400, - public: true, - }); - } - - await profileAgents.update({ - profileAgent: { - ...profileAgent, - sequence: ++profileAgent.sequence, - account - } - }); - res.status(204).end(); - })); - - // delegates profile agent's zCaps to a specified "controller" - app.post( - routes.profileAgentCapabilities, - ensureAuthenticated, - validate({bodySchema: schemas.delegateCapability}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account, controller, zcap} = req.body; - - // ensure requested account matches session account - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - const {profileAgentId} = req.params; - const {profileAgent, secrets} = await profileAgents.get( - {id: profileAgentId, includeSecrets: true}); - - // ensure profile agent `account` matches session account - if(profileAgent.account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - // determine `expires` for delegated zcap - const now = Date.now(); - const ttl = config['profile-http'].zcap.ttl; - const preferredExpires = new Date(now + ttl); - let expires; - if(!zcap.expires) { - expires = preferredExpires.toISOString(); - } else { - const maxExpires = new Date(zcap.expires); - expires = maxExpires < preferredExpires ? - maxExpires.toISOString() : preferredExpires.toISOString(); - } - - const [delegated] = await profileAgents.delegateCapabilities( - {profileAgent, capabilities: [zcap], controller, secrets, expires}); - res.json({zcap: delegated}); - })); - - // update profile agent's zcaps (updates their capability set) - app.post( - routes.profileAgentCapabilitySet, - ensureAuthenticated, - validate({bodySchema: schemas.zcaps, querySchema: schemas.accountQuery}), - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {account} = req.query; - const {zcaps} = req.body; - if(!accountId || account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - const {profileAgentId} = req.params; - const {profileAgent} = await profileAgents.get({id: profileAgentId}); - - // ensure profile agent `account` matches session account - if(profileAgent.account !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - profileAgent.sequence++; - // replace existing zcaps - profileAgent.zcaps = zcaps; - - await profileAgents.update({profileAgent}); - - res.status(204).end(); - })); -}); - -// return select properties in the profileAgent record which does NOT include -// record.secrets -function _sanitizeProfileAgentRecord(record) { - const {meta, profileAgent} = record; - const sanitizedRecord = { - meta, - profileAgent, - }; - return sanitizedRecord; -} - -async function _createMeter({controller, productId, capability} = {}) { - let url; - if(capability) { - url = capability.invocationTarget; - } else { - // only use `url` from config if `capability` is not provided - ({url} = config['profile-http'].meterService); - } - - // create a meter - let meter = {controller, product: {id: productId}}; - ({data: {meter}} = await ZCAP_CLIENT.write({url, json: meter, capability})); - - // return fully qualified meter ID - const {id} = meter; - // ensure `URL` terminates at `/meters` -- in case zcap invocation target - // was attenuated - url = url.slice(0, url.indexOf('/meters') + '/meters'.length); - return {id: `${url}/${id}`}; -} diff --git a/lib/profileAgents.js b/lib/profileAgents.js new file mode 100644 index 0000000..b7728c4 --- /dev/null +++ b/lib/profileAgents.js @@ -0,0 +1,326 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as schemas from '../schemas/bedrock-profile-http.js'; +import { + APP_ID, + WEBKMS_METER_CREATION_ZCAP, + ZCAP_CLIENT +} from './zcapClient.js'; +import {profileAgents, profileMeters} from '@bedrock/profile'; +import {asyncHandler} from '@bedrock/express'; +import {createMeter} from './helpers.js'; +import {ensureAuthenticated} from '@bedrock/passport'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; + +const {util: {BedrockError}} = bedrock; + +bedrock.events.on('bedrock-express.configure.routes', app => { + const cfg = bedrock.config['profile-http']; + const {defaultProducts} = cfg; + const profileAgentsPath = '/profile-agents'; + const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; + const routes = { + profileAgents: profileAgentsPath, + profileAgent: profileAgentPath, + profileAgentClaim: `${profileAgentPath}/claim`, + profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`, + profileAgentCapabilitySet: `${profileAgentPath}/capability-set` + }; + + // creates a profile agent, optionally w/ account set + app.post( + routes.profileAgents, + ensureAuthenticated, + validate({bodySchema: schemas.profileAgent}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account, profile, token} = req.body; + + if(!accountId || (account && account !== accountId)) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + // create a new meter and keystore options + const {id: meterId} = await createMeter({ + // controller of meter is app that runs bedrock-profile-http + controller: APP_ID, + // use default webkms product; specifying in request not supported + productId: defaultProducts.webkms, + // use zcap for webkms meter creation; when undefined invoke root zcap + capability: WEBKMS_METER_CREATION_ZCAP, + }); + const keystoreOptions = { + meterId, + meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner + }; + + const options = { + profileId: profile, + keystoreOptions, + store: true + }; + if(account) { + options.accountId = account; + } + if(token) { + options.token = token; + } + + const profileAgentRecord = await profileAgents.create(options); + const {meters} = await profileMeters.findByProfile({ + profileId: profile + }); + + res.json({ + ..._sanitizeProfileAgentRecord(profileAgentRecord), + profileMeters: meters + }); + })); + + // gets all profile agents associated with an account + app.get( + routes.profileAgents, + ensureAuthenticated, + validate({querySchema: schemas.profileAgents}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account, profile} = req.query; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + const profileAgentRecords = await profileAgents.getAll({ + accountId: account + }); + if(profile) { + const {meters} = await profileMeters.findByProfile({ + profileId: profile + }); + // Note: In the next major release, this API should not repeat the same + // set of meters for each profile. + const records = profileAgentRecords.filter(({profileAgent}) => { + return profileAgent.profile === profile; + }).map(r => ({...r, profileMeters: meters})); + return res.json(records); + } + + const profileMetersMap = new Map(); + const promises = profileAgentRecords.map(async record => { + const {profile} = record.profileAgent; + let promise = profileMetersMap.get(profile); + if(!promise) { + promise = profileMeters.findByProfile({profileId: profile}); + profileMetersMap.set(profile, promise); + } + + const {meters} = await promise; + return { + ...record, + profileMeters: meters + }; + }); + + // No concurrency protection due to the assumption that for a given + // aaccount there will be <= 10 profiles + const records = await Promise.all(promises); + res.json(records); + })); + + // gets a profile agent by its "id" + app.get( + routes.profileAgent, + ensureAuthenticated, + validate({querySchema: schemas.accountQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account} = req.query; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + const {profileAgentId} = req.params; + const profileAgentRecord = await profileAgents.get({id: profileAgentId}); + + // if profile agent is claimed (has an `account`), ensure profile agent + // `account` matches session account + const {profileAgent} = profileAgentRecord; + if(profileAgent.account && profileAgent.account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + const {profile} = profileAgent; + const {meters} = await profileMeters.findByProfile({profileId: profile}); + + res.json({...profileAgentRecord, profileMeters: meters}); + })); + + // deletes a profile agent by its "id" + app.delete( + routes.profileAgent, + ensureAuthenticated, + validate({querySchema: schemas.accountQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account} = req.query; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + const {profileAgentId} = req.params; + // require `account` to also match the profile agent + await profileAgents.remove({id: profileAgentId, account}); + res.status(204).end(); + })); + + // claims a profile agent using an account + app.post( + routes.profileAgentClaim, + ensureAuthenticated, + validate({bodySchema: schemas.accountQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account} = req.body; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The account must match the authenticated user.', 'NotAllowedError', { + httpStatusCode: 400, + public: true, + }); + } + const {profileAgentId} = req.params; + const profileAgentRecord = await profileAgents.get( + {id: profileAgentId, includeSecrets: true}); + + const {profileAgent} = profileAgentRecord; + if(profileAgent.account === account) { + // account already set properly, send affirmative response + return res.status(204).end(); + } + + // cannot claim a profile agent that has a different account or has + // a token + if(profileAgent.account || profileAgentRecord.secrets.token) { + throw new BedrockError( + 'Profile agent cannot be claimed.', 'NotAllowedError', { + httpStatusCode: 400, + public: true, + }); + } + + await profileAgents.update({ + profileAgent: { + ...profileAgent, + sequence: ++profileAgent.sequence, + account + } + }); + res.status(204).end(); + })); + + // delegates profile agent's zCaps to a specified "controller" + app.post( + routes.profileAgentCapabilities, + ensureAuthenticated, + validate({bodySchema: schemas.delegateCapability}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account, controller, zcap} = req.body; + + // ensure requested account matches session account + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + const {profileAgentId} = req.params; + const {profileAgent, secrets} = await profileAgents.get( + {id: profileAgentId, includeSecrets: true}); + + // ensure profile agent `account` matches session account + if(profileAgent.account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + // determine `expires` for delegated zcap + const now = Date.now(); + const ttl = bedrock.config['profile-http'].zcap.ttl; + const preferredExpires = new Date(now + ttl); + let expires; + if(!zcap.expires) { + expires = preferredExpires.toISOString(); + } else { + const maxExpires = new Date(zcap.expires); + expires = maxExpires < preferredExpires ? + maxExpires.toISOString() : preferredExpires.toISOString(); + } + + const [delegated] = await profileAgents.delegateCapabilities( + {profileAgent, capabilities: [zcap], controller, secrets, expires}); + res.json({zcap: delegated}); + })); + + // update profile agent's zcaps (updates their capability set) + app.post( + routes.profileAgentCapabilitySet, + ensureAuthenticated, + validate({bodySchema: schemas.zcaps, querySchema: schemas.accountQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account} = req.query; + const {zcaps} = req.body; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + const {profileAgentId} = req.params; + const {profileAgent} = await profileAgents.get({id: profileAgentId}); + + // ensure profile agent `account` matches session account + if(profileAgent.account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + profileAgent.sequence++; + // replace existing zcaps + profileAgent.zcaps = zcaps; + + await profileAgents.update({profileAgent}); + + res.status(204).end(); + })); +}); + +// return select properties in the profileAgent record which does NOT include +// record.secrets +function _sanitizeProfileAgentRecord(record) { + const {meta, profileAgent} = record; + const sanitizedRecord = { + meta, + profileAgent, + }; + return sanitizedRecord; +} diff --git a/lib/profiles.js b/lib/profiles.js new file mode 100644 index 0000000..886acbf --- /dev/null +++ b/lib/profiles.js @@ -0,0 +1,98 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as schemas from '../schemas/bedrock-profile-http.js'; +import { + APP_ID, + EDV_METER_CREATION_ZCAP, + WEBKMS_METER_CREATION_ZCAP, + ZCAP_CLIENT +} from './zcapClient.js'; +import {asyncHandler} from '@bedrock/express'; +import {createMeter} from './helpers.js'; +import {ensureAuthenticated} from '@bedrock/passport'; +import {profiles} from '@bedrock/profile'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; + +const {util: {BedrockError}} = bedrock; + +bedrock.events.on('bedrock-express.configure.routes', app => { + const cfg = bedrock.config['profile-http']; + const {defaultProducts} = cfg; + const {basePath} = cfg.routes; + const profileAgentsPath = '/profile-agents'; + const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; + const routes = { + profiles: basePath, + profileAgents: profileAgentsPath, + profileAgent: profileAgentPath, + profileAgentClaim: `${profileAgentPath}/claim`, + profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`, + profileAgentCapabilitySet: `${profileAgentPath}/capability-set` + }; + + // create a new profile + app.post( + routes.profiles, + ensureAuthenticated, + validate({bodySchema: schemas.accountQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {account, didMethod, didOptions} = req.body; + if(!accountId || account !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + // create a new meter, edv options, and keystore options + const [{id: edvMeterId}, {id: kmsMeterId}] = await Promise.all([ + createMeter({ + // controller of meter is the app that runs bedrock-profile-http + controller: APP_ID, + // use default EDV product; specifying in request not supported + productId: defaultProducts.edv, + // use zcap for edv meter creation; when undefined invoke root zcap + capability: EDV_METER_CREATION_ZCAP, + }), + createMeter({ + // controller of meter is the app that runs bedrock-profile-http + controller: APP_ID, + // use default webkms product; specifying in request not supported + productId: defaultProducts.webkms, + // use zcap for webkms meter creation; when undefined invoke root zcap + capability: WEBKMS_METER_CREATION_ZCAP, + }) + ]); + const edvOptions = { + baseUrl: cfg.edvBaseUrl, + meterId: edvMeterId, + meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner + }; + // add any additionally configured EDVs + if(cfg.additionalEdvs) { + edvOptions.additionalEdvs = Object.values(cfg.additionalEdvs); + } + const keystoreOptions = { + meterId: kmsMeterId, + meterCapabilityInvocationSigner: ZCAP_CLIENT.invocationSigner + }; + + const profile = await profiles.create({ + accountId: account, + didMethod, + keystoreOptions: { + profileAgent: keystoreOptions, + profile: keystoreOptions + }, + edvOptions: { + profile: edvOptions + }, + didOptions + }); + + res.json(profile); + })); +}); diff --git a/lib/zcapClient.js b/lib/zcapClient.js index f0087e0..5a71832 100644 --- a/lib/zcapClient.js +++ b/lib/zcapClient.js @@ -7,15 +7,29 @@ import {getAppIdentity} from '@bedrock/app-identity'; import {httpsAgent} from '@bedrock/https-agent'; import {ZcapClient} from '@digitalbazaar/ezcap'; +export let APP_ID; export let ZCAP_CLIENT; +export let EDV_METER_CREATION_ZCAP; +export let WEBKMS_METER_CREATION_ZCAP; bedrock.events.on('bedrock.init', () => { // create signer using the application's capability invocation key - const {keys: {capabilityInvocationKey}} = getAppIdentity(); + const {id, keys: {capabilityInvocationKey}} = getAppIdentity(); + APP_ID = id; ZCAP_CLIENT = new ZcapClient({ agent: httpsAgent, invocationSigner: capabilityInvocationKey.signer(), SuiteClass: Ed25519Signature2020 }); + + // load zcaps delegated to the application + const cfg = bedrock.config['profile-http']; + const {edvMeterCreationZcap, webKmsMeterCreationZcap} = cfg; + if(edvMeterCreationZcap) { + EDV_METER_CREATION_ZCAP = JSON.parse(edvMeterCreationZcap); + } + if(webKmsMeterCreationZcap) { + WEBKMS_METER_CREATION_ZCAP = JSON.parse(webKmsMeterCreationZcap); + } }); From 99743b122eb69f896189d978709e1a3471ac5f4e Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 29 Sep 2025 12:49:28 -0400 Subject: [PATCH 02/33] Add structure for zcap routes. --- lib/config.js | 13 ++++- lib/middleware.js | 116 +++++++++++++++++++++++++++++++++++++++ lib/zcaps.js | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 lib/middleware.js create mode 100644 lib/zcaps.js diff --git a/lib/config.js b/lib/config.js index 0915c33..22289f2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -14,11 +14,22 @@ cfg.routes = { basePath }; +// profile agent zcap config cfg.zcap = { - // default: 24 hour expiration + // default: 24 hour TTL for delegated zcaps (when a profile agent's zcaps + // are delegated) ttl: 24 * 60 * 60 * 1000 }; +// for middleware that uses zcap-authz +cfg.authorizeZcapInvocationOptions = { + maxChainLength: 10, + // 300 second clock skew permitted by default + maxClockSkew: 300, + // 1 year max TTL by default + maxDelegationTtl: 1 * 60 * 60 * 24 * 365 * 1000 +}; + // default products (if none specified in request) cfg.defaultProducts = { // mock ID for default edv service product diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..8bd4a0a --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,116 @@ +/*! + * Copyright (c) 2018-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as brZCapStorage from '@bedrock/zcap-storage'; +import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; +import { + authorizeZcapInvocation as _authorizeZcapInvocation, + authorizeZcapRevocation as _authorizeZcapRevocation +} from '@digitalbazaar/ezcap-express'; +import {documentLoader} from '../documentLoader.js'; +import { + Ed25519Signature2020 +} from '@digitalbazaar/ed25519-signature-2020'; + +const {util: {BedrockError}} = bedrock; +const {helpers: {inspectCapabilityChain}} = brZCapStorage; + +// creates middleware for authorizing a zcap policy request +export function authorizeZcapPolicyRequest({ + expectedAction, root = false +} = {}) { + const cfg = bedrock.config['profile-http']; + const {basePath} = cfg.routes; + const {baseUri} = bedrock.config.server; + const profilePath = `${baseUri}/${basePath}`; + + return authorizeZcapInvocation({ + async getExpectedValues({req}) { + const {profileId, policyId} = req.params; + const policyRoot = + `${profilePath}/${encodeURIComponent(profileId)}/zcaps/policies`; + return { + // allow expected action override + action: expectedAction, + host: bedrock.config.server.host, + rootInvocationTarget: root ? + policyRoot : `${policyRoot}/${encodeURIComponent(policyId)}` + }; + }, + async getRootController({req}) { + // this will always be present based on where this middleware is used + return req.param.profileId; + } + }); +} + +// calls ezcap-express's authorizeZcapInvocation w/constant params, exposing +// only those params that change in this module +export function authorizeZcapInvocation({ + getExpectedValues, getRootController +} = {}) { + const {authorizeZcapInvocationOptions} = bedrock.config['profile-http']; + return _authorizeZcapInvocation({ + documentLoader, getExpectedValues, getRootController, + getVerifier, + inspectCapabilityChain, + onError, + suiteFactory, + ...authorizeZcapInvocationOptions + }); +} + +// FIXME: remove if not used +// creates middleware for revocation of zcaps for service objects +export function authorizeZcapRevocation() { + return _authorizeZcapRevocation({ + documentLoader, + expectedHost: bedrock.config.server.host, + async getRootController({req}) { + // this will always be present based on where this middleware is used + return req.serviceObject.config.controller; + }, + getVerifier, + inspectCapabilityChain, + onError, + suiteFactory + }); +} + +// hook used to verify zcap invocation HTTP signatures +async function getVerifier({keyId, documentLoader}) { + const {document} = await documentLoader(keyId); + const key = await Ed25519Multikey.from(document); + const verificationMethod = await key.export( + {publicKey: true, includeContext: true}); + const verifier = key.verifier(); + return {verifier, verificationMethod}; +} + +function onError({error}) { + if(!(error instanceof BedrockError)) { + // always expose cause message and name; expose cause details as + // BedrockError if error is marked public + let details = {}; + if(error.details && error.details.public) { + details = error.details; + } + error = new BedrockError( + error.message, + error.name || 'NotAllowedError', { + ...details, + public: true, + }, error); + } + throw new BedrockError( + 'Authorization error.', 'NotAllowedError', { + httpStatusCode: 403, + public: true, + }, error); +} + +// hook used to create suites for verifying zcap delegation chains +async function suiteFactory() { + return new Ed25519Signature2020(); +} diff --git a/lib/zcaps.js b/lib/zcaps.js new file mode 100644 index 0000000..fb1ada0 --- /dev/null +++ b/lib/zcaps.js @@ -0,0 +1,137 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as middleware from './middleware.js'; +import * as schemas from '../schemas/bedrock-profile-http.js'; +import {asyncHandler} from '@bedrock/express'; +import cors from 'cors'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; + +bedrock.events.on('bedrock-express.configure.routes', app => { + const cfg = bedrock.config['profile-http']; + const {basePath} = cfg.routes; + const zcapsPath = `${basePath}/:profileId/zcaps`; + const routes = { + policies: `${zcapsPath}/policies`, + refresh: `${zcapsPath}/refresh` + }; + routes.policy = `${routes.policies}/:policyId`; + + // base URL for server + //const {baseUri} = bedrock.config.server; + + /* Note: CORS is used on all endpoints. This is safe because authorization + uses HTTP signatures + capabilities, not cookies; CSRF is not possible. */ + + // create a new zcap policy + app.options(routes.policies, cors()); + app.post( + routes.policies, + cors(), + // FIXME: make wider than "zcap policy" as "authorization policy"? + validate({bodySchema: schemas.createZcapPolicyBody}), + validate({bodySchema: schemas.createZcapPolicy}), + middleware.authorizeZcapPolicyRequest({root: true}), + asyncHandler(async (/*req, res*/) => { + // FIXME: create zcap/authz policy + throw new Error('Not implemented'); + // FIXME: consider meter usage for storing policies or some other limit + /* + const {body: {meterId}, meterCheck: {hasAvailable}} = req; + if(!hasAvailable) { + // insufficient remaining storage + throw new BedrockError('Permission denied.', 'NotAllowedError', { + httpStatusCode: 403, + public: true, + }); + } + */ + })); + + // get zcap policies by query + app.get( + routes.policies, + cors(), + validate({querySchema: schemas.getZcapPoliciesQuery}), + middleware.authorizeZcapPolicyRequest(), + asyncHandler(async (/*req, res*/) => { + // FIXME: implement + throw new Error('Not implemented'); + /* + const {profileId} = req.query; + const results = await policyStorage.find({ + profileId, req, options: {projection: {_id: 0, policy: 1}} + }); + res.json({ + // return as `results` to enable adding `hasMore` / `cursor` + // information in the future + results: results.map(r => r.policy) + }); + */ + })); + + // update a zcap policy + app.options(routes.policy, cors()); + app.post( + routes.policy, + cors(), + validate({bodySchema: schemas.updateZcapPolicyBody}), + middleware.authorizeZcapPolicyRequest(), + asyncHandler(async (/*req, res*/) => { + // FIXME: implement + throw new Error('Not implemented'); + /* + await policyStorage.update({policy}); + res.json(policy); + + // FIXME: use meters? + // meter operation usage + reportOperationUsage({req}); + */ + })); + + // get a zcap policy + app.get( + routes.policy, + cors(), + middleware.authorizeZcapPolicyRequest(), + asyncHandler(async (/*req, res*/) => { + // FIXME: implement + throw new Error('Not implemented'); + })); + + // deletes a zcap policy + app.delete( + routes.policy, + middleware.authorizeZcapPolicyRequest(), + asyncHandler(async (/*req, res*/) => { + // FIXME: implement + throw new Error('Not implemented'); + })); + + // refresh a zcap + app.options(routes.refresh, cors()); + app.post( + routes.refresh, + validate({bodySchema: schemas.refreshZcap}), + middleware.authorizeZcapInvocation({ + async getExpectedValues({req}) { + const {profileId} = req.params; + const {baseUri} = bedrock.config.server; + const profilePath = `${baseUri}/${basePath}`; + return { + host: bedrock.config.server.host, + rootInvocationTarget: + `${profilePath}/${encodeURIComponent(profileId)}/zcaps/refresh` + }; + }, + async getRootController({req}) { + return req.param.profileId; + } + }), + asyncHandler(async (/*req, res*/) => { + // FIXME: + throw new Error('Not implemented'); + })); +}); From b3ce3801ab06ff4be52b8f9c0b7c6be603534340 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Tue, 30 Sep 2025 11:59:57 -0400 Subject: [PATCH 03/33] Simplify zcap object model for zcap refresh/policy. --- lib/middleware.js | 16 ++++++---------- lib/zcaps.js | 44 ++++++++++++++++++++++---------------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index 8bd4a0a..9f8702a 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -16,26 +16,22 @@ import { const {util: {BedrockError}} = bedrock; const {helpers: {inspectCapabilityChain}} = brZCapStorage; -// creates middleware for authorizing a zcap policy request -export function authorizeZcapPolicyRequest({ - expectedAction, root = false -} = {}) { +// creates middleware for authorizing a profile zcap request +export function authorizeProfileZcapRequest({expectedAction} = {}) { const cfg = bedrock.config['profile-http']; const {basePath} = cfg.routes; const {baseUri} = bedrock.config.server; - const profilePath = `${baseUri}/${basePath}`; + const profilesPath = `${baseUri}/${basePath}`; return authorizeZcapInvocation({ async getExpectedValues({req}) { - const {profileId, policyId} = req.params; - const policyRoot = - `${profilePath}/${encodeURIComponent(profileId)}/zcaps/policies`; + const {profileId} = req.params; return { // allow expected action override action: expectedAction, host: bedrock.config.server.host, - rootInvocationTarget: root ? - policyRoot : `${policyRoot}/${encodeURIComponent(policyId)}` + rootInvocationTarget: + `${profilesPath}/${encodeURIComponent(profileId)}` }; }, async getRootController({req}) { diff --git a/lib/zcaps.js b/lib/zcaps.js index fb1ada0..c685ed0 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -16,7 +16,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { policies: `${zcapsPath}/policies`, refresh: `${zcapsPath}/refresh` }; - routes.policy = `${routes.policies}/:policyId`; + // full zcap policy for a particular delegate + routes.policy = `${routes.policies}/:delegateId`; + // viewable zcap policy of a delegate when they invoke a refresh zcap + routes.viewablePolicy = `${routes.refresh}/policy`; // base URL for server //const {baseUri} = bedrock.config.server; @@ -32,7 +35,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // FIXME: make wider than "zcap policy" as "authorization policy"? validate({bodySchema: schemas.createZcapPolicyBody}), validate({bodySchema: schemas.createZcapPolicy}), - middleware.authorizeZcapPolicyRequest({root: true}), + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { // FIXME: create zcap/authz policy throw new Error('Not implemented'); @@ -54,7 +57,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { routes.policies, cors(), validate({querySchema: schemas.getZcapPoliciesQuery}), - middleware.authorizeZcapPolicyRequest(), + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { // FIXME: implement throw new Error('Not implemented'); @@ -77,7 +80,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { routes.policy, cors(), validate({bodySchema: schemas.updateZcapPolicyBody}), - middleware.authorizeZcapPolicyRequest(), + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { // FIXME: implement throw new Error('Not implemented'); @@ -95,7 +98,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.get( routes.policy, cors(), - middleware.authorizeZcapPolicyRequest(), + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { // FIXME: implement throw new Error('Not implemented'); @@ -104,7 +107,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // deletes a zcap policy app.delete( routes.policy, - middleware.authorizeZcapPolicyRequest(), + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { // FIXME: implement throw new Error('Not implemented'); @@ -115,23 +118,20 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.post( routes.refresh, validate({bodySchema: schemas.refreshZcap}), - middleware.authorizeZcapInvocation({ - async getExpectedValues({req}) { - const {profileId} = req.params; - const {baseUri} = bedrock.config.server; - const profilePath = `${baseUri}/${basePath}`; - return { - host: bedrock.config.server.host, - rootInvocationTarget: - `${profilePath}/${encodeURIComponent(profileId)}/zcaps/refresh` - }; - }, - async getRootController({req}) { - return req.param.profileId; - } - }), + middleware.authorizeProfileZcapRequest(), + asyncHandler(async (/*req, res*/) => { + // FIXME: implement + throw new Error('Not implemented'); + })); + + // get only the details of the refresh policy that a delegate can see + app.options(routes.viewablePolicy, cors()); + app.post( + routes.viewablePolicy, + middleware.authorizeProfileZcapRequest(), asyncHandler(async (/*req, res*/) => { - // FIXME: + // FIXME: implement; use `controller` of invoked zcap to determine + // `delegateId` to look up policy details throw new Error('Not implemented'); })); }); From 6e019449cfccaefaaaf88abfb641414c7d7c180a Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 5 Oct 2025 16:56:13 -0400 Subject: [PATCH 04/33] Add more refresh zcap infrastructure/skeleton code. --- lib/middleware.js | 194 +++++++++++++++++++++++++++++--- lib/zcaps.js | 64 ++++++++++- package.json | 4 +- schemas/bedrock-profile-http.js | 117 +++++++++++++++++-- 4 files changed, 350 insertions(+), 29 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index 9f8702a..060909a 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -5,16 +5,23 @@ import * as bedrock from '@bedrock/core'; import * as brZCapStorage from '@bedrock/zcap-storage'; import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; import { - authorizeZcapInvocation as _authorizeZcapInvocation, - authorizeZcapRevocation as _authorizeZcapRevocation + CapabilityDelegation, + createRootCapability, + constants as zcapConstants +} from '@digitalbazaar/zcap'; +import { + authorizeZcapInvocation as _authorizeZcapInvocation } from '@digitalbazaar/ezcap-express'; +import {asyncHandler} from '@bedrock/express'; import {documentLoader} from '../documentLoader.js'; import { Ed25519Signature2020 } from '@digitalbazaar/ed25519-signature-2020'; +import jsigs from 'jsonld-signatures'; const {util: {BedrockError}} = bedrock; const {helpers: {inspectCapabilityChain}} = brZCapStorage; +const {ZCAP_ROOT_PREFIX} = zcapConstants; // creates middleware for authorizing a profile zcap request export function authorizeProfileZcapRequest({expectedAction} = {}) { @@ -57,20 +64,75 @@ export function authorizeZcapInvocation({ }); } -// FIXME: remove if not used -// creates middleware for revocation of zcaps for service objects -export function authorizeZcapRevocation() { - return _authorizeZcapRevocation({ - documentLoader, - expectedHost: bedrock.config.server.host, - async getRootController({req}) { - // this will always be present based on where this middleware is used - return req.serviceObject.config.controller; - }, - getVerifier, - inspectCapabilityChain, - onError, - suiteFactory +// creates a middleware to verify a refreshable zcap's delegation chain +export function verifyRefreshableZcapDelegation() { + return asyncHandler(async function verifyRefreshZcap(req, res, next) { + const {body: capability} = req; + + // these params are always present where this middleware is present + const {profileId, delegateId} = req.params; + + // confirm the zcap controller (delegate) matches the route `delegateId` + if(capability.controller !== delegateId) { + throw new BedrockError( + `The given capability's controller does not match the refresh ` + + `endpoint; the capability controller must be "${delegateId}".`, { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + + // verify CapabilityDelegation + let delegator; + const capture = {}; + const chainControllers = []; + try { + const results = await _verifyDelegation({ + req, + capability, + documentLoader: _createRootCapabilityLoader({ + documentLoader, + getRootController() { + return profileId; + }, + req + }), + inspectCapabilityChain: _captureChainControllers({ + inspectCapabilityChain, + chainControllers, + capture + }), + suiteFactory + }); + ({delegator} = results[0].purposeResult); + delegator = delegator.id || delegator; + } catch(e) { + const error = new Error('The provided capability delegation is invalid.'); + error.name = 'DataError'; + error.cause = e; + error.httpStatusCode = 400; + return _handleError({res, error, onError}); + } + + // confirm the zcap was delegated by the profile + if(delegator !== profileId) { + throw new BedrockError( + `The given capability was not delegated by "${profileId}".`, { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + + const {capabilityChain} = capture; + + // expose middleware results for reuse + req.verifyRefreshableZcapDelegation = { + delegator, capabilityChain, chainControllers, capability + }; + + // proceed to next middleware on next tick to prevent subsequent + // middleware from potentially throwing here + process.nextTick(next); }); } @@ -110,3 +172,103 @@ function onError({error}) { async function suiteFactory() { return new Ed25519Signature2020(); } + +function _captureChainControllers({ + inspectCapabilityChain, chainControllers, capture +}) { + return async function _inspectCapabilityChain(chainDetails) { + // collect every controller in the chain + const {capabilityChain} = chainDetails; + capture.capabilityChain = capabilityChain; + for(const capability of capabilityChain.values()) { + chainControllers.push(..._getCapabilityControllers({capability})); + } + return inspectCapabilityChain(chainDetails); + }; +} + +function _createRootCapabilityLoader({ + documentLoader, getRootController, req +}) { + return async function rootCapabilityLoader(...args) { + const [url] = args; + if(url.startsWith(ZCAP_ROOT_PREFIX)) { + const document = await _getRootCapability({ + getRootController, req, rootCapabilityId: url + }); + return { + contextUrl: null, + documentUrl: url, + document + }; + } + return documentLoader(...args); + }; +} + +function _getCapabilityControllers({capability}) { + const {controller} = capability; + return Array.isArray(controller) ? controller : [controller]; +} + +async function _getRootCapability({ + getRootController, req, rootCapabilityId +}) { + const rootInvocationTarget = decodeURIComponent( + rootCapabilityId.slice(ZCAP_ROOT_PREFIX.length)); + const controller = await getRootController({ + req, rootCapabilityId, rootInvocationTarget + }); + return createRootCapability({ + controller, invocationTarget: rootInvocationTarget + }); +} + +function _handleError({res, error, onError, throwError = true}) { + if(error.httpStatusCode) { + res.status(error.httpStatusCode); + } else if(res.status < 400) { + res.status(500); + } + if(onError) { + return onError({error}); + } + if(throwError) { + throw error; + } +} + +async function _verifyDelegation({ + req, capability, documentLoader, inspectCapabilityChain, suiteFactory +}) { + // the expected root capability must be the parent capability for a + // refreshable zcap; if this is somehow an attacker provided value, + // then the capability delegation proof will either not verify or the + // newly refreshed zcap will fail when invoked at its target if the root + // capability controller is invalid + const expectedRootCapability = capability.parentCapability; + // FIXME: compute `date` as before expiration in zcap and pass it + // const date = new Date((new Date(capability.expires)).getTime() - 1); + const {verified, error, results} = await jsigs.verify(capability, { + documentLoader, + purpose: new CapabilityDelegation({ + /* Note: Path-based target attenuation must always be true to support the + convention described above. This is not a security problem even if the + to-be-refreshed zcap cannot be invoked (because the invocation endpoint + doesn't allow such attenuation). It just means zcaps that can be + delegated with attenuation rules that aren't supported by the invocation + endpoint can still be refreshed. */ + allowTargetAttenuation: true, + // FIXME: pass `date` as well + // date, + expectedRootCapability, + inspectCapabilityChain, + suite: await suiteFactory({req}) + }), + suite: await suiteFactory({req}) + }); + if(!verified) { + throw error; + } + return results; +} diff --git a/lib/zcaps.js b/lib/zcaps.js index c685ed0..e3bac6b 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -8,6 +8,8 @@ import {asyncHandler} from '@bedrock/express'; import cors from 'cors'; import {createValidateMiddleware as validate} from '@bedrock/validation'; +const {util: {BedrockError}} = bedrock; + bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = bedrock.config['profile-http']; const {basePath} = cfg.routes; @@ -117,11 +119,67 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.options(routes.refresh, cors()); app.post( routes.refresh, - validate({bodySchema: schemas.refreshZcap}), + validate({bodySchema: schemas.refreshableZcap}), middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement + middleware.verifyRefreshableZcapDelegation(), + asyncHandler(async (req/*, res*/) => { + const {profileId, delegateId} = req.params; + + // FIXME: first check cache for already refreshed zcap + /* + const {capability} = req.body; + const cacheKey = getRefreshableZcapCacheKey({ + profileId, delegatorId, capability + }); + // FIXME: details: + /*const cacheKey = JSON.stringify({ + profileId, delegatorId, hash: sha256(jcs(capability)) + });*/ + + // FIXME: move code below into _getUncachedRefreshedZcap() + + // get the policy for the profile + controller (delegate) + //let policy; + try { + //policy = await brZcapStorage.policies.get({profileId, delegateId}); + throw new Error('Not implemented'); + } catch(e) { + // no matching policy, so refresh is denied + if(e.name === 'NotFoundError') { + throw new BedrockError( + `No refresh policy specified for profile "${profileId}" and ` + + `delegate "${delegateId}".`, { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + throw e; + } + throw new Error('Not implemented'); + + /* + // FIXME: get the profile agent from the policy + // FIXME: perhaps allow "any root profile agent" to be used, requiring + // new methods to from `@bedrock/profile` to expose this feature + const {profileAgentId} = policy; + const profileAgentRecord = await profileAgents.get({id: profileAgentId}); + + const profileSigner = await profileAgents.getProfileSigner({ + profileAgentRecord + }); + + // FIXME: compute new expires from policy + const now = Date.now(); + const expires = ''; + + const {capability} = req.body; + const delegated = await profileAgents.refreshCapability({ + capability, profileSigner, now, expires + }); + + res.json({zcap: delegated}); + */ })); // get only the details of the refresh policy that a delegate can see diff --git a/package.json b/package.json index 06525b8..7ac94d9 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "dependencies": { "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/ezcap": "^4.1.0", - "@digitalbazaar/http-client": "^4.2.0" + "@digitalbazaar/http-client": "^4.2.0", + "@digitalbazaar/zcap": "^9.0.1", + "jsonld-signatures": "^11.5.0" }, "peerDependencies": { "@bedrock/app-identity": "^4.0.0", diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index 6e350c3..b458ce5 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -11,6 +11,18 @@ const profile = { type: 'string' }; +const controller = { + title: 'controller', + type: 'string', + maxLength: 4096 +}; + +const id = { + title: 'id', + type: 'string', + maxLength: 4096 +}; + // this should match query objects with an account in them const accountQuery = { title: 'Account Query', @@ -70,10 +82,7 @@ const zcap = { items: {type: 'string'} }] }, - id: { - title: 'id', - type: 'string' - }, + id, allowedAction: { anyOf: [{ type: 'string' @@ -83,10 +92,7 @@ const zcap = { items: {type: 'string'} }] }, - controller: { - title: 'controller', - type: 'string' - }, + controller, invocationTarget: { title: 'Invocation Target', anyOf: [{ @@ -129,6 +135,98 @@ const zcap = { } }; +const delegatedZcap = { + title: 'Delegated ZCAP', + type: 'object', + additionalProperties: false, + required: [ + '@context', 'controller', 'expires', 'id', 'invocationTarget', + 'parentCapability', 'proof' + ], + properties: { + controller, + id, + allowedAction: { + anyOf: [{ + type: 'string' + }, { + type: 'array', + minItems: 1, + items: {type: 'string'} + }] + }, + expires: { + title: 'W3C Date/Time', + description: 'A W3C-formatted date and time combination.', + type: 'string', + pattern: '^[1-9][0-9]{3}-(0[1-9]|1[0-2])-([0-2][0-9]|3[0-1])' + + 'T([0-1][0-9]|2[0-3]):([0-5][0-9]):(([0-5][0-9])|60)(\\.[0-9]+)?' + + '(Z|((\\+|-)([0-1][0-9]|2[0-3]):([0-5][0-9])))?$' + }, + '@context': { + title: '@context', + anyOf: [{ + type: 'string' + }, { + type: 'array', + minItems: 1, + items: {type: 'string'} + }] + }, + invocationTarget: { + title: 'Invocation Target', + type: 'string' + }, + parentCapability: { + title: 'Parent Capability', + type: 'string' + }, + proof: { + title: 'Proof', + type: 'object', + additionalProperties: false, + required: [ + 'verificationMethod', 'type', 'created', 'proofPurpose', + 'capabilityChain', 'proofValue' + ], + properties: { + verificationMethod: { + title: 'verificationMethod', + type: 'string' + }, + type: { + title: 'type', + type: 'string' + }, + created: { + title: 'created', + type: 'string' + }, + proofPurpose: { + title: 'proofPurpose', + type: 'string' + }, + capabilityChain: { + title: 'capabilityChain', + type: 'array', + minItems: 1, + items: { + type: ['string', 'object'] + } + }, + proofValue: { + title: 'proofValue', + type: 'string' + }, + } + } + } +}; + +// refreshable zcaps have a capability chain length of 1 +const refreshableZcap = structuredClone(delegatedZcap); +refreshableZcap.properties.proof.properties.capabilityChain.maxItems = 1; + const zcaps = { title: 'zcaps', type: 'object', @@ -217,5 +315,6 @@ export { delegateCapability, createInteraction, getInteractionQuery, - zcaps + zcaps, + refreshableZcap }; From 897c2965ce33c5374458f216369ab4593a2164da Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 5 Oct 2025 18:26:03 -0400 Subject: [PATCH 05/33] Add refreshed zcap cache + zcap refresh implementation. --- lib/config.js | 11 +++++++ lib/refreshedZcapCache.js | 66 +++++++++++++++++++++++++++++++++++++++ lib/zcaps.js | 63 ++++--------------------------------- package.json | 5 ++- test/package.json | 2 +- 5 files changed, 88 insertions(+), 59 deletions(-) create mode 100644 lib/refreshedZcapCache.js diff --git a/lib/config.js b/lib/config.js index 22289f2..bd2d3b8 100644 --- a/lib/config.js +++ b/lib/config.js @@ -14,6 +14,17 @@ cfg.routes = { basePath }; +cfg.caches = { + // refreshed zcap cache + refreshedZcap: { + // zcaps are not typically large objects (hundreds of bytes); but this + // cache isn't expected to be used frequently either, just for zcap refresh + // retries or misbehaving zcap refresh clients + max: 100, + ttl: 5 * 60 * 1000 + } +}; + // profile agent zcap config cfg.zcap = { // default: 24 hour TTL for delegated zcaps (when a profile agent's zcaps diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js new file mode 100644 index 0000000..2251574 --- /dev/null +++ b/lib/refreshedZcapCache.js @@ -0,0 +1,66 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as brZcapStorage from '@bedrock/zcap-storage'; +import canonicalize from 'canonicalize'; +import {createHash} from 'node:crypto'; +import {LruCache} from '@digitalbazaar/lru-memoize'; +import {profileAgents} from '@bedrock/profile'; + +const {util: {BedrockError}} = bedrock; + +let REFRESHED_ZCAP_CACHE; + +bedrock.events.on('bedrock.init', async () => { + const cfg = bedrock.config['profile-http']; + REFRESHED_ZCAP_CACHE = new LruCache(cfg.caches.refreshedZcap); +}); + +export async function getRefreshedZcap({profileId, delegateId, capability}) { + const key = _createCacheKey({profileId, delegateId, capability}); + const fn = () => _getUncached({profileId, delegateId, capability}); + return REFRESHED_ZCAP_CACHE.memoize({key, fn}); +} + +function _createCacheKey({profileId, delegateId, capability}) { + const json = {profileId, delegateId, canonicalZcap: canonicalize(capability)}; + const hash = createHash('sha256').update(json, 'utf8').digest('base64url'); + return hash; +} + +async function _getUncached({profileId, delegateId, capability}) { + // get the policy for the profile + controller (delegate) + let policy; + try { + policy = await brZcapStorage.policies.get({profileId, delegateId}); + } catch(e) { + // no matching policy, so refresh is denied + if(e.name === 'NotFoundError') { + throw new BedrockError( + `No refresh policy specified for profile "${profileId}" and ` + + `delegate "${delegateId}".`, { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + throw e; + } + + // get the profile signer associated with the policy + // FIXME: perhaps allow "any root profile agent" to be used, requiring + // new methods to from `@bedrock/profile` to expose this feature + const {profileAgentId} = policy; + const profileAgentRecord = await profileAgents.get({id: profileAgentId}); + const profileSigner = await profileAgents.getProfileSigner({ + profileAgentRecord + }); + + // FIXME: compute new expires from policy + const now = Date.now(); + const expires = now + 1000; + + return profileAgents.refreshCapability({ + capability, profileSigner, now, expires + }); +} diff --git a/lib/zcaps.js b/lib/zcaps.js index e3bac6b..13f7e05 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -6,6 +6,8 @@ import * as middleware from './middleware.js'; import * as schemas from '../schemas/bedrock-profile-http.js'; import {asyncHandler} from '@bedrock/express'; import cors from 'cors'; +import {createHash} from 'node:crypto'; +import {getRefreshedZcap} from './refreshedZcapCache.js'; import {createValidateMiddleware as validate} from '@bedrock/validation'; const {util: {BedrockError}} = bedrock; @@ -122,64 +124,11 @@ bedrock.events.on('bedrock-express.configure.routes', app => { validate({bodySchema: schemas.refreshableZcap}), middleware.authorizeProfileZcapRequest(), middleware.verifyRefreshableZcapDelegation(), - asyncHandler(async (req/*, res*/) => { + asyncHandler(async (req, res) => { const {profileId, delegateId} = req.params; - - // FIXME: first check cache for already refreshed zcap - /* - const {capability} = req.body; - const cacheKey = getRefreshableZcapCacheKey({ - profileId, delegatorId, capability - }); - // FIXME: details: - /*const cacheKey = JSON.stringify({ - profileId, delegatorId, hash: sha256(jcs(capability)) - });*/ - - // FIXME: move code below into _getUncachedRefreshedZcap() - - // get the policy for the profile + controller (delegate) - //let policy; - try { - //policy = await brZcapStorage.policies.get({profileId, delegateId}); - throw new Error('Not implemented'); - } catch(e) { - // no matching policy, so refresh is denied - if(e.name === 'NotFoundError') { - throw new BedrockError( - `No refresh policy specified for profile "${profileId}" and ` + - `delegate "${delegateId}".`, { - name: 'NotAllowedError', - details: {httpStatusCode: 403, public: true} - }); - } - throw e; - } - - throw new Error('Not implemented'); - - /* - // FIXME: get the profile agent from the policy - // FIXME: perhaps allow "any root profile agent" to be used, requiring - // new methods to from `@bedrock/profile` to expose this feature - const {profileAgentId} = policy; - const profileAgentRecord = await profileAgents.get({id: profileAgentId}); - - const profileSigner = await profileAgents.getProfileSigner({ - profileAgentRecord - }); - - // FIXME: compute new expires from policy - const now = Date.now(); - const expires = ''; - - const {capability} = req.body; - const delegated = await profileAgents.refreshCapability({ - capability, profileSigner, now, expires - }); - - res.json({zcap: delegated}); - */ + const {body: capability} = req; + const zcap = await getRefreshedZcap({profileId, delegateId, capability}); + res.json({zcap}); })); // get only the details of the refresh policy that a delegate can see diff --git a/package.json b/package.json index 7ac94d9..7fb74e6 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/ezcap": "^4.1.0", "@digitalbazaar/http-client": "^4.2.0", + "@digitalbazaar/lru-memoize": "^4.0.0", "@digitalbazaar/zcap": "^9.0.1", + "canonicalize": "^2.1.0", "jsonld-signatures": "^11.5.0" }, "peerDependencies": { @@ -43,7 +45,8 @@ "@bedrock/notify": "^1.1.0", "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.0.0", - "@bedrock/validation": "^7.1.1" + "@bedrock/validation": "^7.1.1", + "@bedrock/zcap-storage": "^9.3.0" }, "directories": { "lib": "./lib" diff --git a/test/package.json b/test/package.json index d110768..8ea609f 100644 --- a/test/package.json +++ b/test/package.json @@ -43,7 +43,7 @@ "@bedrock/vc-delivery": "^7.7.1", "@bedrock/vc-verifier": "^22.1.0", "@bedrock/veres-one-context": "^16.0.0", - "@bedrock/zcap-storage": "^9.0.0", + "@bedrock/zcap-storage": "^9.3.0", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", From dc134716f8070623d6f0df0299a381d10d01e62c Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 5 Oct 2025 18:26:38 -0400 Subject: [PATCH 06/33] Fix linting errors. --- lib/zcaps.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index 13f7e05..1287fbe 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -6,11 +6,11 @@ import * as middleware from './middleware.js'; import * as schemas from '../schemas/bedrock-profile-http.js'; import {asyncHandler} from '@bedrock/express'; import cors from 'cors'; -import {createHash} from 'node:crypto'; import {getRefreshedZcap} from './refreshedZcapCache.js'; import {createValidateMiddleware as validate} from '@bedrock/validation'; -const {util: {BedrockError}} = bedrock; +// FIXME: use below +//const {util: {BedrockError}} = bedrock; bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = bedrock.config['profile-http']; From 3ac0f70ceaad567e2a78461e5b09c976d333d77e Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 5 Oct 2025 18:44:57 -0400 Subject: [PATCH 07/33] Implement zcap refresh policy fetch endpoint. --- lib/middleware.js | 16 +++++++++------- lib/refreshedZcapCache.js | 30 +++++++++++++++++------------- lib/zcaps.js | 18 +++++++++++------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index 060909a..b91867a 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -69,19 +69,21 @@ export function verifyRefreshableZcapDelegation() { return asyncHandler(async function verifyRefreshZcap(req, res, next) { const {body: capability} = req; - // these params are always present where this middleware is present - const {profileId, delegateId} = req.params; - - // confirm the zcap controller (delegate) matches the route `delegateId` - if(capability.controller !== delegateId) { + // confirm the invoked zcap controller matches the controller of the zcap + // that is to be refreshed + if(capability.controller !== req.zcap.controller) { throw new BedrockError( - `The given capability's controller does not match the refresh ` + - `endpoint; the capability controller must be "${delegateId}".`, { + `The controller "${capability.controller}" of the capability to be ` + + `refreshed must equal the invoked capability's controller ` + + `${req.zcap.controller}.`, { name: 'NotAllowedError', details: {httpStatusCode: 403, public: true} }); } + // `profileId` param always present where this middleware is used + const {profileId} = req.params; + // verify CapabilityDelegation let delegator; const capture = {}; diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js index 2251574..cc3b4c4 100644 --- a/lib/refreshedZcapCache.js +++ b/lib/refreshedZcapCache.js @@ -17,23 +17,15 @@ bedrock.events.on('bedrock.init', async () => { REFRESHED_ZCAP_CACHE = new LruCache(cfg.caches.refreshedZcap); }); -export async function getRefreshedZcap({profileId, delegateId, capability}) { - const key = _createCacheKey({profileId, delegateId, capability}); - const fn = () => _getUncached({profileId, delegateId, capability}); +export async function getRefreshedZcap({profileId, capability}) { + const key = _createCacheKey({profileId, capability}); + const fn = () => _getUncached({profileId, capability}); return REFRESHED_ZCAP_CACHE.memoize({key, fn}); } -function _createCacheKey({profileId, delegateId, capability}) { - const json = {profileId, delegateId, canonicalZcap: canonicalize(capability)}; - const hash = createHash('sha256').update(json, 'utf8').digest('base64url'); - return hash; -} - -async function _getUncached({profileId, delegateId, capability}) { - // get the policy for the profile + controller (delegate) - let policy; +export async function getRefreshZcapPolicy({profileId, delegateId}) { try { - policy = await brZcapStorage.policies.get({profileId, delegateId}); + return brZcapStorage.policies.get({profileId, delegateId}); } catch(e) { // no matching policy, so refresh is denied if(e.name === 'NotFoundError') { @@ -46,6 +38,18 @@ async function _getUncached({profileId, delegateId, capability}) { } throw e; } +} + +function _createCacheKey({profileId, capability}) { + const json = {profileId, canonicalZcap: canonicalize(capability)}; + const hash = createHash('sha256').update(json, 'utf8').digest('base64url'); + return hash; +} + +async function _getUncached({profileId, capability}) { + // get the policy for the profile + controller (delegate) + const {controller: delegateId} = capability; + const policy = await getRefreshZcapPolicy({profileId, delegateId}); // get the profile signer associated with the policy // FIXME: perhaps allow "any root profile agent" to be used, requiring diff --git a/lib/zcaps.js b/lib/zcaps.js index 1287fbe..9b254c6 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -4,9 +4,9 @@ import * as bedrock from '@bedrock/core'; import * as middleware from './middleware.js'; import * as schemas from '../schemas/bedrock-profile-http.js'; +import {getRefreshedZcap, getRefreshZcapPolicy} from './refreshedZcapCache.js'; import {asyncHandler} from '@bedrock/express'; import cors from 'cors'; -import {getRefreshedZcap} from './refreshedZcapCache.js'; import {createValidateMiddleware as validate} from '@bedrock/validation'; // FIXME: use below @@ -125,9 +125,9 @@ bedrock.events.on('bedrock-express.configure.routes', app => { middleware.authorizeProfileZcapRequest(), middleware.verifyRefreshableZcapDelegation(), asyncHandler(async (req, res) => { - const {profileId, delegateId} = req.params; + const {profileId} = req.params; const {body: capability} = req; - const zcap = await getRefreshedZcap({profileId, delegateId, capability}); + const zcap = await getRefreshedZcap({profileId, capability}); res.json({zcap}); })); @@ -136,9 +136,13 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.post( routes.viewablePolicy, middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement; use `controller` of invoked zcap to determine - // `delegateId` to look up policy details - throw new Error('Not implemented'); + asyncHandler(async (req, res) => { + // use `controller` of invoked zcap to determine `delegateId` to look up + // policy details + const {profileId} = req.params; + const {controller: delegateId} = req.zcap; + const policy = await getRefreshZcapPolicy({profileId, delegateId}); + // FIXME: attenuate policy + res.json({policy}); })); }); From d5b15bc46491c19c32bee3f3f54707c217bfd430 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 16 Oct 2025 17:43:21 -0400 Subject: [PATCH 08/33] Update zcap policy data model/use. --- lib/refreshedZcapCache.js | 8 +++++--- lib/zcaps.js | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js index cc3b4c4..a6d40f1 100644 --- a/lib/refreshedZcapCache.js +++ b/lib/refreshedZcapCache.js @@ -25,7 +25,9 @@ export async function getRefreshedZcap({profileId, capability}) { export async function getRefreshZcapPolicy({profileId, delegateId}) { try { - return brZcapStorage.policies.get({profileId, delegateId}); + return brZcapStorage.policies.get({ + controller: profileId, delegate: delegateId + }); } catch(e) { // no matching policy, so refresh is denied if(e.name === 'NotFoundError') { @@ -53,8 +55,8 @@ async function _getUncached({profileId, capability}) { // get the profile signer associated with the policy // FIXME: perhaps allow "any root profile agent" to be used, requiring - // new methods to from `@bedrock/profile` to expose this feature - const {profileAgentId} = policy; + // new methods from `@bedrock/profile` to expose this feature + const {refresh: {profileAgentId}} = policy; const profileAgentRecord = await profileAgents.get({id: profileAgentId}); const profileSigner = await profileAgents.getProfileSigner({ profileAgentRecord diff --git a/lib/zcaps.js b/lib/zcaps.js index 9b254c6..0db61ca 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -142,7 +142,12 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const {profileId} = req.params; const {controller: delegateId} = req.zcap; const policy = await getRefreshZcapPolicy({profileId, delegateId}); - // FIXME: attenuate policy - res.json({policy}); + // return only `refresh.constraints` to client + const viewablePolicy = { + refresh: { + constraints: policy.refresh.constraints + } + }; + res.json({policy: viewablePolicy}); })); }); From 8b8bf88c15b7391e833e00e7d0aa827d18d2a0a2 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 17 Oct 2025 12:50:45 -0400 Subject: [PATCH 09/33] Add configurable hard limits on profile agents and zcap policies. --- lib/config.js | 10 ++++++++++ lib/profiles.js | 18 +++++++++++++++++- lib/zcaps.js | 25 +++++++++++++++++-------- package.json | 2 +- test/package.json | 2 +- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/lib/config.js b/lib/config.js index bd2d3b8..8a17754 100644 --- a/lib/config.js +++ b/lib/config.js @@ -89,3 +89,13 @@ cfg.interactions = { */ types: {} }; + +// optional default limits on number of profile agents, zcap policies etc. +cfg.limits = { + // limit per account; -1 is unlimited; default to -1 for backwards compat; + // future version may set another default limit, e.g., 1000 + profileAgents: -1, + // limit per profile; -1 is unlimited; default to -1 for backwards compat; + // future version may set another default limit, e.g., 1000 + zcapPolicies: -1 +}; diff --git a/lib/profiles.js b/lib/profiles.js index 886acbf..66eb994 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -9,10 +9,10 @@ import { WEBKMS_METER_CREATION_ZCAP, ZCAP_CLIENT } from './zcapClient.js'; +import {profileAgents, profiles} from '@bedrock/profile'; import {asyncHandler} from '@bedrock/express'; import {createMeter} from './helpers.js'; import {ensureAuthenticated} from '@bedrock/passport'; -import {profiles} from '@bedrock/profile'; import {createValidateMiddleware as validate} from '@bedrock/validation'; const {util: {BedrockError}} = bedrock; @@ -47,6 +47,22 @@ bedrock.events.on('bedrock-express.configure.routes', app => { {httpStatusCode: 403, public: true}); } + // apply any limits checks + if(cfg.limits?.profileAgents !== -1) { + const {count} = await profileAgents.count({accountId}); + if(count >= cfg.limits?.profileAgents) { + throw new BedrockError( + 'Permission denied; Maximum profile agents for account ' + + `"${cfg.limits.profileAgents}" already reached.`, { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } + } + // create a new meter, edv options, and keystore options const [{id: edvMeterId}, {id: kmsMeterId}] = await Promise.all([ createMeter({ diff --git a/lib/zcaps.js b/lib/zcaps.js index 0db61ca..45f97f7 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -43,16 +43,25 @@ bedrock.events.on('bedrock-express.configure.routes', app => { asyncHandler(async (/*req, res*/) => { // FIXME: create zcap/authz policy throw new Error('Not implemented'); - // FIXME: consider meter usage for storing policies or some other limit + /* - const {body: {meterId}, meterCheck: {hasAvailable}} = req; - if(!hasAvailable) { - // insufficient remaining storage - throw new BedrockError('Permission denied.', 'NotAllowedError', { - httpStatusCode: 403, - public: true, - }); + // apply any limits checks + if(cfg.limits?.zcapPolicies !== -1) { + const {count} = await brZcapStorage.policies.count({accountId}); + if(count >= cfg.limits?.zcapPolicies) { + throw new BedrockError( + 'Permission denied; Maximum policies per profile ' + + `"${cfg.limits.zcapPolicies}" already reached.`, { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } } + + // FIXME: implement */ })); diff --git a/package.json b/package.json index 7fb74e6..594e066 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@bedrock/https-agent": "^4.1.0", "@bedrock/notify": "^1.1.0", "@bedrock/passport": "^12.0.0", - "@bedrock/profile": "^26.0.0", + "@bedrock/profile": "^26.3.0", "@bedrock/validation": "^7.1.1", "@bedrock/zcap-storage": "^9.3.0" }, diff --git a/test/package.json b/test/package.json index 8ea609f..809d57a 100644 --- a/test/package.json +++ b/test/package.json @@ -31,7 +31,7 @@ "@bedrock/oauth2-verifier": "^2.4.0", "@bedrock/package-manager": "^3.0.0", "@bedrock/passport": "^12.0.0", - "@bedrock/profile": "^26.0.0", + "@bedrock/profile": "^26.3.0", "@bedrock/profile-http": "file:..", "@bedrock/security-context": "^9.0.0", "@bedrock/server": "^5.1.0", From d151093ed65200c41babeb580ad4b29182b69b33 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 17 Oct 2025 16:22:09 -0400 Subject: [PATCH 10/33] Use `profileAgents.getRootAgents()` for zcap refresh. - Apply zcap policy refresh constraints. --- lib/refreshedZcapCache.js | 35 ++++++++++++++++++++++++++++++----- lib/zcaps.js | 17 +++++++---------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js index a6d40f1..b5ebfd0 100644 --- a/lib/refreshedZcapCache.js +++ b/lib/refreshedZcapCache.js @@ -53,11 +53,36 @@ async function _getUncached({profileId, capability}) { const {controller: delegateId} = capability; const policy = await getRefreshZcapPolicy({profileId, delegateId}); - // get the profile signer associated with the policy - // FIXME: perhaps allow "any root profile agent" to be used, requiring - // new methods from `@bedrock/profile` to expose this feature - const {refresh: {profileAgentId}} = policy; - const profileAgentRecord = await profileAgents.get({id: profileAgentId}); + // check policy constraints + if(typeof policy.refresh?.constraints?.maxTtlBeforeRefresh === 'number') { + // get max clock skew in milliseconds + const {authorizeZcapInvocationOptions} = bedrock.config['profile-http']; + const maxClockSkew = authorizeZcapInvocationOptions.maxClockSkew * 1000; + + // compute earliest refresh time + const now = Date.now(); + const {maxTtlBeforeRefresh} = policy.refresh.constraints; + const expiryTime = Date.parse(capability.expires).getTime(); + const refreshTime = expiryTime - maxClockSkew - maxTtlBeforeRefresh; + + // apply refresh time constraint + if(now < refreshTime) { + throw new BedrockError( + 'Refresh policy constraint violation; too early to refresh.', { + name: 'ConstraintError', + details: { + refreshTime, + public: true, + httpStatusCode: 400 + } + }); + } + } + + // get profile signer associated with the policy; use any root profile agent + const profileAgentRecord = await profileAgents.getRootAgents({ + profileId, options: {limit: 1}, includeSecrets: true + }); const profileSigner = await profileAgents.getProfileSigner({ profileAgentRecord }); diff --git a/lib/zcaps.js b/lib/zcaps.js index 45f97f7..3350313 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -2,6 +2,7 @@ * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; +import * as brZcapStorage from '@bedrock/zcap-storage'; import * as middleware from './middleware.js'; import * as schemas from '../schemas/bedrock-profile-http.js'; import {getRefreshedZcap, getRefreshZcapPolicy} from './refreshedZcapCache.js'; @@ -9,8 +10,7 @@ import {asyncHandler} from '@bedrock/express'; import cors from 'cors'; import {createValidateMiddleware as validate} from '@bedrock/validation'; -// FIXME: use below -//const {util: {BedrockError}} = bedrock; +const {util: {BedrockError}} = bedrock; bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = bedrock.config['profile-http']; @@ -36,18 +36,15 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.post( routes.policies, cors(), - // FIXME: make wider than "zcap policy" as "authorization policy"? validate({bodySchema: schemas.createZcapPolicyBody}), validate({bodySchema: schemas.createZcapPolicy}), middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: create zcap/authz policy - throw new Error('Not implemented'); + asyncHandler(async (req, res) => { + const {profileId} = req.params; - /* // apply any limits checks if(cfg.limits?.zcapPolicies !== -1) { - const {count} = await brZcapStorage.policies.count({accountId}); + const {count} = await brZcapStorage.policies.count({profileId}); if(count >= cfg.limits?.zcapPolicies) { throw new BedrockError( 'Permission denied; Maximum policies per profile ' + @@ -61,8 +58,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => { } } - // FIXME: implement - */ + // FIXME: create zcap policy + res.json('Not implemented'); })); // get zcap policies by query From e2e4032f9211f84ec42e16b4ea3a285043200a41 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 17 Oct 2025 16:56:13 -0400 Subject: [PATCH 11/33] Set `expires` for refreshed zcap based on policy or default. --- lib/refreshedZcapCache.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js index b5ebfd0..c3941a3 100644 --- a/lib/refreshedZcapCache.js +++ b/lib/refreshedZcapCache.js @@ -54,9 +54,9 @@ async function _getUncached({profileId, capability}) { const policy = await getRefreshZcapPolicy({profileId, delegateId}); // check policy constraints + const {authorizeZcapInvocationOptions} = bedrock.config['profile-http']; if(typeof policy.refresh?.constraints?.maxTtlBeforeRefresh === 'number') { // get max clock skew in milliseconds - const {authorizeZcapInvocationOptions} = bedrock.config['profile-http']; const maxClockSkew = authorizeZcapInvocationOptions.maxClockSkew * 1000; // compute earliest refresh time @@ -87,9 +87,10 @@ async function _getUncached({profileId, capability}) { profileAgentRecord }); - // FIXME: compute new expires from policy + // compute new `expires` from policy, defaulting to max delegation TTL const now = Date.now(); - const expires = now + 1000; + const expires = new Date(now + policy.refresh?.maxDelegationTtl ?? + authorizeZcapInvocationOptions.maxDelegationTtl); return profileAgents.refreshCapability({ capability, profileSigner, now, expires From 95fa185909fb4730abf6ec5d9fb5a7c882743951 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 17 Oct 2025 17:28:40 -0400 Subject: [PATCH 12/33] Use `@bedrock/zcap-storage@9.4`. --- package.json | 2 +- test/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 594e066..8dce92b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.3.0", "@bedrock/validation": "^7.1.1", - "@bedrock/zcap-storage": "^9.3.0" + "@bedrock/zcap-storage": "^9.4.0" }, "directories": { "lib": "./lib" diff --git a/test/package.json b/test/package.json index 809d57a..56b8079 100644 --- a/test/package.json +++ b/test/package.json @@ -43,7 +43,7 @@ "@bedrock/vc-delivery": "^7.7.1", "@bedrock/vc-verifier": "^22.1.0", "@bedrock/veres-one-context": "^16.0.0", - "@bedrock/zcap-storage": "^9.3.0", + "@bedrock/zcap-storage": "^9.4.0", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", From 839f2bf0ae7e93c3d71bc16e30be1d5e5e882890 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 17 Oct 2025 17:34:34 -0400 Subject: [PATCH 13/33] Update changelog. --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e1ca1..ce3bc3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # bedrock-profile-http ChangeLog +## 26.2.0 - 2025-10-dd + +### Added +- Add zcap refresh capability. Policies for zcaps can be created per profile + and delegate, enabling delegates to auto-refresh previously issued that + are compliant with their associated policy. New HTTP routes: + - `/profiles//zcaps/policies`: For creating new policies on + behalf of a controlling profile. + - `/profiles//zcaps/policies/`: For updating + and fetching existing policies on behalf of a controlling profile. + - `/profiles//zcaps/refresh`: For delegates to refresh their + zcaps according to the matching policy, if any. + - `/profiles//zcaps/refresh/policy`: For delegates to view any + elements exposed by the controller (profile) of the policy that applies + to the controller of the zcap invoked at this endpoint. + ## 26.1.0 - 2025-09-19 ### Added From 423306defac2a5670b4045330a1e2c057ce9d0b0 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 15 Nov 2025 15:00:58 -0500 Subject: [PATCH 14/33] Set default number of zcap policies to 1000. --- lib/config.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/config.js b/lib/config.js index 8a17754..2502e75 100644 --- a/lib/config.js +++ b/lib/config.js @@ -95,7 +95,6 @@ cfg.limits = { // limit per account; -1 is unlimited; default to -1 for backwards compat; // future version may set another default limit, e.g., 1000 profileAgents: -1, - // limit per profile; -1 is unlimited; default to -1 for backwards compat; - // future version may set another default limit, e.g., 1000 - zcapPolicies: -1 + // limit per profile; -1 is unlimited; default to 1000 + zcapPolicies: 1000 }; From 9e9fd2b41408e180016d1928f3558c21c935902e Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 15 Nov 2025 18:49:40 -0500 Subject: [PATCH 15/33] Implement get and delete zcap policy routes. --- lib/zcaps.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index 3350313..a33b2df 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -109,18 +109,22 @@ bedrock.events.on('bedrock-express.configure.routes', app => { routes.policy, cors(), middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement - throw new Error('Not implemented'); + asyncHandler(async (req, res) => { + const {profileId, delegateId} = req.params; + const policy = await getRefreshZcapPolicy({profileId, delegateId}); + res.json({policy}); })); // deletes a zcap policy app.delete( routes.policy, middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement - throw new Error('Not implemented'); + asyncHandler(async (req, res) => { + const {profileId, delegateId} = req.params; + const deleted = await brZcapStorage.policies.remove({ + controller: profileId, delegate: delegateId + }); + res.json({deleted}); })); // refresh a zcap From bc71ddeb6d005eccde1d2290ea10422cab4a3e41 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 15 Nov 2025 19:16:42 -0500 Subject: [PATCH 16/33] Include `req.host` in redirection URL. --- lib/interactions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/interactions.js b/lib/interactions.js index d79e578..2967344 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -114,7 +114,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // FIXME: check config for supported QR code route and use it // instead of hard-coded value if(req.accepts('html') || !req.accepts('json')) { - return res.redirect(`${req.originalUrl}/qr-code`); + return res.redirect(`${req.get('host')}/${req.originalUrl}/qr-code`); } try { const url = `${exchangeId}/protocols`; From da6358a0a5a666ad435bac62f8ee89715175a900 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 15 Nov 2025 19:17:43 -0500 Subject: [PATCH 17/33] Implement zcap policy insert and update. --- lib/zcaps.js | 54 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index a33b2df..c3d7dec 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -41,6 +41,17 @@ bedrock.events.on('bedrock-express.configure.routes', app => { middleware.authorizeProfileZcapRequest(), asyncHandler(async (req, res) => { const {profileId} = req.params; + const {policy} = req.body; + if(policy.controller !== profileId) { + throw new BedrockError( + 'Permission denied; policy controller does not match HTTP route.', { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } // apply any limits checks if(cfg.limits?.zcapPolicies !== -1) { @@ -58,8 +69,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { } } - // FIXME: create zcap policy - res.json('Not implemented'); + const record = await brZcapStorage.policies.insert({policy}); + const location = `${req.get('host')}/${req.originalUrl}` + + encodeURIComponent(policy.delegate); + res.status(201).location(location).json({policy: record.policy}); })); // get zcap policies by query @@ -68,20 +81,16 @@ bedrock.events.on('bedrock-express.configure.routes', app => { cors(), validate({querySchema: schemas.getZcapPoliciesQuery}), middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement - throw new Error('Not implemented'); - /* + asyncHandler(async (req, res) => { const {profileId} = req.query; - const results = await policyStorage.find({ - profileId, req, options: {projection: {_id: 0, policy: 1}} + const results = await brZcapStorage.policies.find({ + profileId, req, options: {projection: {_id: 0, policy: 1}, limit: 100} }); res.json({ // return as `results` to enable adding `hasMore` / `cursor` // information in the future results: results.map(r => r.policy) }); - */ })); // update a zcap policy @@ -91,17 +100,22 @@ bedrock.events.on('bedrock-express.configure.routes', app => { cors(), validate({bodySchema: schemas.updateZcapPolicyBody}), middleware.authorizeProfileZcapRequest(), - asyncHandler(async (/*req, res*/) => { - // FIXME: implement - throw new Error('Not implemented'); - /* - await policyStorage.update({policy}); - res.json(policy); - - // FIXME: use meters? - // meter operation usage - reportOperationUsage({req}); - */ + asyncHandler(async (req, res) => { + const {profileId, delegateId} = req.params; + const {policy} = req.body; + if(policy.controller !== profileId || policy.delegate !== delegateId) { + throw new BedrockError( + 'Permission denied; policy controller or delegate do not match ' + + 'HTTP route.', { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } + const record = await brZcapStorage.policies.update({policy}); + res.json({policy: record.policy}); })); // get a zcap policy From eded548d4a02d289d23bf34cd89d736aac2bae7b Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sat, 15 Nov 2025 19:18:27 -0500 Subject: [PATCH 18/33] Remove unused code. --- lib/zcaps.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index c3d7dec..faade39 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -25,9 +25,6 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // viewable zcap policy of a delegate when they invoke a refresh zcap routes.viewablePolicy = `${routes.refresh}/policy`; - // base URL for server - //const {baseUri} = bedrock.config.server; - /* Note: CORS is used on all endpoints. This is safe because authorization uses HTTP signatures + capabilities, not cookies; CSRF is not possible. */ From b9b397a9532f76bbd5ab008d5419214520d2a099 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 01:26:59 -0500 Subject: [PATCH 19/33] Improve zcap policy JSON schemas; fix interaction JSON schema. --- lib/zcaps.js | 1 - schemas/bedrock-profile-http.js | 79 +++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index faade39..e94bcc9 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -34,7 +34,6 @@ bedrock.events.on('bedrock-express.configure.routes', app => { routes.policies, cors(), validate({bodySchema: schemas.createZcapPolicyBody}), - validate({bodySchema: schemas.createZcapPolicy}), middleware.authorizeProfileZcapRequest(), asyncHandler(async (req, res) => { const {profileId} = req.params; diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index b458ce5..39d2c84 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -23,6 +23,13 @@ const id = { maxLength: 4096 }; +const sequence = { + title: 'sequence', + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER - 1 +}; + // this should match query objects with an account in them const accountQuery = { title: 'Account Query', @@ -272,24 +279,11 @@ const createInteraction = { exchange: { type: 'object', required: ['variables'], - variables: { - type: 'object', - additionalProperties: false, - oneOf: [{ - required: ['verifiablePresentation'] - }, { - required: ['verifiablePresentationRequest'] - }], - properties: { - allowUnprotectedPresentation: { - type: 'boolean' - }, - verifiablePresentation: { - type: 'object' - }, - verifiablePresentationRequest: { - type: 'object' - } + additionalProperties: false, + properties: { + variables: { + type: 'object', + additionalProperties: true } } } @@ -308,6 +302,52 @@ const getInteractionQuery = { } }; +const zcapPolicy = { + title: 'Zcap Policy', + type: 'object', + required: ['sequence', 'refresh'], + additionalProperties: false, + properties: { + sequence, + refresh: { + anyOf: [{ + const: false + }, { + type: 'object', + additionalProperties: false, + properties: { + constraints: { + type: 'object', + additionalProperties: false, + properties: { + maxTtlBeforeRefresh: { + type: 'number' + } + } + } + } + }] + } + } +}; + +const createZcapPolicyBody = { + title: 'Create Zcap Policy', + type: 'object', + required: ['policy'], + additionalProperties: false, + properties: { + policy: zcapPolicy + } +}; +const getZcapPoliciesQuery = { + title: 'Zcap Policy Query', + type: 'object', + additionalProperties: false, + properties: {} +}; +const updateZcapPolicyBody = createZcapPolicyBody; + export { profileAgent, profileAgents, @@ -315,6 +355,9 @@ export { delegateCapability, createInteraction, getInteractionQuery, + createZcapPolicyBody, + getZcapPoliciesQuery, + updateZcapPolicyBody, zcaps, refreshableZcap }; From 2fd50d35b766fc67ec4bddc53e9c9efe9086f3ac Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 01:31:16 -0500 Subject: [PATCH 20/33] Load and fix up zcap policy HTTP APIs. --- lib/documentLoader.js | 27 +++++++++++++++++++++++++++ lib/http.js | 1 + lib/middleware.js | 2 +- package.json | 7 ++++++- test/package.json | 30 +++++++++++++++--------------- 5 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 lib/documentLoader.js diff --git a/lib/documentLoader.js b/lib/documentLoader.js new file mode 100644 index 0000000..de9f251 --- /dev/null +++ b/lib/documentLoader.js @@ -0,0 +1,27 @@ +/*! + * Copyright (c) 2018-2022 Digital Bazaar, Inc. All rights reserved. + */ +import {documentLoader as brDocumentLoader} + from '@bedrock/jsonld-document-loader'; +import {didIo} from '@bedrock/did-io'; + +import '@bedrock/did-context'; +import '@bedrock/security-context'; +import '@bedrock/veres-one-context'; + +// load config defaults +import './config.js'; + +export async function documentLoader(url) { + if(url.startsWith('did:')) { + const document = await didIo.get({did: url}); + return { + contextUrl: null, + documentUrl: url, + document + }; + } + + // finally, try the bedrock document loader + return brDocumentLoader(url); +} diff --git a/lib/http.js b/lib/http.js index 34c3564..1b0887a 100644 --- a/lib/http.js +++ b/lib/http.js @@ -4,3 +4,4 @@ import './profiles.js'; import './profileAgents.js'; import './interactions.js'; +import './zcaps.js'; diff --git a/lib/middleware.js b/lib/middleware.js index b91867a..87c5b45 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -13,7 +13,7 @@ import { authorizeZcapInvocation as _authorizeZcapInvocation } from '@digitalbazaar/ezcap-express'; import {asyncHandler} from '@bedrock/express'; -import {documentLoader} from '../documentLoader.js'; +import {documentLoader} from './documentLoader.js'; import { Ed25519Signature2020 } from '@digitalbazaar/ed25519-signature-2020'; diff --git a/package.json b/package.json index 8dce92b..d17a2b7 100644 --- a/package.json +++ b/package.json @@ -40,13 +40,18 @@ "peerDependencies": { "@bedrock/app-identity": "^4.0.0", "@bedrock/core": "^6.3.0", + "@bedrock/did-context": "^6.0.0", + "@bedrock/did-io": "^10.4.0", "@bedrock/express": "^8.3.1", "@bedrock/https-agent": "^4.1.0", + "@bedrock/jsonld-document-loader": "^5.2.0", "@bedrock/notify": "^1.1.0", "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.3.0", + "@bedrock/security-context": "^9.0.0", "@bedrock/validation": "^7.1.1", - "@bedrock/zcap-storage": "^9.4.0" + "@bedrock/veres-one-context": "^16.0.0", + "@bedrock/zcap-storage": "^9.4.1" }, "directories": { "lib": "./lib" diff --git a/test/package.json b/test/package.json index 56b8079..27a0e6a 100644 --- a/test/package.json +++ b/test/package.json @@ -12,46 +12,46 @@ }, "dependencies": { "@bedrock/account": "^10.0.0", - "@bedrock/app-identity": "^4.0.0", + "@bedrock/app-identity": "^4.1.0", "@bedrock/core": "^6.3.0", "@bedrock/did-context": "^6.0.0", "@bedrock/did-io": "^10.4.0", - "@bedrock/edv-storage": "^20.0.0", - "@bedrock/express": "^8.3.1", + "@bedrock/edv-storage": "^21.2.0", + "@bedrock/express": "^8.5.1", "@bedrock/https-agent": "^4.1.0", "@bedrock/jsonld-document-loader": "^5.2.0", "@bedrock/kms": "^16.0.0", - "@bedrock/kms-http": "^22.0.0", + "@bedrock/kms-http": "^23.0.0", "@bedrock/ledger-context": "^25.0.0", "@bedrock/meter": "^6.0.0", "@bedrock/meter-http": "^14.0.0", - "@bedrock/meter-usage-reporter": "^10.0.0", - "@bedrock/mongodb": "^11.0.0", - "@bedrock/notify": "^1.1.0", + "@bedrock/meter-usage-reporter": "^10.1.0", + "@bedrock/mongodb": "^11.0.1", + "@bedrock/notify": "^1.1.1", "@bedrock/oauth2-verifier": "^2.4.0", "@bedrock/package-manager": "^3.0.0", - "@bedrock/passport": "^12.0.0", + "@bedrock/passport": "^12.1.0", "@bedrock/profile": "^26.3.0", "@bedrock/profile-http": "file:..", "@bedrock/security-context": "^9.0.0", "@bedrock/server": "^5.1.0", "@bedrock/service-agent": "^10.2.0", - "@bedrock/service-core": "^11.2.1", + "@bedrock/service-core": "^11.4.0", "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", "@bedrock/validation": "^7.1.1", - "@bedrock/vc-delivery": "^7.7.1", - "@bedrock/vc-verifier": "^22.1.0", + "@bedrock/vc-delivery": "^7.7.2", + "@bedrock/vc-verifier": "^23.3.0", "@bedrock/veres-one-context": "^16.0.0", - "@bedrock/zcap-storage": "^9.4.0", + "@bedrock/zcap-storage": "^9.4.1", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", + "@digitalbazaar/ezcap": "^4.1.0", "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", "@digitalbazaar/zcap-context": "^2.0.0", - "apisauce": "^3.1.0", + "apisauce": "^3.2.1", "c8": "^10.1.3", - "cross-env": "^7.0.3", - "uuid": "^11.1.0" + "cross-env": "^10.1.0" }, "c8": { "excludeNodeModules": false, From 5029d8b8faa416e512a3c80a5e649562f4f535c8 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 01:31:41 -0500 Subject: [PATCH 21/33] Add zcap policy tests. --- test/mocha/40-policies.js | 113 ++++++++++++++++++++++++++++++++++++++ test/mocha/mock.data.js | 3 +- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 test/mocha/40-policies.js diff --git a/test/mocha/40-policies.js b/test/mocha/40-policies.js new file mode 100644 index 0000000..4894409 --- /dev/null +++ b/test/mocha/40-policies.js @@ -0,0 +1,113 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import { + AsymmetricKey, + CapabilityAgent, + KmsClient +} from '@digitalbazaar/webkms-client'; +import {config} from '@bedrock/core'; +// apisauce is a wrapper around axios that provides improved error handling +import {create} from 'apisauce'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import https from 'node:https'; +import {httpsAgent} from '@bedrock/https-agent'; +import {mockData} from './mock.data.js'; +import {ZcapClient} from '@digitalbazaar/ezcap'; + +let accounts; +let api; + +const baseURL = `https://${config.server.host}`; + +describe('policies', () => { + // mock session authentication for delegations endpoint + let passportStub; + let capabilityAgent; + let zcapClient; + const urls = {}; + before(async () => { + await helpers.prepareDatabase(mockData); + passportStub = helpers.stubPassport(); + accounts = mockData.accounts; + api = create({ + baseURL, + headers: {Accept: 'application/ld+json, application/json'}, + httpsAgent: new https.Agent({rejectUnauthorized: false}) + }); + + // create local ephemeral capability agent + const secret = crypto.randomUUID(); + const handle = 'test'; + capabilityAgent = await CapabilityAgent.fromSecret({secret, handle}); + + // delegate profile root zcap to capability agent + const {account: {id: account}} = accounts['alpha@example.com']; + const {data: {id: profileId}} = await api.post('/profiles', + {account, didMethod: 'key'}); + const {data} = await api.get(`/profile-agents/?account=${account}` + + `&profile=${profileId}`); + const [{profileAgent}] = data; + const {id: profileAgentId} = profileAgent; + const zcap = profileAgent.zcaps.profileCapabilityInvocationKey; + const result = await api.post( + `/profile-agents/${profileAgentId}/capabilities/delegate`, { + controller: capabilityAgent.id, account, zcap + }); + + // create `invocationSigner` interface for acting as profile + const profileSigner = await AsymmetricKey.fromCapability({ + capability: result.data.zcap, + invocationSigner: capabilityAgent.getSigner(), + kmsClient: new KmsClient({httpsAgent}) + }); + zcapClient = new ZcapClient({ + agent: httpsAgent, + invocationSigner: profileSigner, + delegationSigner: profileSigner, + SuiteClass: Ed25519Signature2020 + }); + + // create test "delegates" for whom the policies will be about + const delegates = []; + for(let i = 0; i < 2; ++i) { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + delegates.push(delegate); + } + + // setup policy urls + const profilePath = `${baseURL}/profiles/${encodeURIComponent(profileId)}`; + const zcapsPath = `${profilePath}/zcaps`; + urls.policies = `${zcapsPath}/policies`; + urls.refresh = `${zcapsPath}/refresh`; + urls.viewablePolicy = `${urls.refresh}/policy`; + }); + after(async () => { + passportStub.restore(); + }); + + it('fails to create a new policy with bad post data', async () => { + should.exist(zcapClient); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + json: {foo: {}, policy: {}} + }); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(400); + err.data.details.errors.should.have.length(1); + const [error] = err.data.details.errors; + error.name.should.equal('ValidationError'); + error.message.should.contain('should NOT have additional properties'); + }); +}); diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index c89b360..38a1236 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -1,7 +1,6 @@ /*! * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ -import {v4 as uuid} from 'uuid'; import {constants as zcapConstants} from '@digitalbazaar/zcap'; const {ZCAP_CONTEXT_URL} = zcapConstants; @@ -31,7 +30,7 @@ accounts[email].meta = {}; function createAccount(email) { const newAccount = { - id: 'urn:uuid:' + uuid(), + id: `urn:uuid:${crypto.randomUUID()}`, email }; return newAccount; From 493b84e6972a80c68fe019b9d6ba3a814aba8516 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 14:45:23 -0500 Subject: [PATCH 22/33] Add tests for managing zcap policies. --- lib/middleware.js | 25 +- lib/zcaps.js | 48 +-- package.json | 2 +- schemas/bedrock-profile-http.js | 2 + test/mocha/40-policies.js | 626 +++++++++++++++++++++++++++++++- test/package.json | 5 +- 6 files changed, 653 insertions(+), 55 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index 87c5b45..dfd4674 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -28,7 +28,7 @@ export function authorizeProfileZcapRequest({expectedAction} = {}) { const cfg = bedrock.config['profile-http']; const {basePath} = cfg.routes; const {baseUri} = bedrock.config.server; - const profilesPath = `${baseUri}/${basePath}`; + const profilesPath = `${baseUri}${basePath}`; return authorizeZcapInvocation({ async getExpectedValues({req}) { @@ -43,7 +43,7 @@ export function authorizeProfileZcapRequest({expectedAction} = {}) { }, async getRootController({req}) { // this will always be present based on where this middleware is used - return req.param.profileId; + return req.params.profileId; } }); } @@ -69,21 +69,21 @@ export function verifyRefreshableZcapDelegation() { return asyncHandler(async function verifyRefreshZcap(req, res, next) { const {body: capability} = req; - // confirm the invoked zcap controller matches the controller of the zcap - // that is to be refreshed - if(capability.controller !== req.zcap.controller) { + // `profileId` and `delegateId` params always present where this middleware + // is used + const {profileId, delegateId} = req.params; + + // confirm capability to be refreshed has `controller` matching the + // `delegateId` of the route + if(capability.controller !== delegateId) { throw new BedrockError( `The controller "${capability.controller}" of the capability to be ` + - `refreshed must equal the invoked capability's controller ` + - `${req.zcap.controller}.`, { + `refreshed must match the HTTP route delegate ID "${delegateId}".`, { name: 'NotAllowedError', details: {httpStatusCode: 403, public: true} }); } - // `profileId` param always present where this middleware is used - const {profileId} = req.params; - // verify CapabilityDelegation let delegator; const capture = {}; @@ -132,9 +132,8 @@ export function verifyRefreshableZcapDelegation() { delegator, capabilityChain, chainControllers, capability }; - // proceed to next middleware on next tick to prevent subsequent - // middleware from potentially throwing here - process.nextTick(next); + // proceed to next middleware + next(); }); } diff --git a/lib/zcaps.js b/lib/zcaps.js index e94bcc9..6bc4a16 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -17,13 +17,14 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const {basePath} = cfg.routes; const zcapsPath = `${basePath}/:profileId/zcaps`; const routes = { - policies: `${zcapsPath}/policies`, - refresh: `${zcapsPath}/refresh` + policies: `${zcapsPath}/policies` }; // full zcap policy for a particular delegate routes.policy = `${routes.policies}/:delegateId`; - // viewable zcap policy of a delegate when they invoke a refresh zcap - routes.viewablePolicy = `${routes.refresh}/policy`; + // refresh for a particular delegate + routes.refresh = `${routes.policy}/refresh`; + // delegate view of a zcap refresh policy + routes.viewableRefreshPolicy = `${routes.refresh}/policy`; /* Note: CORS is used on all endpoints. This is safe because authorization uses HTTP signatures + capabilities, not cookies; CSRF is not possible. */ @@ -51,7 +52,9 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // apply any limits checks if(cfg.limits?.zcapPolicies !== -1) { - const {count} = await brZcapStorage.policies.count({profileId}); + const {count} = await brZcapStorage.policies.count({ + controller: profileId + }); if(count >= cfg.limits?.zcapPolicies) { throw new BedrockError( 'Permission denied; Maximum policies per profile ' + @@ -66,7 +69,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { } const record = await brZcapStorage.policies.insert({policy}); - const location = `${req.get('host')}/${req.originalUrl}` + + const location = `https://${req.get('host')}${req.originalUrl}/` + encodeURIComponent(policy.delegate); res.status(201).location(location).json({policy: record.policy}); })); @@ -85,7 +88,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { res.json({ // return as `results` to enable adding `hasMore` / `cursor` // information in the future - results: results.map(r => r.policy) + results: results.map(r => ({policy: r.policy})) }); })); @@ -121,7 +124,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { middleware.authorizeProfileZcapRequest(), asyncHandler(async (req, res) => { const {profileId, delegateId} = req.params; - const policy = await getRefreshZcapPolicy({profileId, delegateId}); + const {policy} = await getRefreshZcapPolicy({profileId, delegateId}); res.json({policy}); })); @@ -152,22 +155,25 @@ bedrock.events.on('bedrock-express.configure.routes', app => { })); // get only the details of the refresh policy that a delegate can see - app.options(routes.viewablePolicy, cors()); - app.post( - routes.viewablePolicy, + app.options(routes.viewableRefreshPolicy, cors()); + app.get( + routes.viewableRefreshPolicy, middleware.authorizeProfileZcapRequest(), asyncHandler(async (req, res) => { - // use `controller` of invoked zcap to determine `delegateId` to look up - // policy details - const {profileId} = req.params; - const {controller: delegateId} = req.zcap; - const policy = await getRefreshZcapPolicy({profileId, delegateId}); - // return only `refresh.constraints` to client - const viewablePolicy = { - refresh: { - constraints: policy.refresh.constraints + const {profileId, delegateId} = req.params; + const {policy} = await getRefreshZcapPolicy({profileId, delegateId}); + // return only `refresh=false` or `refresh.constraints` to client + const viewablePolicy = {}; + const {refresh} = policy; + if(!refresh) { + viewablePolicy.refresh = false; + } else { + viewablePolicy.refresh = {}; + const {constraints} = refresh; + if(constraints) { + viewablePolicy.refresh.constraints = constraints; } - }; + } res.json({policy: viewablePolicy}); })); }); diff --git a/package.json b/package.json index d17a2b7..1178674 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@bedrock/security-context": "^9.0.0", "@bedrock/validation": "^7.1.1", "@bedrock/veres-one-context": "^16.0.0", - "@bedrock/zcap-storage": "^9.4.1" + "@bedrock/zcap-storage": "^9.4.2" }, "directories": { "lib": "./lib" diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index 39d2c84..18a86fd 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -309,6 +309,8 @@ const zcapPolicy = { additionalProperties: false, properties: { sequence, + controller, + delegate: controller, refresh: { anyOf: [{ const: false diff --git a/test/mocha/40-policies.js b/test/mocha/40-policies.js index 4894409..551af30 100644 --- a/test/mocha/40-policies.js +++ b/test/mocha/40-policies.js @@ -10,6 +10,7 @@ import { import {config} from '@bedrock/core'; // apisauce is a wrapper around axios that provides improved error handling import {create} from 'apisauce'; +import {createRootCapability} from '@digitalbazaar/zcap'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; import https from 'node:https'; import {httpsAgent} from '@bedrock/https-agent'; @@ -19,18 +20,19 @@ import {ZcapClient} from '@digitalbazaar/ezcap'; let accounts; let api; -const baseURL = `https://${config.server.host}`; - describe('policies', () => { // mock session authentication for delegations endpoint let passportStub; let capabilityAgent; + let profileId; + let rootZcap; let zcapClient; const urls = {}; before(async () => { await helpers.prepareDatabase(mockData); passportStub = helpers.stubPassport(); accounts = mockData.accounts; + const baseURL = `https://${config.server.host}`; api = create({ baseURL, headers: {Accept: 'application/ld+json, application/json'}, @@ -44,10 +46,11 @@ describe('policies', () => { // delegate profile root zcap to capability agent const {account: {id: account}} = accounts['alpha@example.com']; - const {data: {id: profileId}} = await api.post('/profiles', - {account, didMethod: 'key'}); - const {data} = await api.get(`/profile-agents/?account=${account}` + - `&profile=${profileId}`); + ({ + data: {id: profileId} + } = await api.post('/profiles', {account, didMethod: 'key'})); + const {data} = await api.get( + `/profile-agents/?account=${account}&profile=${profileId}`); const [{profileAgent}] = data; const {id: profileAgentId} = profileAgent; const zcap = profileAgent.zcaps.profileCapabilityInvocationKey; @@ -69,34 +72,27 @@ describe('policies', () => { SuiteClass: Ed25519Signature2020 }); - // create test "delegates" for whom the policies will be about - const delegates = []; - for(let i = 0; i < 2; ++i) { - const secret = crypto.randomUUID(); - const handle = 'test'; - const delegate = await CapabilityAgent.fromSecret({secret, handle}); - delegates.push(delegate); - } - // setup policy urls const profilePath = `${baseURL}/profiles/${encodeURIComponent(profileId)}`; const zcapsPath = `${profilePath}/zcaps`; urls.policies = `${zcapsPath}/policies`; urls.refresh = `${zcapsPath}/refresh`; - urls.viewablePolicy = `${urls.refresh}/policy`; + + ({id: rootZcap} = createRootCapability({ + invocationTarget: profilePath + })); }); after(async () => { passportStub.restore(); }); it('fails to create a new policy with bad post data', async () => { - should.exist(zcapClient); - let err; let result; try { result = await zcapClient.write({ url: urls.policies, + capability: rootZcap, json: {foo: {}, policy: {}} }); } catch(e) { @@ -110,4 +106,598 @@ describe('policies', () => { error.name.should.equal('ValidationError'); error.message.should.contain('should NOT have additional properties'); }); + + it('fails to create a new policy with bad controller', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: 'did:example:1234', + delegate: delegate.id, + refresh: false + } + } + }); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.name.should.equal('NotAllowedError'); + }); + + it('creates a new "refresh=false" policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: false + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(201); + const expectedLocation = + `${urls.policies}/${encodeURIComponent(delegate.id)}`; + result.headers.get('location').should.equal(expectedLocation); + result.data.should.deep.equal({ + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: false + } + }); + }); + + it('creates a new policy with no constraints', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: {} + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(201); + const expectedLocation = + `${urls.policies}/${encodeURIComponent(delegate.id)}`; + result.headers.get('location').should.equal(expectedLocation); + result.data.should.deep.equal({ + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: {} + } + }); + }); + + it('creates a new policy with empty constraints', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: {} + } + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(201); + const expectedLocation = + `${urls.policies}/${encodeURIComponent(delegate.id)}`; + result.headers.get('location').should.equal(expectedLocation); + result.data.should.deep.equal({ + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: {} + } + } + }); + }); + + it('creates a new policy with constraints', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + let err; + let result; + try { + result = await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(201); + const expectedLocation = + `${urls.policies}/${encodeURIComponent(delegate.id)}`; + result.headers.get('location').should.equal(expectedLocation); + result.data.should.deep.equal({ + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + }); + }); + + it('fails to update an existing policy w/ wrong sequence', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add initial policy w/o refresh support + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: false + } + } + }); + + // fail to update existing policy to enable refresh w/constraints + let err; + let result; + try { + result = await zcapClient.write({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(409); + err.data.name.should.equal('InvalidStateError'); + }); + + it('updates an existing "refresh=false" policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add initial policy w/o refresh support + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: false + } + } + }); + + // update existing policy to enable refresh w/constraints + let err; + let result; + try { + result = await zcapClient.write({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap, + json: { + policy: { + sequence: 1, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.should.deep.equal({ + policy: { + sequence: 1, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + }); + }); + + it('updates an existing policy w/constraints', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add initial policy w/o refresh support + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + + // update existing policy to enable refresh w/constraints + let err; + let result; + try { + result = await zcapClient.write({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap, + json: { + policy: { + sequence: 1, + controller: profileId, + delegate: delegate.id, + refresh: false + } + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.should.deep.equal({ + policy: { + sequence: 1, + controller: profileId, + delegate: delegate.id, + refresh: false + } + }); + }); + + it('deletes nothing for a non-existent policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // delete existing policy + let err; + let result; + try { + result = await zcapClient.request({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap, + method: 'delete', + action: 'write' + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.deleted.should.equal(false); + }); + + it('deletes an existing policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add policy to delete + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: false + } + } + }); + + // delete existing policy + let err; + let result; + try { + result = await zcapClient.request({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap, + method: 'delete', + action: 'write' + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.deleted.should.equal(true); + }); + + it('get existing policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + + // get policy + let err; + let result; + try { + result = await zcapClient.read({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.should.deep.equal({ + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + }); + }); + + it('fails to get non-existent policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // fail to get policy + let err; + let result; + try { + result = await zcapClient.read({ + url: `${urls.policies}/${encodeURIComponent(delegate.id)}`, + capability: rootZcap + }); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(404); + err.data.name.should.equal('NotFoundError'); + }); + + it('gets multiple policies', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + + // get policies + let err; + let result; + try { + result = await zcapClient.read({ + url: urls.policies, + capability: rootZcap + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.results.should.be.an('array'); + result.data.results.length.should.be.gte(1); + }); + + it('gets a delegate-viewable policy', async () => { + const secret = crypto.randomUUID(); + const handle = 'test'; + const delegate = await CapabilityAgent.fromSecret({secret, handle}); + + // add policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: delegate.id, + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + } + }); + + let err; + let result; + try { + result = await zcapClient.read({ + url: + `${urls.policies}/${encodeURIComponent(delegate.id)}/refresh/policy`, + capability: rootZcap + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.status.should.equal(200); + result.data.should.deep.equal({ + policy: { + refresh: { + constraints: { + // must be 30 days from expiry or less + maxTtlBeforeRefresh: 1000 * 60 * 60 * 24 * 30 + } + } + } + }); + }); }); diff --git a/test/package.json b/test/package.json index 27a0e6a..6e68995 100644 --- a/test/package.json +++ b/test/package.json @@ -35,7 +35,7 @@ "@bedrock/profile-http": "file:..", "@bedrock/security-context": "^9.0.0", "@bedrock/server": "^5.1.0", - "@bedrock/service-agent": "^10.2.0", + "@bedrock/service-agent": "^10.3.0", "@bedrock/service-core": "^11.4.0", "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", @@ -43,9 +43,10 @@ "@bedrock/vc-delivery": "^7.7.2", "@bedrock/vc-verifier": "^23.3.0", "@bedrock/veres-one-context": "^16.0.0", - "@bedrock/zcap-storage": "^9.4.1", + "@bedrock/zcap-storage": "^9.4.2", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/ezcap": "^4.1.0", + "@digitalbazaar/http-client": "^4.2.0", "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", "@digitalbazaar/zcap-context": "^2.0.0", From 7188d4e13fa1dfaa45ef4e60fb8c3c0a91b242e3 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 14:45:51 -0500 Subject: [PATCH 23/33] Move config-based var inside `describe()` function. --- test/mocha/10-profiles.js | 3 +-- test/mocha/20-profileAgents.js | 3 +-- test/mocha/30-interactions.js | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/test/mocha/10-profiles.js b/test/mocha/10-profiles.js index 42b275a..db662ba 100644 --- a/test/mocha/10-profiles.js +++ b/test/mocha/10-profiles.js @@ -11,8 +11,6 @@ import {mockData} from './mock.data.js'; let accounts; let api; -const baseURL = `https://${config.server.host}`; - describe('profiles', () => { // mock session authentication for delegations endpoint let passportStub; @@ -20,6 +18,7 @@ describe('profiles', () => { await helpers.prepareDatabase(mockData); passportStub = helpers.stubPassport(); accounts = mockData.accounts; + const baseURL = `https://${config.server.host}`; api = create({ baseURL, headers: {Accept: 'application/ld+json, application/json'}, diff --git a/test/mocha/20-profileAgents.js b/test/mocha/20-profileAgents.js index a0d4319..6bdf98a 100644 --- a/test/mocha/20-profileAgents.js +++ b/test/mocha/20-profileAgents.js @@ -12,8 +12,6 @@ let accounts; let zcaps; let api; -const baseURL = `https://${config.server.host}`; - describe('profile agents', () => { // mock session authentication for delegations endpoint let passportStub; @@ -22,6 +20,7 @@ describe('profile agents', () => { passportStub = helpers.stubPassport(); accounts = mockData.accounts; zcaps = mockData.zcaps; + const baseURL = `https://${config.server.host}`; api = create({ baseURL, headers: {Accept: 'application/ld+json, application/json'}, diff --git a/test/mocha/30-interactions.js b/test/mocha/30-interactions.js index b82c517..44f5100 100644 --- a/test/mocha/30-interactions.js +++ b/test/mocha/30-interactions.js @@ -10,14 +10,13 @@ import {mockData} from './mock.data.js'; let api; -const baseURL = `https://${config.server.host}`; - describe('interactions', () => { // mock session authentication for delegations endpoint let passportStub; before(async () => { await helpers.prepareDatabase(mockData); passportStub = helpers.stubPassport(); + const baseURL = `https://${config.server.host}`; api = create({ baseURL, headers: {Accept: 'application/ld+json, application/json'}, From 90384b8c2711df1652ff4656babae485844e96e7 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 14:46:48 -0500 Subject: [PATCH 24/33] Add test infrastructure for zcap refresh tests. --- test/mocha/50-zcap-refresh.js | 596 ++++++++++++++++++++++++++++++++++ test/mocha/helpers.js | 59 ++++ test/mocha/mock.data.js | 8 +- test/test.config.js | 12 + test/test.js | 52 ++- 5 files changed, 724 insertions(+), 3 deletions(-) create mode 100644 test/mocha/50-zcap-refresh.js diff --git a/test/mocha/50-zcap-refresh.js b/test/mocha/50-zcap-refresh.js new file mode 100644 index 0000000..6ef0bab --- /dev/null +++ b/test/mocha/50-zcap-refresh.js @@ -0,0 +1,596 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import { + AsymmetricKey, + CapabilityAgent, + KmsClient +} from '@digitalbazaar/webkms-client'; +import {config} from '@bedrock/core'; +// apisauce is a wrapper around axios that provides improved error handling +import {create} from 'apisauce'; +import {createRootCapability} from '@digitalbazaar/zcap'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import {httpClient} from '@digitalbazaar/http-client'; +import {httpsAgent} from '@bedrock/https-agent'; +import {mockData} from './mock.data.js'; +import {refreshZcaps} from '@bedrock/service-agent'; +import {ZcapClient} from '@digitalbazaar/ezcap'; + +let accounts; +let api; + +describe.skip('zcap refresh', () => { + // mock session authentication for delegations endpoint + let passportStub; + let capabilityAgent; + let serviceAgent; + let profileId; + let rootZcap; + let zcapClient; + const urls = {}; + before(async () => { + await helpers.prepareDatabase(mockData); + passportStub = helpers.stubPassport(); + accounts = mockData.accounts; + const baseURL = `https://${config.server.host}`; + api = create({ + baseURL, + headers: {Accept: 'application/ld+json, application/json'}, + httpsAgent + }); + + // create local ephemeral capability agent + const secret = crypto.randomUUID(); + const handle = 'test'; + capabilityAgent = await CapabilityAgent.fromSecret({secret, handle}); + + // delegate profile root zcap to capability agent + const {account: {id: account}} = accounts['alpha@example.com']; + ({ + data: {id: profileId} + } = await api.post('/profiles', {account, didMethod: 'key'})); + const {data} = await api.get( + `/profile-agents/?account=${account}&profile=${profileId}`); + const [{profileAgent}] = data; + const {id: profileAgentId} = profileAgent; + const zcap = profileAgent.zcaps.profileCapabilityInvocationKey; + const result = await api.post( + `/profile-agents/${profileAgentId}/capabilities/delegate`, { + controller: capabilityAgent.id, account, zcap + }); + + // create `invocationSigner` interface for acting as profile + const profileSigner = await AsymmetricKey.fromCapability({ + capability: result.data.zcap, + invocationSigner: capabilityAgent.getSigner(), + kmsClient: new KmsClient({httpsAgent}) + }); + zcapClient = new ZcapClient({ + agent: httpsAgent, + invocationSigner: profileSigner, + delegationSigner: profileSigner, + SuiteClass: Ed25519Signature2020 + }); + + // get service agent of interest + const {baseUrl} = mockData; + const serviceAgentUrl = `${baseUrl}/service-agents/refreshing`; + ({data: serviceAgent} = await httpClient.get(serviceAgentUrl, { + agent: httpsAgent + })); + + // setup policy urls + const profilePath = `${baseURL}/profiles/${encodeURIComponent(profileId)}`; + const zcapsPath = `${profilePath}/zcaps`; + urls.policies = `${zcapsPath}/policies`; + urls.refresh = `${zcapsPath}/refresh`; + urls.viewablePolicy = `${urls.refresh}/policy`; + urls.policy = `${urls.policies}/${encodeURIComponent(serviceAgent.id)}`; + + ({id: rootZcap} = createRootCapability({ + invocationTarget: profilePath + })); + }); + after(async () => { + passportStub.restore(); + }); + + it('should handle 404 for refresh policy', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'post', + action: 'write' + }); + + // function to be called when refreshing the created config + let expectedAfter; + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(false); + result.error.name.should.equal('NotFoundError'); + should.not.exist(result.config); + + expectedAfter = result.refresh.after; + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...record.config, sequence: record.config.sequence + 1}, + refresh: { + enabled: result.refresh.enabled, + after: result.refresh.after + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + try { + const {id: meterId} = await helpers.createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + }); + const zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent + }); + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + const {id: capabilityAgentId} = capabilityAgent; + result.controller.should.equal(capabilityAgentId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.after.should.equal(expectedAfter); + }); + it('should handle 403 for refresh policy', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'post', + action: 'write' + }); + + // function to be called when refreshing the created config + let expectedAfter; + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(false); + result.error.name.should.equal('NotAllowedError'); + should.not.exist(result.config); + + expectedAfter = result.refresh.after; + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...record.config, sequence: record.config.sequence + 1}, + refresh: { + enabled: true, + after: expectedAfter + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + try { + const {id: meterId} = await helpers.createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + }); + const zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent + }); + // make refresh zcap go to the wrong URL (wrong delegate ID) to trigger + // a 403 + zcaps.refresh.invocationTarget += 'foo'; + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + const {id: capabilityAgentId} = capabilityAgent; + result.controller.should.equal(capabilityAgentId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.after.should.equal(expectedAfter); + }); + it('should not refresh zcaps with "refresh=false" policy', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'post', + action: 'write' + }); + + // add "refresh=false" policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: serviceAgent.id, + refresh: false + } + } + }); + + // function to be called when refreshing the created config + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(false); + result.refresh.after.should.equal(0); + should.not.exist(result.config); + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...record.config, sequence: record.config.sequence + 1}, + refresh: { + enabled: result.refresh.enabled, + after: result.refresh.after + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + try { + const {id: meterId} = await helpers.createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + }); + const zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent + }); + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + const {id: capabilityAgentId} = capabilityAgent; + result.controller.should.equal(capabilityAgentId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(false); + record.meta.refresh.after.should.equal(0); + }); + it('should not refresh zcaps with too large TTL', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'post', + action: 'write' + }); + + // add constrained policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: serviceAgent.id, + refresh: { + constraints: { + // require fully expired zcaps + maxTtlBeforeRefresh: 0 + } + } + } + } + }); + + // function to be called when refreshing the created config + let expectedAfter; + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const now = Date.now(); + const later = now + 1000 * 60 * 5; + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(true); + should.exist(result.config); + result.refresh.after.should.be.gte(later); + should.exist(result.results); + result.results.length.should.equal(4); + result.results[3].refreshed.should.equal(false); + result.results[3].refreshed.should.equal(false); + result.results[3].refreshed.should.equal(false); + result.results[3].refreshed.should.equal(false); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + + expectedAfter = result.refresh.after; + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...result.config, sequence: result.config.sequence + 1}, + refresh: { + enabled: result.refresh.enabled, + after: result.refresh.after + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + let zcaps; + try { + const {id: meterId} = await helpers.createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + }); + zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent + }); + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + const {id: capabilityAgentId} = capabilityAgent; + result.controller.should.equal(capabilityAgentId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.after.should.equal(expectedAfter); + + // ensure zcaps did not change + for(const [key, value] of Object.entries(zcaps)) { + record.config.zcaps[key].should.deep.equal(value); + } + }); + it('should refresh zcaps in a config', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'post', + action: 'write' + }); + + // add unconstrained policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: serviceAgent.id, + refresh: { + // no constraints + constraints: {} + } + } + } + }); + + // function to be called when refreshing the created config + let expectedAfter; + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const now = Date.now(); + const later = now + 1000 * 60 * 5; + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(true); + should.exist(result.config); + result.refresh.after.should.be.gte(later); + should.exist(result.results); + result.results.length.should.equal(4); + result.results[0].refreshed.should.equal(true); + result.results[1].refreshed.should.equal(true); + result.results[2].refreshed.should.equal(true); + result.results[3].refreshed.should.equal(true); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + + // set expected after + expectedAfter = result.refresh.after; + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...result.config, sequence: result.config.sequence + 1}, + refresh: { + enabled: result.refresh.enabled, + after: result.refresh.after + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + let zcaps; + try { + const {id: meterId} = await helpers.createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + }); + zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent + }); + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + const {id: capabilityAgentId} = capabilityAgent; + result.controller.should.equal(capabilityAgentId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.after.should.equal(expectedAfter); + + // ensure zcaps changed + for(const [key, value] of Object.entries(zcaps)) { + record.config.zcaps[key].should.not.deep.equal(value); + } + }); +}); + +async function _createZcaps({profileId, zcapClient, serviceAgent}) { + const zcaps = {}; + const {baseUrl} = mockData; + + // delegate *mock* edv, hmac, and key agreement key zcaps to service agent + zcaps.edv = await helpers.delegate({ + controller: serviceAgent.id, + invocationTarget: `${baseUrl}/edv`, + zcapClient + }); + zcaps.hmac = await helpers.delegate({ + controller: serviceAgent.id, + invocationTarget: `${baseUrl}/hmac`, + zcapClient + }); + zcaps.keyAgreementKey = await helpers.delegate({ + controller: serviceAgent.id, + invocationTarget: `${baseUrl}/keyAgreementKey`, + zcapClient + }); + + // delegate refresh zcap to service agent + const refreshUrl = + `${baseUrl}/profiles/${encodeURIComponent(profileId)}` + + '/zcaps/refresh'; + zcaps.refresh = await helpers.delegate({ + controller: serviceAgent.id, + invocationTarget: refreshUrl, + zcapClient + }); + + return zcaps; +} diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 8a1f828..64d7419 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -1,12 +1,71 @@ /*! * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ +import * as bedrock from '@bedrock/core'; import * as brAccount from '@bedrock/account'; import * as database from '@bedrock/mongodb'; import {_deserializeUser, passport} from '@bedrock/passport'; import {mockData} from './mock.data.js'; +export async function createMeter({profileId, zcapClient, serviceType} = {}) { + // create a meter + const meterService = `${bedrock.config.server.baseUri}/meters`; + let meter = { + controller: profileId, + product: { + // mock ID for service type + id: mockData.productIdMap.get(serviceType) + } + }; + ({data: {meter}} = await zcapClient.write({url: meterService, json: meter})); + + // return full meter ID + const {id} = meter; + return {id: `${meterService}/${id}`}; +} + +export async function createConfig({ + profileId, zcapClient, ipAllowList, meterId, zcaps, options = {}, + servicePath = '/refreshing' +} = {}) { + if(!meterId) { + // create a meter for the keystore + ({id: meterId} = await createMeter({ + profileId, zcapClient, serviceType: 'refreshing' + })); + } + + // create service object + const config = { + sequence: 0, + controller: profileId, + meterId, + ...options + }; + if(ipAllowList) { + config.ipAllowList = ipAllowList; + } + if(zcaps) { + config.zcaps = zcaps; + } + + const url = `${mockData.baseUrl}${servicePath}`; + const response = await zcapClient.write({url, json: config}); + return response.data; +} + +export async function delegate({ + capability, controller, invocationTarget, expires, allowedActions, + zcapClient +}) { + expires = expires || (capability && capability.expires) || + new Date(Date.now() + 5000).toISOString().slice(0, -5) + 'Z'; + return zcapClient.delegate({ + capability, controller, expires, invocationTarget, allowedActions + }); +} + export function stubPassport({email = 'alpha@example.com'} = {}) { const original = passport.authenticate; passport._original = original; diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index 38a1236..b32b3cc 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -7,6 +7,9 @@ const {ZCAP_CONTEXT_URL} = zcapConstants; export const mockData = {}; +// functions used in tests +mockData.refreshHandlerListeners = new Map(); + // mock product IDs and reverse lookup for webkms/edv/etc service products mockData.productIdMap = new Map([ // webkms service @@ -17,7 +20,10 @@ mockData.productIdMap = new Map([ ['urn:uuid:dbd15f08-ff67-11eb-893b-10bf48838a41', 'edv'], // workflow service ['vc-workflow', 'urn:uuid:146b6a5b-eade-4612-a215-1f3b5f03d648'], - ['urn:uuid:146b6a5b-eade-4612-a215-1f3b5f03d648', 'vc-workflow'] + ['urn:uuid:146b6a5b-eade-4612-a215-1f3b5f03d648', 'vc-workflow'], + // refreshing service for testing refresh feature + ['refreshing', 'urn:uuid:c48900f6-cb4f-4c7e-bbd6-afdc2cc4b070'], + ['urn:uuid:c48900f6-cb4f-4c7e-bbd6-afdc2cc4b070', 'refreshing'] ]); const zcaps = mockData.zcaps = {}; diff --git a/test/test.config.js b/test/test.config.js index 959ed87..d9672e7 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -9,6 +9,8 @@ import '@bedrock/express'; import '@bedrock/https-agent'; import '@bedrock/mongodb'; import '@bedrock/profile'; +import '@bedrock/service-core'; +import '@bedrock/service-agent'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -52,5 +54,15 @@ config.notify.push.hmacKey = { secretKeyMultibase: 'uogHy02QDNPX4GID7dGUSGuYQ_Gv0WOIcpmTuKgt1ZNz7_4' }; +// create application identity for service with refresh +config['app-identity'].seeds.services.refreshing = { + id: 'did:key:z6MkqhgbwggDuoHeru2GSDmZN6V2oPs1vHZoXhEVJnKpDzEz', + seedMultibase: 'z1AnLvp9wWsUe9YkGoQpvLikA1GjtuduvQGwgptu5va2mKS', + serviceType: 'refreshing' +}; + +// set config storage refresh interval short for testing purposes +config['service-core'].configStorage.refresh.interval = 100; + // service agent config['service-agent'].kms.baseUrl = `${config.server.baseUri}/kms`; diff --git a/test/test.js b/test/test.js index 4b0e0af..790e74f 100644 --- a/test/test.js +++ b/test/test.js @@ -2,8 +2,11 @@ * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; +import {createService, schemas} from '@bedrock/service-core'; import {CapabilityAgent} from '@digitalbazaar/webkms-client'; +import {getServiceIdentities} from '@bedrock/app-identity'; import {handlers} from '@bedrock/meter-http'; +import {initializeServiceAgent} from '@bedrock/service-agent'; import {meters as meterReporting} from '@bedrock/meter-usage-reporter'; import {meters} from '@bedrock/meter'; import {workflowService} from '@bedrock/vc-delivery'; @@ -47,17 +50,62 @@ bedrock.events.on('bedrock.init', async () => { handler({meter} = {}) { // use configured meter usage reporter as service ID for tests const serviceType = mockData.productIdMap.get(meter.product.id); - meter.serviceId = bedrock.config['app-identity'].seeds - .services[serviceType].id; + const serviceIdentites = getServiceIdentities(); + const serviceIdentity = serviceIdentites.get(serviceType); + if(!serviceIdentity) { + throw new Error(`Could not find identity "${serviceType}".`); + } + meter.serviceId = serviceIdentity.id; return {meter}; } }); handlers.setUpdateHandler({handler: ({meter} = {}) => ({meter})}); handlers.setRemoveHandler({handler: ({meter} = {}) => ({meter})}); handlers.setUseHandler({handler: ({meter} = {}) => ({meter})}); + + // create `refreshing` service with a refresh handler + const allowClientIdCreateConfigBody = structuredClone( + schemas.createConfigBody); + allowClientIdCreateConfigBody.properties.id = + schemas.updateConfigBody.properties.id; + mockData.refreshingService = await createService({ + serviceType: 'refreshing', + routePrefix: '/refreshables', + storageCost: { + config: 1, + revocation: 1 + }, + validation: { + createConfigBody: allowClientIdCreateConfigBody, + zcapReferenceIds: [{ + referenceId: 'edv', + required: false + }, { + referenceId: 'hmac', + required: false + }, { + referenceId: 'keyAgreementKey', + required: false + }, { + referenceId: 'refresh', + required: false + }] + }, + async refreshHandler({record, signal}) { + const fn = mockData.refreshHandlerListeners.get(record.config.id); + await fn?.({record, signal}); + } + }); }); bedrock.events.on('bedrock.ready', async () => { + // initialize service agents; + // normally a service agent should be created on `bedrock-mongodb.ready`, + // however, since the KMS system used is local, we have to wait for it to + // be ready; so only do this on `bedrock.ready` + await initializeServiceAgent({serviceType: 'example'}); + await initializeServiceAgent({serviceType: 'refreshing'}); + // programmatically create workflow for interactions... // create some controller for the workflow From e87eba28eebc5b967ca8492a942ee2516c925dfc Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:45:27 -0500 Subject: [PATCH 25/33] Remove unused code. --- test/mocha/40-policies.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/mocha/40-policies.js b/test/mocha/40-policies.js index 551af30..05b76b0 100644 --- a/test/mocha/40-policies.js +++ b/test/mocha/40-policies.js @@ -76,7 +76,6 @@ describe('policies', () => { const profilePath = `${baseURL}/profiles/${encodeURIComponent(profileId)}`; const zcapsPath = `${profilePath}/zcaps`; urls.policies = `${zcapsPath}/policies`; - urls.refresh = `${zcapsPath}/refresh`; ({id: rootZcap} = createRootCapability({ invocationTarget: profilePath From 13007b632b7741a7325c0294346422759037c4e3 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:45:39 -0500 Subject: [PATCH 26/33] Return delegated zcap directly (not nested under `zcap`). --- lib/zcaps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/zcaps.js b/lib/zcaps.js index 6bc4a16..8c4366f 100644 --- a/lib/zcaps.js +++ b/lib/zcaps.js @@ -151,7 +151,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const {profileId} = req.params; const {body: capability} = req; const zcap = await getRefreshedZcap({profileId, capability}); - res.json({zcap}); + res.json(zcap); })); // get only the details of the refresh policy that a delegate can see From 53d5dc44d815af125df04aab21fc8495e5f59666 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:45:56 -0500 Subject: [PATCH 27/33] Fix refreshed zcap cache date and cache key computations. --- lib/refreshedZcapCache.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/refreshedZcapCache.js b/lib/refreshedZcapCache.js index c3941a3..d8b7b96 100644 --- a/lib/refreshedZcapCache.js +++ b/lib/refreshedZcapCache.js @@ -43,7 +43,9 @@ export async function getRefreshZcapPolicy({profileId, delegateId}) { } function _createCacheKey({profileId, capability}) { - const json = {profileId, canonicalZcap: canonicalize(capability)}; + const json = JSON.stringify({ + profileId, canonicalZcap: canonicalize(capability) + }); const hash = createHash('sha256').update(json, 'utf8').digest('base64url'); return hash; } @@ -79,8 +81,9 @@ async function _getUncached({profileId, capability}) { } } - // get profile signer associated with the policy; use any root profile agent - const profileAgentRecord = await profileAgents.getRootAgents({ + // get profile signer associated with the policy; use any root profile agent, + // for which there must be at least one + const [profileAgentRecord] = await profileAgents.getRootAgents({ profileId, options: {limit: 1}, includeSecrets: true }); const profileSigner = await profileAgents.getProfileSigner({ @@ -89,8 +92,9 @@ async function _getUncached({profileId, capability}) { // compute new `expires` from policy, defaulting to max delegation TTL const now = Date.now(); - const expires = new Date(now + policy.refresh?.maxDelegationTtl ?? - authorizeZcapInvocationOptions.maxDelegationTtl); + const expires = new Date( + now + (policy.refresh?.constraints?.maxDelegationTtl ?? + authorizeZcapInvocationOptions.maxDelegationTtl)); return profileAgents.refreshCapability({ capability, profileSigner, now, expires From b881f67418ba80b437b5ed2660a1547ae6c441f5 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:46:27 -0500 Subject: [PATCH 28/33] Create test meter using app identity. --- test/mocha/helpers.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 64d7419..5105266 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -5,14 +5,27 @@ import * as bedrock from '@bedrock/core'; import * as brAccount from '@bedrock/account'; import * as database from '@bedrock/mongodb'; import {_deserializeUser, passport} from '@bedrock/passport'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import {getAppIdentity} from '@bedrock/app-identity'; +import {httpsAgent} from '@bedrock/https-agent'; +import {ZcapClient} from '@digitalbazaar/ezcap'; import {mockData} from './mock.data.js'; -export async function createMeter({profileId, zcapClient, serviceType} = {}) { +export async function createMeter({controller, serviceType} = {}) { + // create signer using the application's capability invocation key + const {keys: {capabilityInvocationKey}} = getAppIdentity(); + + const zcapClient = new ZcapClient({ + agent: httpsAgent, + invocationSigner: capabilityInvocationKey.signer(), + SuiteClass: Ed25519Signature2020 + }); + // create a meter const meterService = `${bedrock.config.server.baseUri}/meters`; let meter = { - controller: profileId, + controller, product: { // mock ID for service type id: mockData.productIdMap.get(serviceType) From ecf481116d70be9a819f85b96c84a61aa8aed753 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:46:45 -0500 Subject: [PATCH 29/33] Include `baseUrl` in test mock data. --- test/mocha/mock.data.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index b32b3cc..b3dc0c8 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -1,12 +1,15 @@ /*! * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ +import {config} from '@bedrock/core'; import {constants as zcapConstants} from '@digitalbazaar/zcap'; const {ZCAP_CONTEXT_URL} = zcapConstants; export const mockData = {}; +mockData.baseUrl = config.server.baseUri; + // functions used in tests mockData.refreshHandlerListeners = new Map(); From 347c7f20d9eb84815c83fe4dbb6d295c2715af2b Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 15:47:05 -0500 Subject: [PATCH 30/33] Fix zcap refresh tests. --- test/mocha/50-zcap-refresh.js | 92 +++++++++++++++++++++++------------ test/package.json | 2 +- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/test/mocha/50-zcap-refresh.js b/test/mocha/50-zcap-refresh.js index 6ef0bab..df651e1 100644 --- a/test/mocha/50-zcap-refresh.js +++ b/test/mocha/50-zcap-refresh.js @@ -21,7 +21,7 @@ import {ZcapClient} from '@digitalbazaar/ezcap'; let accounts; let api; -describe.skip('zcap refresh', () => { +describe('zcap refresh', () => { // mock session authentication for delegations endpoint let passportStub; let capabilityAgent; @@ -85,8 +85,6 @@ describe.skip('zcap refresh', () => { const profilePath = `${baseURL}/profiles/${encodeURIComponent(profileId)}`; const zcapsPath = `${profilePath}/zcaps`; urls.policies = `${zcapsPath}/policies`; - urls.refresh = `${zcapsPath}/refresh`; - urls.viewablePolicy = `${urls.refresh}/policy`; urls.policy = `${urls.policies}/${encodeURIComponent(serviceAgent.id)}`; ({id: rootZcap} = createRootCapability({ @@ -102,7 +100,7 @@ describe.skip('zcap refresh', () => { await zcapClient.request({ url: urls.policy, capability: rootZcap, - method: 'post', + method: 'delete', action: 'write' }); @@ -141,7 +139,7 @@ describe.skip('zcap refresh', () => { let result; try { const {id: meterId} = await helpers.createMeter({ - profileId, zcapClient, serviceType: 'refreshing' + controller: profileId, serviceType: 'refreshing' }); const zcaps = await _createZcaps({ profileId, zcapClient, serviceAgent @@ -162,14 +160,13 @@ describe.skip('zcap refresh', () => { 'controller', 'id', 'sequence', 'meterId', 'zcaps' ]); result.sequence.should.equal(0); - const {id: capabilityAgentId} = capabilityAgent; - result.controller.should.equal(capabilityAgentId); + result.controller.should.equal(profileId); // wait for refresh promise to resolve const record = await configRefreshPromise; record.config.id.should.equal(configId); record.config.sequence.should.equal(1); - record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.enabled.should.equal(false); record.meta.refresh.after.should.equal(expectedAfter); }); it('should handle 403 for refresh policy', async () => { @@ -177,10 +174,43 @@ describe.skip('zcap refresh', () => { await zcapClient.request({ url: urls.policy, capability: rootZcap, - method: 'post', + method: 'delete', action: 'write' }); + // create policy and refrehs zcap for another delegate + // (to erroneously reference to trigger `NotAllowedError`) + let wrongRefreshZcap; + { + const secret = crypto.randomUUID(); + const handle = 'test'; + const otherDelegate = await CapabilityAgent.fromSecret({secret, handle}); + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: otherDelegate.id, + refresh: {} + } + } + }); + const {baseUrl} = mockData; + const profilePath = + `${baseUrl}/profiles/${encodeURIComponent(profileId)}`; + const refreshUrl = + `${profilePath}/zcaps` + + `/policies/${encodeURIComponent(serviceAgent.id)}/refresh`; + wrongRefreshZcap = await helpers.delegate({ + controller: otherDelegate.id, + capability: `urn:zcap:root:${encodeURIComponent(profilePath)}`, + invocationTarget: refreshUrl, + zcapClient + }); + } + // function to be called when refreshing the created config let expectedAfter; const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; @@ -202,8 +232,8 @@ describe.skip('zcap refresh', () => { await mockData.refreshingService.configStorage.update({ config: {...record.config, sequence: record.config.sequence + 1}, refresh: { - enabled: true, - after: expectedAfter + enabled: result.refresh.enabled, + after: result.refresh.after } }); resolve(mockData.refreshingService.configStorage.get({id: configId})); @@ -216,14 +246,13 @@ describe.skip('zcap refresh', () => { let result; try { const {id: meterId} = await helpers.createMeter({ - profileId, zcapClient, serviceType: 'refreshing' + controller: profileId, serviceType: 'refreshing' }); const zcaps = await _createZcaps({ profileId, zcapClient, serviceAgent }); - // make refresh zcap go to the wrong URL (wrong delegate ID) to trigger - // a 403 - zcaps.refresh.invocationTarget += 'foo'; + // use wrong refresh zcap to trigger 403 + zcaps.refresh = wrongRefreshZcap; result = await helpers.createConfig({ profileId, zcapClient, meterId, servicePath: '/refreshables', options: { @@ -240,14 +269,13 @@ describe.skip('zcap refresh', () => { 'controller', 'id', 'sequence', 'meterId', 'zcaps' ]); result.sequence.should.equal(0); - const {id: capabilityAgentId} = capabilityAgent; - result.controller.should.equal(capabilityAgentId); + result.controller.should.equal(profileId); // wait for refresh promise to resolve const record = await configRefreshPromise; record.config.id.should.equal(configId); record.config.sequence.should.equal(1); - record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.enabled.should.equal(false); record.meta.refresh.after.should.equal(expectedAfter); }); it('should not refresh zcaps with "refresh=false" policy', async () => { @@ -255,7 +283,7 @@ describe.skip('zcap refresh', () => { await zcapClient.request({ url: urls.policy, capability: rootZcap, - method: 'post', + method: 'delete', action: 'write' }); @@ -305,7 +333,7 @@ describe.skip('zcap refresh', () => { let result; try { const {id: meterId} = await helpers.createMeter({ - profileId, zcapClient, serviceType: 'refreshing' + controller: profileId, serviceType: 'refreshing' }); const zcaps = await _createZcaps({ profileId, zcapClient, serviceAgent @@ -326,8 +354,7 @@ describe.skip('zcap refresh', () => { 'controller', 'id', 'sequence', 'meterId', 'zcaps' ]); result.sequence.should.equal(0); - const {id: capabilityAgentId} = capabilityAgent; - result.controller.should.equal(capabilityAgentId); + result.controller.should.equal(profileId); // wait for refresh promise to resolve const record = await configRefreshPromise; @@ -341,7 +368,7 @@ describe.skip('zcap refresh', () => { await zcapClient.request({ url: urls.policy, capability: rootZcap, - method: 'post', + method: 'delete', action: 'write' }); @@ -412,7 +439,7 @@ describe.skip('zcap refresh', () => { let zcaps; try { const {id: meterId} = await helpers.createMeter({ - profileId, zcapClient, serviceType: 'refreshing' + controller: profileId, serviceType: 'refreshing' }); zcaps = await _createZcaps({ profileId, zcapClient, serviceAgent @@ -433,8 +460,7 @@ describe.skip('zcap refresh', () => { 'controller', 'id', 'sequence', 'meterId', 'zcaps' ]); result.sequence.should.equal(0); - const {id: capabilityAgentId} = capabilityAgent; - result.controller.should.equal(capabilityAgentId); + result.controller.should.equal(profileId); // wait for refresh promise to resolve const record = await configRefreshPromise; @@ -453,7 +479,7 @@ describe.skip('zcap refresh', () => { await zcapClient.request({ url: urls.policy, capability: rootZcap, - method: 'post', + method: 'delete', action: 'write' }); @@ -523,7 +549,7 @@ describe.skip('zcap refresh', () => { let zcaps; try { const {id: meterId} = await helpers.createMeter({ - profileId, zcapClient, serviceType: 'refreshing' + controller: profileId, serviceType: 'refreshing' }); zcaps = await _createZcaps({ profileId, zcapClient, serviceAgent @@ -544,8 +570,7 @@ describe.skip('zcap refresh', () => { 'controller', 'id', 'sequence', 'meterId', 'zcaps' ]); result.sequence.should.equal(0); - const {id: capabilityAgentId} = capabilityAgent; - result.controller.should.equal(capabilityAgentId); + result.controller.should.equal(profileId); // wait for refresh promise to resolve const record = await configRefreshPromise; @@ -583,11 +608,14 @@ async function _createZcaps({profileId, zcapClient, serviceAgent}) { }); // delegate refresh zcap to service agent + const profilePath = + `${baseUrl}/profiles/${encodeURIComponent(profileId)}`; const refreshUrl = - `${baseUrl}/profiles/${encodeURIComponent(profileId)}` + - '/zcaps/refresh'; + `${profilePath}/zcaps` + + `/policies/${encodeURIComponent(serviceAgent.id)}/refresh`; zcaps.refresh = await helpers.delegate({ controller: serviceAgent.id, + capability: `urn:zcap:root:${encodeURIComponent(profilePath)}`, invocationTarget: refreshUrl, zcapClient }); diff --git a/test/package.json b/test/package.json index 6e68995..f33a895 100644 --- a/test/package.json +++ b/test/package.json @@ -35,7 +35,7 @@ "@bedrock/profile-http": "file:..", "@bedrock/security-context": "^9.0.0", "@bedrock/server": "^5.1.0", - "@bedrock/service-agent": "^10.3.0", + "@bedrock/service-agent": "^10.3.1", "@bedrock/service-core": "^11.4.0", "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", From 40ea7f6ae308023884708aa1dc8165132e9efb08 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 18:10:41 -0500 Subject: [PATCH 31/33] Clean up changelog entry. --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3bc3a..e4abfdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,11 @@ behalf of a controlling profile. - `/profiles//zcaps/policies/`: For updating and fetching existing policies on behalf of a controlling profile. - - `/profiles//zcaps/refresh`: For delegates to refresh their - zcaps according to the matching policy, if any. - - `/profiles//zcaps/refresh/policy`: For delegates to view any - elements exposed by the controller (profile) of the policy that applies - to the controller of the zcap invoked at this endpoint. + - `/profiles//zcaps/policies//refresh`: For delegates + to refresh their zcaps according to the matching policy, if any. + - `/profiles//zcaps/policies//refresh/policy`: For + delegates to view any elements exposed by the controller (profile) of + the policy associated with this endpoint. ## 26.1.0 - 2025-09-19 From 1cf83c1da0b1a7d24b686330096de388cbfd1737 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 19:01:37 -0500 Subject: [PATCH 32/33] Add test for already-expired zcaps. --- lib/middleware.js | 13 ++-- test/mocha/50-zcap-refresh.js | 136 ++++++++++++++++++++++++++++++++-- test/mocha/helpers.js | 4 +- test/package.json | 2 +- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/lib/middleware.js b/lib/middleware.js index dfd4674..e662cd0 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -104,7 +104,9 @@ export function verifyRefreshableZcapDelegation() { chainControllers, capture }), - suiteFactory + suiteFactory, + // allow expired zcap to be refreshed + allowExpired: true }); ({delegator} = results[0].purposeResult); delegator = delegator.id || delegator; @@ -240,7 +242,8 @@ function _handleError({res, error, onError, throwError = true}) { } async function _verifyDelegation({ - req, capability, documentLoader, inspectCapabilityChain, suiteFactory + req, capability, documentLoader, inspectCapabilityChain, suiteFactory, + allowExpired = false }) { // the expected root capability must be the parent capability for a // refreshable zcap; if this is somehow an attacker provided value, @@ -248,8 +251,7 @@ async function _verifyDelegation({ // newly refreshed zcap will fail when invoked at its target if the root // capability controller is invalid const expectedRootCapability = capability.parentCapability; - // FIXME: compute `date` as before expiration in zcap and pass it - // const date = new Date((new Date(capability.expires)).getTime() - 1); + const date = allowExpired ? new Date(capability.expires) : undefined; const {verified, error, results} = await jsigs.verify(capability, { documentLoader, purpose: new CapabilityDelegation({ @@ -260,8 +262,7 @@ async function _verifyDelegation({ delegated with attenuation rules that aren't supported by the invocation endpoint can still be refreshed. */ allowTargetAttenuation: true, - // FIXME: pass `date` as well - // date, + date, expectedRootCapability, inspectCapabilityChain, suite: await suiteFactory({req}) diff --git a/test/mocha/50-zcap-refresh.js b/test/mocha/50-zcap-refresh.js index df651e1..9fbcdd2 100644 --- a/test/mocha/50-zcap-refresh.js +++ b/test/mocha/50-zcap-refresh.js @@ -579,6 +579,116 @@ describe('zcap refresh', () => { record.meta.refresh.enabled.should.equal(true); record.meta.refresh.after.should.equal(expectedAfter); + // ensure zcaps changed + for(const [key, value] of Object.entries(zcaps)) { + record.config.zcaps[key].should.not.deep.equal(value); + } + }); + it('should refresh already-expired zcaps in a config', async () => { + // remove any existing policy + await zcapClient.request({ + url: urls.policy, + capability: rootZcap, + method: 'delete', + action: 'write' + }); + + // add unconstrained policy + await zcapClient.write({ + url: urls.policies, + capability: rootZcap, + json: { + policy: { + sequence: 0, + controller: profileId, + delegate: serviceAgent.id, + refresh: { + // no constraints + constraints: {} + } + } + } + }); + + // function to be called when refreshing the created config + let expectedAfter; + const configId = `${mockData.baseUrl}/refreshables/${crypto.randomUUID()}`; + const configRefreshPromise = new Promise((resolve, reject) => + mockData.refreshHandlerListeners.set(configId, async ({ + record, signal + }) => { + try { + const now = Date.now(); + const later = now + 1000 * 60 * 5; + const result = await refreshZcaps({ + serviceType: 'refreshing', config: record.config, signal + }); + result.refresh.enabled.should.equal(true); + should.exist(result.config); + result.refresh.after.should.be.gte(later); + should.exist(result.results); + result.results.length.should.equal(4); + result.results[0].refreshed.should.equal(true); + result.results[1].refreshed.should.equal(true); + result.results[2].refreshed.should.equal(true); + result.results[3].refreshed.should.equal(true); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + should.not.exist(result.results[0].error); + + // set expected after + expectedAfter = result.refresh.after; + + // update record + await mockData.refreshingService.configStorage.update({ + config: {...result.config, sequence: result.config.sequence + 1}, + refresh: { + enabled: result.refresh.enabled, + after: result.refresh.after + } + }); + resolve(mockData.refreshingService.configStorage.get({id: configId})); + } catch(e) { + reject(e); + } + })); + + let err; + let result; + let zcaps; + try { + const {id: meterId} = await helpers.createMeter({ + controller: profileId, serviceType: 'refreshing' + }); + zcaps = await _createZcaps({ + profileId, zcapClient, serviceAgent, alreadyExpired: true + }); + result = await helpers.createConfig({ + profileId, zcapClient, meterId, servicePath: '/refreshables', + options: { + id: configId, + zcaps + } + }); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.have.keys([ + 'controller', 'id', 'sequence', 'meterId', 'zcaps' + ]); + result.sequence.should.equal(0); + result.controller.should.equal(profileId); + + // wait for refresh promise to resolve + const record = await configRefreshPromise; + record.config.id.should.equal(configId); + record.config.sequence.should.equal(1); + record.meta.refresh.enabled.should.equal(true); + record.meta.refresh.after.should.equal(expectedAfter); + // ensure zcaps changed for(const [key, value] of Object.entries(zcaps)) { record.config.zcaps[key].should.not.deep.equal(value); @@ -586,28 +696,44 @@ describe('zcap refresh', () => { }); }); -async function _createZcaps({profileId, zcapClient, serviceAgent}) { +async function _createZcaps({ + profileId, zcapClient, serviceAgent, alreadyExpired = false +}) { const zcaps = {}; const {baseUrl} = mockData; + let expires; + let now; + if(alreadyExpired) { + // set now in the past, expires an hour later + now = Date.now() - 1000 * 60 * 60 * 24 * 365; + expires = new Date(now + 1000 * 60 * 60); + } + // delegate *mock* edv, hmac, and key agreement key zcaps to service agent zcaps.edv = await helpers.delegate({ controller: serviceAgent.id, invocationTarget: `${baseUrl}/edv`, - zcapClient + expires, + zcapClient, + now }); zcaps.hmac = await helpers.delegate({ controller: serviceAgent.id, invocationTarget: `${baseUrl}/hmac`, - zcapClient + expires, + zcapClient, + now }); zcaps.keyAgreementKey = await helpers.delegate({ controller: serviceAgent.id, invocationTarget: `${baseUrl}/keyAgreementKey`, - zcapClient + expires, + zcapClient, + now }); - // delegate refresh zcap to service agent + // delegate refresh zcap to service agent; this zcap must not be expired const profilePath = `${baseUrl}/profiles/${encodeURIComponent(profileId)}`; const refreshUrl = diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 5105266..5887da2 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -70,12 +70,12 @@ export async function createConfig({ export async function delegate({ capability, controller, invocationTarget, expires, allowedActions, - zcapClient + zcapClient, now }) { expires = expires || (capability && capability.expires) || new Date(Date.now() + 5000).toISOString().slice(0, -5) + 'Z'; return zcapClient.delegate({ - capability, controller, expires, invocationTarget, allowedActions + capability, controller, expires, invocationTarget, allowedActions, now }); } diff --git a/test/package.json b/test/package.json index f33a895..2504f02 100644 --- a/test/package.json +++ b/test/package.json @@ -45,7 +45,7 @@ "@bedrock/veres-one-context": "^16.0.0", "@bedrock/zcap-storage": "^9.4.2", "@digitalbazaar/ed25519-signature-2020": "^5.4.0", - "@digitalbazaar/ezcap": "^4.1.0", + "@digitalbazaar/ezcap": "^4.2.0", "@digitalbazaar/http-client": "^4.2.0", "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", From fdc3901256b3f8ce51bace12a522d01e3149dff1 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 16 Nov 2025 19:03:53 -0500 Subject: [PATCH 33/33] Update github actions. --- .github/workflows/main.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 62f5daa..430c451 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -8,7 +8,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: [22.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -29,7 +29,7 @@ jobs: - 27017:27017 strategy: matrix: - node-version: [20.x, 22.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -55,7 +55,7 @@ jobs: - 27017:27017 strategy: matrix: - node-version: [22.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }}