diff --git a/.github/workflows/vc-delegation-engine-examples.yml b/.github/workflows/vc-delegation-engine-examples.yml new file mode 100644 index 000000000..b5295b1bb --- /dev/null +++ b/.github/workflows/vc-delegation-engine-examples.yml @@ -0,0 +1,29 @@ +name: Delegation Engine examples + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + + - run: yarn install --frozen-lockfile --ignore-scripts + + - run: npm install -g turbo@2.0.5 + + - run: turbo telemetry disable + + - name: Run example scripts (pharmacy, delegation, staff, ai-agent, …) + run: turbo run examples --filter @docknetwork/vc-delegation-engine diff --git a/examples/src/delegation.js b/examples/src/delegation.js index d65bbbf32..812cd3178 100644 --- a/examples/src/delegation.js +++ b/examples/src/delegation.js @@ -4,7 +4,7 @@ import { verifyPresentation, documentLoader as credentialDocumentLoader, } from '@docknetwork/credential-sdk/vc'; -import { MAY_CLAIM_IRI } from '@docknetwork/vc-delegation-engine'; +import { MAY_CLAIM_IRI, computePolicyDigestHex } from '@docknetwork/vc-delegation-engine'; import * as cedar from '@cedar-policy/cedar-wasm/nodejs'; const cedarPolicies = { @@ -24,6 +24,7 @@ permit( const AUTHORITY_DID = 'did:example:authority'; const DELEGATE_DID = 'did:example:delegator'; const SUBJECT_DID = 'did:example:alice'; +const POLICY_ID = 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10'; const CHALLENGE = 'credit-score-demo'; const DOMAIN = 'delegation.example'; const W3CONTEXT = 'https://www.w3.org/2018/credentials/v1'; @@ -61,6 +62,53 @@ const CREDIT_SCORE_CONTEXT = [ }, ]; +const delegationPolicy = { + id: POLICY_ID, + ruleset: { + capabilities: [ + { + name: 'profile', + schema: { + type: 'object', + }, + }, + { + name: 'status', + schema: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + ], + roles: [ + { + roleId: 'delegator', + parentRoleId: null, + attributes: ['*'], + capabilityGrants: [ + { + capability: 'profile', + schema: { + type: 'object', + }, + }, + { + capability: 'status', + schema: { + type: 'string', + enum: ['active', 'inactive'], + }, + }, + ], + }, + ], + overallConstraints: { + maxDelegationDepth: 1, + }, + }, +}; +const delegationPolicyDigest = computePolicyDigestHex(delegationPolicy); + const BASE_JWK_KEY = { type: 'JsonWebKey2020', publicKeyJwk: { @@ -139,6 +187,9 @@ async function exampleDocumentLoader(url) { if (url === DELEGATE_KEY.id) { return { contextUrl: null, documentUrl: url, document: DELEGATE_KEY }; } + if (url === POLICY_ID) { + return { contextUrl: null, documentUrl: url, document: delegationPolicy }; + } return baseLoader(url); } @@ -161,14 +212,25 @@ async function main() { }, }, }, - [MAY_CLAIM_IRI]: ['$.scope.profile.financial.creditScore', 'status'], + [MAY_CLAIM_IRI]: [ + '$.profile', + '$.profile.financial', + '$.profile.financial.creditScore', + 'status', + ], + profile: { + financial: { + creditScore: 800, + }, + }, + status: 'active', body: 'Issuer of Credit Scores', }, rootCredentialId: rootId, previousCredentialId: null, - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', + delegationPolicyId: POLICY_ID, + delegationPolicyDigest, + delegationRoleId: 'delegator', }); const creditScoreCredential = await issueCredential(DELEGATE_KEY, { @@ -181,13 +243,16 @@ async function main() { id: SUBJECT_DID, profile: { financial: { - creditScore: 760, + creditScore: 800, }, }, status: 'active', }, rootCredentialId: rootId, previousCredentialId: rootId, + delegationPolicyId: POLICY_ID, + delegationPolicyDigest, + delegationRoleId: 'delegator', }); const signedPresentation = await signPresentation( @@ -217,9 +282,14 @@ async function main() { console.log('Credential verifications:', verification.credentialResults.map((r) => r.verified)); if (verification.delegationResult) { - const { decision, summaries, authorizations } = verification.delegationResult; + const { + decision, summaries, authorizations, failures, + } = verification.delegationResult; console.log('Delegation decision:', decision); console.log('Authorized claims derived from chain:', summaries?.[0]?.authorizedClaims); + if (decision !== 'allow') { + console.log('Delegation failures:', failures); + } if (authorizations?.length) { console.log('Cedar authorization decisions:', authorizations); } diff --git a/packages/credential-sdk/.eslintrc.json b/packages/credential-sdk/.eslintrc.json new file mode 100644 index 000000000..511dcf384 --- /dev/null +++ b/packages/credential-sdk/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "babelOptions": { + "babelrc": false, + "configFile": false, + "plugins": ["@babel/plugin-syntax-import-attributes"], + "presets": ["@babel/preset-env"], + "sourceType": "module" + } + } +} diff --git a/packages/credential-sdk/package.json b/packages/credential-sdk/package.json index 49f84ed87..8160d3225 100644 --- a/packages/credential-sdk/package.json +++ b/packages/credential-sdk/package.json @@ -69,6 +69,7 @@ "devDependencies": { "@babel/cli": "^7.24.1", "@babel/core": "^7.24.3", + "@babel/eslint-parser": "^7.25.9", "@babel/node": "^7.23.9", "@babel/plugin-syntax-import-attributes": "^7.25.6", "@babel/plugin-transform-modules-commonjs": "^7.24.1", @@ -77,9 +78,9 @@ "@helia/strings": "^3.0.5", "@rollup/plugin-alias": "^4.0.2", "@rollup/plugin-babel": "^6.0.4", - "@rollup/plugin-commonjs": "^24.0.0", - "@rollup/plugin-json": "^4.1.0", - "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-wasm": "^5.1.0", "@types/jest": "^29.5.12", "babel-eslint": "^10.1.0", @@ -95,9 +96,9 @@ "jest-environment-node": "^29.7.0", "jsdoc": "^3.6.3", "jsdoc-typeof-plugin": "^1.0.0", - "rollup": "2.79.2", + "rollup": "^4.28.0", "rollup-plugin-copy": "^3.4.0", - "rollup-plugin-multi-input": "^1.3.2", + "rollup-plugin-multi-input": "^1.4.1", "rollup-plugin-node-polyfills": "^0.2.1", "rollup-plugin-terser": "^7.0.2" }, diff --git a/packages/credential-sdk/rollup.config.mjs b/packages/credential-sdk/rollup.config.mjs index cad8cbf4b..e39ffd903 100644 --- a/packages/credential-sdk/rollup.config.mjs +++ b/packages/credential-sdk/rollup.config.mjs @@ -1,37 +1,60 @@ -import json from "@rollup/plugin-json"; -import multiInput from "rollup-plugin-multi-input"; -import commonjs from "@rollup/plugin-commonjs"; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -export default async function () { - return [ - { - plugins: [ - multiInput(), - json(), - // terser(), - commonjs(), - // Temporarily disabled, not sure if required - // since rify is a node module doesnt seem to work - // but would be nice to try embed it - // wasm({ - // sync: ['*.wasm'], - // }), - ], - input: ["src/**/*.js"], - output: [ - { - sourcemap: true, - dir: "dist/esm", - format: "esm", - entryFileNames: "[name].js", - }, - { - sourcemap: true, - dir: "dist/cjs", - format: "cjs", - entryFileNames: "[name].cjs", - }, - ], - }, - ]; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import multiInput from 'rollup-plugin-multi-input'; + +const sep = path.sep; +const packageRoot = path.normalize(path.dirname(fileURLToPath(import.meta.url))); + +/** + * Rollup 4 follows deps into .wasm etc.; keep real dependencies external. + * Yarn workspaces may symlink this package under node_modules — still bundle files under this package root. + */ +function isExternal(id) { + if (id.startsWith('\0')) { + return false; + } + // Relative paths and multi-input entries like "src/crypto/index.js" (no leading ./) + if (id.startsWith('.') || id.startsWith(`src${sep}`) || id.startsWith('src/')) { + return false; + } + if (path.isAbsolute(id)) { + const normalized = path.normalize(id); + if (normalized.startsWith(packageRoot)) { + return false; + } + return id.includes(`${sep}node_modules${sep}`); + } + return true; } + +export default [ + { + input: ['src/**/*.js'], + external: isExternal, + plugins: [ + multiInput(), + nodeResolve({ preferBuiltins: true }), + json({ preferConst: true }), + commonjs(), + ], + output: [ + { + sourcemap: true, + dir: 'dist/esm', + format: 'esm', + entryFileNames: '[name].js', + }, + { + sourcemap: true, + dir: 'dist/cjs', + format: 'cjs', + entryFileNames: '[name].cjs', + interop: 'auto', + }, + ], + }, +]; diff --git a/packages/credential-sdk/src/vc/contexts.js b/packages/credential-sdk/src/vc/contexts.js index 623dfaacd..f10b9eebd 100644 --- a/packages/credential-sdk/src/vc/contexts.js +++ b/packages/credential-sdk/src/vc/contexts.js @@ -1,24 +1,24 @@ -import vcExamplesV1 from './contexts/vc-examples-v1'; -import ed25519V1Context from './contexts/ed25519-2020-v1-context.json'; -import sr25519Context from './contexts/sr25519-context.json'; -import secContext from './contexts/security_context'; -import secContextV1 from './contexts/security-v1.json'; -import didV1Context from './contexts/did-v1-updated.json'; -import credV1Context from './contexts/credential-v1-updated.json'; -import schema from './contexts/schema.json'; -import odrl from './contexts/odrl.json'; -import bbsV1Context from './contexts/bbs-v1.json'; -import dockBBSV1Context from './contexts/dock-bbs-v1.json'; -import dockBBS23V1Context from './contexts/dock-bbs23-v1.json'; -import dockPSV1Context from './contexts/dock-ps-v1.json'; -import dockBBDT16V1Context from './contexts/dock-bbdt16-v1.json'; -import dockPrettyVCContext from './contexts/prettyvc.json'; -import jws2020V1Context from './contexts/jws-2020-v1.json'; -import delegationCredentialContext from './contexts/delegation-credential.json'; -import statusList21Context from './contexts/status-list-21'; -import privateStatusList21Context from './contexts/private-status-list-21'; -import sphereonId from './contexts/sphereon-wallet-identity-v1.json'; -import citizenshipContext from './contexts/citizen-v1.json'; +import vcExamplesV1 from './contexts/vc-examples-v1.js'; +import ed25519V1Context from './contexts/ed25519-2020-v1-context.json' with { type: 'json' }; +import sr25519Context from './contexts/sr25519-context.json' with { type: 'json' }; +import secContext from './contexts/security_context.js'; +import secContextV1 from './contexts/security-v1.json' with { type: 'json' }; +import didV1Context from './contexts/did-v1-updated.json' with { type: 'json' }; +import credV1Context from './contexts/credential-v1-updated.json' with { type: 'json' }; +import schema from './contexts/schema.json' with { type: 'json' }; +import odrl from './contexts/odrl.json' with { type: 'json' }; +import bbsV1Context from './contexts/bbs-v1.json' with { type: 'json' }; +import dockBBSV1Context from './contexts/dock-bbs-v1.json' with { type: 'json' }; +import dockBBS23V1Context from './contexts/dock-bbs23-v1.json' with { type: 'json' }; +import dockPSV1Context from './contexts/dock-ps-v1.json' with { type: 'json' }; +import dockBBDT16V1Context from './contexts/dock-bbdt16-v1.json' with { type: 'json' }; +import dockPrettyVCContext from './contexts/prettyvc.json' with { type: 'json' }; +import jws2020V1Context from './contexts/jws-2020-v1.json' with { type: 'json' }; +import delegationCredentialContext from './contexts/delegation-credential.json' with { type: 'json' }; +import statusList21Context from './contexts/status-list-21.js'; +import privateStatusList21Context from './contexts/private-status-list-21.js'; +import sphereonId from './contexts/sphereon-wallet-identity-v1.json' with { type: 'json' }; +import citizenshipContext from './contexts/citizen-v1.json' with { type: 'json' }; // Lookup of following URLs will lead to loading data from the context directory, this is done as the Sr25519 keys are not // supported in any W3C standard and vc-js has them stored locally. This is a temporary solution. diff --git a/packages/credential-sdk/src/vc/contexts/delegation-credential.json b/packages/credential-sdk/src/vc/contexts/delegation-credential.json index cced578fc..f690f6d18 100644 --- a/packages/credential-sdk/src/vc/contexts/delegation-credential.json +++ b/packages/credential-sdk/src/vc/contexts/delegation-credential.json @@ -24,6 +24,9 @@ "delegationPolicyDigest": { "@id": "dockDelegation:delegationPolicyDigest" }, + "delegationRoleId": { + "@id": "dockDelegation:delegationRoleId" + }, "mayClaim": { "@id": "dockalpha:mayClaim", "@container": "@set" diff --git a/packages/credential-sdk/src/vc/document-loader.js b/packages/credential-sdk/src/vc/document-loader.js index ddbc6c4f1..202ddcdde 100644 --- a/packages/credential-sdk/src/vc/document-loader.js +++ b/packages/credential-sdk/src/vc/document-loader.js @@ -1,6 +1,5 @@ -import cachedUris from './contexts'; -import Resolver from "../resolver/generic/resolver"; // eslint-disable-line -import jsonFetch from '../utils/json-fetch'; +import cachedUris from './contexts.js'; +import jsonFetch from '../utils/json-fetch.js'; function parseEmbeddedDataURI(embedded) { // Strip new lines @@ -28,11 +27,16 @@ function parseEmbeddedDataURI(embedded) { return JSON.parse(dataStr); } +/** + * @typedef {Object} DocumentLoaderResolver + * @property {function(string): boolean} [supports] + * @property {function(string): Promise<*>} [resolve] + */ + /** * Takes a resolver and returns a function that returns a document or throws an error when the document * cannot be found. - * @param {Resolver} [resolver] - The resolver is optional but should be passed when - * `DID`s / `StatusList2021Credential`s / `Blob`s / revocation registries and other documents need to be resolved. + * @param {DocumentLoaderResolver|null} [resolver] - Optional resolver for DID / status list / blob URLs when not served from the built-in context cache. * @returns {loadDocument} - the returned function */ function documentLoader(resolver = null) { @@ -55,7 +59,7 @@ function documentLoader(resolver = null) { } else { // Strip ending slash from uri to determine cache key const cacheKey = uriString.endsWith('/') - ? uriString.substring(0, uri.length - 1) + ? uriString.substring(0, uriString.length - 1) : uriString; // Check its not in data cache diff --git a/packages/credential-sdk/src/vc/presentations.js b/packages/credential-sdk/src/vc/presentations.js index 3d18be2a5..aee4aba83 100644 --- a/packages/credential-sdk/src/vc/presentations.js +++ b/packages/credential-sdk/src/vc/presentations.js @@ -112,6 +112,7 @@ async function optionalDelegationValidation( failOnUnauthorizedClaims: delegationOptions.failOnUnauthorizedClaims ?? true, cedar: cedarOptions.cedar, policies, + delegationPolicy: delegationOptions.delegationPolicy, }); const delegationVerified = delegationResult.decision === 'allow'; @@ -235,10 +236,11 @@ export async function verifyPresentationCredentials( * @property {Boolean} [unsignedPresentation] - Whether to verify the proof or not * @property {Boolean} [compactProof] - Whether to compact the JSON-LD or not. * @property {object} [presentationPurpose] - A purpose other than the default AuthenticationProofPurpose - * @property {object} [documentLoader] - A document loader, can be null and use the default + * @property {object} [documentLoader] - A document loader, can be null and use the default (built from `resolver` when omitted). Must resolve each `delegationPolicyId` to the policy JSON when credentials reference a delegation policy. * @property {boolean} [failOnUnauthorizedClaims] - When true, delegation chains that emit unauthorized claims cause verification to fail * @property {object} [policies] - Optional delegation policy bundle forwarded to @docknetwork/vc-delegation-engine * @property {object} [cedarAuth] - Optional cedar authorization configuration forwarded to @docknetwork/vc-delegation-engine + * @property {object} [delegationPolicy] - Optional: set `enabled: false` to skip delegation policy digest/schema/chain checks even when credentials carry `delegationPolicyId` / `delegationPolicyDigest`. */ /** @@ -281,6 +283,7 @@ export async function verifyPresentation(presentation, options = {}) { const delegationOptions = { failOnUnauthorizedClaims: options.failOnUnauthorizedClaims, cedar: options.cedarAuth, + delegationPolicy: options.delegationPolicy, }; // Build verification options diff --git a/packages/credential-sdk/tests/credential-delegation.test.js b/packages/credential-sdk/tests/credential-delegation.test.js index c39c66fe2..fb3cf087d 100644 --- a/packages/credential-sdk/tests/credential-delegation.test.js +++ b/packages/credential-sdk/tests/credential-delegation.test.js @@ -171,9 +171,6 @@ async function buildDelegationChain({ includeSecondDelegation = false }) { }, rootCredentialId: rootId, previousCredentialId: null, - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', }); const secondDelegationCredential = includeSecondDelegation diff --git a/packages/vc-delegation-engine/.eslintrc.json b/packages/vc-delegation-engine/.eslintrc.json new file mode 100644 index 000000000..511dcf384 --- /dev/null +++ b/packages/vc-delegation-engine/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "babelOptions": { + "babelrc": false, + "configFile": false, + "plugins": ["@babel/plugin-syntax-import-attributes"], + "presets": ["@babel/preset-env"], + "sourceType": "module" + } + } +} diff --git a/packages/vc-delegation-engine/examples/delegation-chain.js b/packages/vc-delegation-engine/examples/delegation-chain.js index 2ce3cbb08..abcf4ae25 100644 --- a/packages/vc-delegation-engine/examples/delegation-chain.js +++ b/packages/vc-delegation-engine/examples/delegation-chain.js @@ -64,9 +64,6 @@ await runScenario('AUTHORIZED CREDIT SCORE', { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { // did:dock:b or relying parties may claim all fields, besides body id: 'did:dock:b', diff --git a/packages/vc-delegation-engine/examples/document-loader.js b/packages/vc-delegation-engine/examples/document-loader.js index 7f73211cd..7016217c4 100644 --- a/packages/vc-delegation-engine/examples/document-loader.js +++ b/packages/vc-delegation-engine/examples/document-loader.js @@ -1,7 +1,20 @@ import credentialDocumentLoader from '../../credential-sdk/src/vc/document-loader.js'; +import pharmacyDelegationPolicy from '../tests/fixtures/delegation-pharmacy-policy.json' with { type: 'json' }; const loadDocumentDefault = credentialDocumentLoader(); -export default function documentLoader(id) { - return loadDocumentDefault(id); +/** + * JSON-LD document loader that also resolves the pharmacy delegation policy fixture by policy id + * (so examples/tests do not pass a separate policy resolver). + * @param {string} url + */ +export default function documentLoader(url) { + if (url === pharmacyDelegationPolicy.id) { + return Promise.resolve({ + contextUrl: null, + documentUrl: url, + document: structuredClone(pharmacyDelegationPolicy), + }); + } + return loadDocumentDefault(url); } diff --git a/packages/vc-delegation-engine/examples/multi-delegation-vp.js b/packages/vc-delegation-engine/examples/multi-delegation-vp.js index 7385ca3d6..0b953285f 100644 --- a/packages/vc-delegation-engine/examples/multi-delegation-vp.js +++ b/packages/vc-delegation-engine/examples/multi-delegation-vp.js @@ -50,9 +50,6 @@ const delegationToB = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['creditScore'], diff --git a/packages/vc-delegation-engine/examples/pharmacy.js b/packages/vc-delegation-engine/examples/pharmacy.js index a1e412750..359c587c0 100644 --- a/packages/vc-delegation-engine/examples/pharmacy.js +++ b/packages/vc-delegation-engine/examples/pharmacy.js @@ -1,215 +1,17 @@ import { runScenario } from './helpers.js'; +import { + pharmacyPolicies, + pharmacyPresentations, +} from '../tests/fixtures/pharmacy.js'; -// This is a cedar verification policy that allows to check the PickUp claim/action -const policyText = ` -permit( - principal in Credential::Chain::"Action:Verify", - action == Credential::Action::"Verify", +const policies = pharmacyPolicies; - // Resource must be a prescription credential type, we can use this to define permit blocks per credential type - resource == Credential::Object::"Prescription" -) when { - // Verifier ensures that the principal matches presentation signer - principal == context.vpSigner && +await runScenario('GUARDIAN PRESENT', pharmacyPresentations.guardianAllowed, policies); - // Verifier ensures the root credential is a Prescription type - context.rootTypes.contains("https://example.org/credentials#Prescription") && +await runScenario('PATIENT PRESENT', pharmacyPresentations.patient, policies); - // Verifier ensures the tail credential is a PrescriptionUsage type - context.tailTypes.contains("https://example.org/credentials#PrescriptionUsage") && - - // Verifier ensures a specific root issuer, i.e the doctor - context.rootIssuer == Credential::Actor::"did:test:doctor" && - - // Verifier ensures that PickUp is true in final list of authorized claims - context.authorizedClaims.PickUp == true -}; - -forbid( - principal in Credential::Chain::"Action:Verify", - action == Credential::Action::"Verify", - resource in Credential::Chain::"Action:Verify" -) when { - // Max depth of 3 delegated credentials - context.tailDepth > 3 -}; -`; - -const policies = { staticPolicies: policyText }; -const BASE_DELEGATION_CONTEXT = [ - 'https://www.w3.org/2018/credentials/v1', - 'https://ld.truvera.io/credentials/delegation', -]; - -// NOTE: to validate that a client is authorized to pay or pickup, a valid PEX request should be used -// the delegation verfication ensures that the chain of claims is valid. The cedar policy allows custom business logic. -// This is useful so that the patient cannot issue any claim to their guardian that they want, while still allowing predefined claims. -// The verifier can trace the chain of delegation to the root to access the prescription resource, knowing the presenter is authorized -// to perform actions on it. Actions in this case are simply credential subject claims with true/false booleean value. - -// Initial prescription from doctor to pharmacy, delegating the pharmacy the option to delegate the prescription to the patient. -const PRESCRIPTION_CREDENTIAL = { - '@context': [ - ...BASE_DELEGATION_CONTEXT, - { - '@version': 1.1, - ex: 'https://example.org/credentials#', - Prescription: 'ex:Prescription', - prescribes: { '@id': 'ex:prescribes', '@type': '@id' }, - }, - ], - id: 'urn:cred:pres-001', - type: ['VerifiableCredential', 'Prescription', 'DelegationCredential'], - issuer: 'did:test:doctor', - rootCredentialId: 'urn:cred:pres-001', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', - credentialSubject: { - id: 'did:test:pharmacy', - 'https://rdf.dock.io/alpha/2021#mayClaim': ['Cancel', 'PickUp', 'Pay'], - prescribes: { id: 'urn:rx:789' }, - }, -}; - -const PRESENTATION_FIELDS = { - "@context": [ - "https://www.w3.org/2018/credentials/v1" - ], - "id": "https://example.com/pres/myid", - "type": [ - "VerifiablePresentation" - ], - "proof": { - "type": "Ed25519Signature2018", - "created": "2025-01-17T12:15:51Z", - "verificationMethod": "did:test:guardian#test", - "proofPurpose": "authentication", - "challenge": "1234567890", - "domain": "myissuer", - "jws": "test..test" - } -}; - -const PRESCRIPTION_USAGE_BASE_FIELDS = { - '@context': [ - ...BASE_DELEGATION_CONTEXT, - { - '@version': 1.1, - ex: 'https://example.org/credentials#', - xsd: 'http://www.w3.org/2001/XMLSchema#', - PrescriptionUsage: 'ex:PrescriptionUsage', - PickUp: { '@id': 'ex:PickUp', '@type': 'xsd:boolean' }, - Pay: { '@id': 'ex:Pay', '@type': 'xsd:boolean' }, - }, - ], -}; - -await runScenario('GUARDIAN PRESENT', { - ...PRESENTATION_FIELDS, - proof: { - ...PRESENTATION_FIELDS.proof, - "verificationMethod": "did:test:guardian#test", - }, - verifiableCredential: [ - PRESCRIPTION_CREDENTIAL, - - // Pharmacy issues pick up and pay authorisation for the patient. The pharmacy has given the patient - // the ability to delegate picking up and paying for the prescription to the guardian. - { - ...PRESCRIPTION_USAGE_BASE_FIELDS, - id: 'urn:cred:pp-001', - type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], - issuer: 'did:test:pharmacy', - previousCredentialId: 'urn:cred:pres-001', - rootCredentialId: 'urn:cred:pres-001', - credentialSubject: { - id: 'did:test:patient', - 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], - PickUp: true, - Pay: true, - }, - }, - // Patient delegates their pick up to the guardian, but they dont want the guardian to be able to pay. - // For this they put a claim "PickUp: true" and "Pay: false" (or not defined at all) in the credential subject. - { - ...PRESCRIPTION_USAGE_BASE_FIELDS, - id: 'urn:cred:pg-001', - type: ['VerifiableCredential', 'PrescriptionUsage'], // note no DelegationCredential here, that ends the chain - issuer: 'did:test:patient', - previousCredentialId: 'urn:cred:pp-001', - rootCredentialId: 'urn:cred:pres-001', - credentialSubject: { - id: 'did:test:guardian', - PickUp: true - }, - }, - ], -}, policies, 'urn:rx:789'); - -await runScenario('PATIENT PRESENT', { - ...PRESENTATION_FIELDS, - proof: { - ...PRESENTATION_FIELDS.proof, - "verificationMethod": "did:test:patient#test", - }, - verifiableCredential: [ - PRESCRIPTION_CREDENTIAL, - - { - ...PRESCRIPTION_USAGE_BASE_FIELDS, - id: 'urn:cred:pp-001', - type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], - issuer: 'did:test:pharmacy', - previousCredentialId: 'urn:cred:pres-001', - rootCredentialId: 'urn:cred:pres-001', - credentialSubject: { - 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], - id: 'did:test:patient', PickUp: true, Pay: true - }, - }, - ], -}, policies, 'urn:rx:789'); - -await runScenario('GUARDIAN PRESENT NOT ALLOWED PICKUP', { - ...PRESENTATION_FIELDS, - proof: { - ...PRESENTATION_FIELDS.proof, - "verificationMethod": "did:test:guardian#test", - }, - verifiableCredential: [ - PRESCRIPTION_CREDENTIAL, - - // Pharmacy issues pick up and pay authorisation for the patient. The pharmacy has given the patient - // the ability to delegate picking up and paying for the prescription to the guardian. - { - ...PRESCRIPTION_USAGE_BASE_FIELDS, - id: 'urn:cred:pp-001', - type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], - issuer: 'did:test:pharmacy', - previousCredentialId: 'urn:cred:pres-001', - rootCredentialId: 'urn:cred:pres-001', - credentialSubject: { - id: 'did:test:patient', - 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], - PickUp: true, - Pay: true, - }, - }, - // Patient delegates pay to the guardian, but they dont want the guardian to be able to pick up. - // For this they put a claim "Pay: true" and "PickUp: false" (or not defined at all) in the credential subject. - { - ...PRESCRIPTION_USAGE_BASE_FIELDS, - id: 'urn:cred:pg-001', - type: ['VerifiableCredential', 'PrescriptionUsage'], // note no DelegationCredential here, that ends the chain - issuer: 'did:test:patient', - previousCredentialId: 'urn:cred:pp-001', - rootCredentialId: 'urn:cred:pres-001', - credentialSubject: { - id: 'did:test:guardian', - PickUp: false, - Pay: true, - }, - }, - ], -}, policies, 'urn:rx:789'); +await runScenario( + 'GUARDIAN PRESENT NOT ALLOWED PICKUP', + pharmacyPresentations.guardianDenied, + policies, +); diff --git a/packages/vc-delegation-engine/examples/simple-delegation.js b/packages/vc-delegation-engine/examples/simple-delegation.js index bab412d5e..9283ac67e 100644 --- a/packages/vc-delegation-engine/examples/simple-delegation.js +++ b/packages/vc-delegation-engine/examples/simple-delegation.js @@ -48,9 +48,6 @@ const authorizedDelegation = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['creditScore'], @@ -93,9 +90,6 @@ const unauthorizedDelegation = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['noClaim'], diff --git a/packages/vc-delegation-engine/package.json b/packages/vc-delegation-engine/package.json index 32d1c2b8c..d747f8307 100644 --- a/packages/vc-delegation-engine/package.json +++ b/packages/vc-delegation-engine/package.json @@ -19,13 +19,14 @@ ], "scripts": { "clean": "rimraf dist", - "build:esm": "esbuild src/index.js --bundle --format=esm --platform=node --sourcemap --outfile=dist/esm/index.js --external:@cedar-policy/cedar-wasm/nodejs --external:jsonld --external:rify", - "build:cjs": "esbuild src/index.js --bundle --format=cjs --platform=node --sourcemap --outfile=dist/cjs/index.cjs --external:@cedar-policy/cedar-wasm/nodejs --external:jsonld --external:rify", + "build:esm": "esbuild src/index.js --bundle --format=esm --platform=node --sourcemap --outfile=dist/esm/index.js --external:@cedar-policy/cedar-wasm/nodejs --external:ajv --external:ajv-formats --external:json-canonicalize --external:jsonld --external:rify", + "build:cjs": "esbuild src/index.js --bundle --format=cjs --platform=node --sourcemap --outfile=dist/cjs/index.cjs --external:@cedar-policy/cedar-wasm/nodejs --external:ajv --external:ajv-formats --external:json-canonicalize --external:jsonld --external:rify", "build:types": "tsc -p tsconfig.build.json", "build": "yarn clean && yarn build:esm && yarn build:cjs && yarn build:types", - "lint": "eslint \"src/**/*.js\" \"tests/**/*.js\"", + "lint": "eslint \"src/**/*.js\" \"tests/**/*.js\" \"examples/document-loader.js\"", "docs-disabled": "rm -rf out && mkdir out && touch out/.nojekyll && jsdoc src -r -c ../../.jsdoc -d out/reference", "test": "vitest run --coverage ./tests", + "examples": "node examples/pharmacy.js && node examples/simple-delegation.js && node examples/multi-delegation-vp.js && node examples/delegation-chain.js && node examples/staff-delegation.js && node examples/non-delegated-presentation.js && node examples/ai-agent.js", "prepublishOnly": "yarn build" }, "repository": { @@ -36,7 +37,10 @@ "author": "", "license": "ISC", "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "base64url": "^3.0.1", + "json-canonicalize": "^1.0.4", "jsonpath-plus": "^10.1.0", "jsonld": "^6.0.0", "rify": "^0.7.1" @@ -45,6 +49,10 @@ "@cedar-policy/cedar-wasm": "^4.5.0" }, "devDependencies": { + "@babel/core": "^7.24.3", + "@babel/eslint-parser": "^7.25.9", + "@babel/plugin-syntax-import-attributes": "^7.25.6", + "@babel/preset-env": "^7.24.3", "@cedar-policy/cedar-wasm": "^4.5.0", "@types/minimatch": "^6.0.0", "@types/node": "^24.10.1", diff --git a/packages/vc-delegation-engine/src/claim-deduction.js b/packages/vc-delegation-engine/src/claim-deduction.js index 740fa1330..b2fce14ec 100644 --- a/packages/vc-delegation-engine/src/claim-deduction.js +++ b/packages/vc-delegation-engine/src/claim-deduction.js @@ -1,7 +1,6 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { collectSubjectClaimEntries } from './utils.js'; -export function collectAuthorizedClaims(chain, derivedFacts, authorizedGraphId, allCredentials = []) { +function buildSubjectClaimValueLookup(allCredentials) { const valueLookup = new Map(); (allCredentials ?? []).forEach((vc) => { const subjectId = vc?.credentialSubject?.id; @@ -16,9 +15,11 @@ export function collectAuthorizedClaims(chain, derivedFacts, authorizedGraphId, valueLookup.set(`${subjectId}::${key}`, value); }); }); + return valueLookup; +} +function mergeDerivedFactsIntoClaimsBySubject(derivedFacts, authorizedGraphId, valueLookup) { const claimsBySubject = new Map(); - (derivedFacts ?? []).forEach(([subject, claim, value, graph]) => { if (graph !== authorizedGraphId) { return; @@ -30,27 +31,41 @@ export function collectAuthorizedClaims(chain, derivedFacts, authorizedGraphId, const lookupKey = `${subject}::${claim}`; subjectClaims[claim] = valueLookup.has(lookupKey) ? valueLookup.get(lookupKey) : value; }); + return claimsBySubject; +} - const tailSubjectId = chain[chain.length - 1]?.credentialSubject?.id; - const perSubjectObject = {}; - claimsBySubject.forEach((value, key) => { - perSubjectObject[key] = { ...value }; - }); - - const unionClaims = tailSubjectId && perSubjectObject[tailSubjectId] - ? { ...perSubjectObject[tailSubjectId] } +function buildUnionFromPerSubject(claimsBySubject, tailSubjectId) { + const base = tailSubjectId && claimsBySubject.has(tailSubjectId) + ? { ...claimsBySubject.get(tailSubjectId) } : {}; - claimsBySubject.forEach((value, key) => { if (key === tailSubjectId) { return; } Object.entries(value).forEach(([claim, claimValue]) => { - if (!(claim in unionClaims)) { - unionClaims[claim] = claimValue; + if (!(claim in base)) { + base[claim] = claimValue; } }); }); + return base; +} + +export function collectAuthorizedClaims(chain, derivedFacts, authorizedGraphId, allCredentials = []) { + const valueLookup = buildSubjectClaimValueLookup(allCredentials); + const claimsBySubject = mergeDerivedFactsIntoClaimsBySubject( + derivedFacts, + authorizedGraphId, + valueLookup, + ); + + const tailSubjectId = chain[chain.length - 1]?.credentialSubject?.id; + const perSubjectObject = {}; + claimsBySubject.forEach((value, key) => { + perSubjectObject[key] = { ...value }; + }); + + const unionClaims = buildUnionFromPerSubject(claimsBySubject, tailSubjectId); return { perSubject: perSubjectObject, diff --git a/packages/vc-delegation-engine/src/constants.js b/packages/vc-delegation-engine/src/constants.js index 347de8089..8e21345cd 100644 --- a/packages/vc-delegation-engine/src/constants.js +++ b/packages/vc-delegation-engine/src/constants.js @@ -14,6 +14,12 @@ export const DELEGATION_NS = 'https://ld.truvera.io/credentials/delegation#'; export const VC_TYPE_DELEGATION_CREDENTIAL = `${DELEGATION_NS}DelegationCredential`; export const VC_PREVIOUS_CREDENTIAL_ID = `${DELEGATION_NS}previousCredentialId`; export const VC_ROOT_CREDENTIAL_ID = `${DELEGATION_NS}rootCredentialId`; +export const VC_DELEGATION_POLICY_ID = `${DELEGATION_NS}delegationPolicyId`; +export const VC_DELEGATION_POLICY_DIGEST = `${DELEGATION_NS}delegationPolicyDigest`; +export const VC_DELEGATION_ROLE_ID = `${DELEGATION_NS}delegationRoleId`; + +export const VC_ISSUANCE_DATE = `${VC_NS}issuanceDate`; +export const VC_EXPIRATION_DATE = `${VC_NS}expirationDate`; export const ACTION_VERIFY = 'Verify'; export const VERIFY_CHAIN_ID = 'Action:Verify'; diff --git a/packages/vc-delegation-engine/src/delegation-policy-chain.js b/packages/vc-delegation-engine/src/delegation-policy-chain.js new file mode 100644 index 000000000..8d306e975 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy-chain.js @@ -0,0 +1 @@ +export * from './delegation-policy/chain-public.js'; diff --git a/packages/vc-delegation-engine/src/delegation-policy-digest.js b/packages/vc-delegation-engine/src/delegation-policy-digest.js new file mode 100644 index 000000000..60c686c14 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy-digest.js @@ -0,0 +1,60 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { canonicalize } from 'json-canonicalize'; + +/** + * @param {object} policyObject + * @returns {string} + */ +export function canonicalPolicyJson(policyObject) { + return canonicalize(policyObject); +} + +/** + * @param {string} inputUtf8 + * @returns {string} lowercase hex + */ +export function sha256Hex(inputUtf8) { + return createHash('sha256').update(inputUtf8, 'utf8').digest('hex'); +} + +/** + * @param {object} policyObject + * @returns {string} + */ +export function computePolicyDigestHex(policyObject) { + return sha256Hex(canonicalPolicyJson(policyObject)); +} + +/** + * Constant-time comparison of two lowercase hex strings (SHA-256 length). + * @param {string} expectedHex + * @param {string} actualHex + * @returns {boolean} + */ +export function digestsEqual(expectedHex, actualHex) { + if ( + typeof expectedHex !== 'string' + || typeof actualHex !== 'string' + || expectedHex.length !== actualHex.length + ) { + return false; + } + if (!/^[0-9a-f]+$/i.test(expectedHex) || !/^[0-9a-f]+$/i.test(actualHex)) { + return false; + } + try { + return timingSafeEqual(Buffer.from(expectedHex, 'hex'), Buffer.from(actualHex, 'hex')); + } catch { + return false; + } +} + +/** + * @param {object} policyObject + * @param {string} expectedHex + * @returns {boolean} + */ +export function verifyPolicyDigest(policyObject, expectedHex) { + const computed = computePolicyDigestHex(policyObject); + return digestsEqual(String(expectedHex).toLowerCase(), computed.toLowerCase()); +} diff --git a/packages/vc-delegation-engine/src/delegation-policy-validate.js b/packages/vc-delegation-engine/src/delegation-policy-validate.js new file mode 100644 index 000000000..60756ecdc --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy-validate.js @@ -0,0 +1,7 @@ +export { + attributesAreNarrowerOrEqual, + validateDelegationPolicy, + validateDelegationPolicySemantics, +} from './delegation-policy/policy-semantics.js'; +export { assertGrantSchemaNarrowing } from './delegation-policy/grant-schema-narrowing.js'; +export { validateCapabilityValueAgainstSchema } from './delegation-policy/capability-value-schema.js'; diff --git a/packages/vc-delegation-engine/src/delegation-policy/capability-value-schema.js b/packages/vc-delegation-engine/src/delegation-policy/capability-value-schema.js new file mode 100644 index 000000000..18f60ddd3 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/capability-value-schema.js @@ -0,0 +1,32 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { DelegationError, DelegationErrorCodes } from '../errors.js'; + +let valueAjv = null; + +function getValueAjv() { + if (!valueAjv) { + valueAjv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(valueAjv); + } + return valueAjv; +} + +/** + * @param {unknown} value + * @param {object} schema + * @param {string} label + */ +export function validateCapabilityValueAgainstSchema(value, schema, label) { + const ajv = getValueAjv(); + const validate = ajv.compile(schema); + if (validate(value)) { + return; + } + const detail = validate.errors?.map((e) => `${e.instancePath || '/'} ${e.message}`).join('; ') + ?? 'validation error'; + throw new DelegationError( + DelegationErrorCodes.POLICY_CAPABILITY_INVALID, + `Capability value invalid for ${label}: ${detail}`, + ); +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-depth.js b/packages/vc-delegation-engine/src/delegation-policy/chain-depth.js new file mode 100644 index 000000000..52b612330 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-depth.js @@ -0,0 +1,23 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { isDelegationCredentialType } from './chain-types-binding.js'; + +/** + * Onward delegation steps = delegation credentials after the root credential in the chain. + * @param {object[]} chain + * @param {number} maxDepth + */ +export function assertMaxDelegationDepth(chain, maxDepth) { + const delegationIndices = chain + .map((vc, i) => (isDelegationCredentialType(vc) ? i : -1)) + .filter((i) => i >= 0); + if (delegationIndices.length === 0) { + return; + } + const onward = delegationIndices.filter((index) => index > 0).length; + if (onward > maxDepth) { + throw new DelegationError( + DelegationErrorCodes.POLICY_DEPTH_EXCEEDED, + `Delegation chain has ${onward} onward delegation step(s), max allowed is ${maxDepth}`, + ); + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-monotonic.js b/packages/vc-delegation-engine/src/delegation-policy/chain-monotonic.js new file mode 100644 index 000000000..3ea213f1f --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-monotonic.js @@ -0,0 +1,153 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { RESERVED_SUBJECT_KEYS } from './reserved-subject-keys.js'; + +function normalizeTokenValue(value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeTokenValue(item)); + } + if (value && typeof value === 'object') { + return Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = normalizeTokenValue(value[key]); + return acc; + }, {}); + } + return value; +} + +function scalarTokenForSet(v) { + return (v && typeof v === 'object') ? JSON.stringify(normalizeTokenValue(v)) : v; +} + +function objectValuesEqual(parentVal, childVal) { + if (!parentVal || !childVal || typeof parentVal !== 'object' || typeof childVal !== 'object') { + return false; + } + return scalarTokenForSet(parentVal) === scalarTokenForSet(childVal); +} + +function valueAsComparisonArray(val) { + if (Array.isArray(val)) { + return val; + } + if (val != null) { + return [val]; + } + return []; +} + +function assertBooleanNarrowing(parentVal, childVal, label) { + if (childVal === true && parentVal !== true) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `${label} broadens a boolean claim relative to the parent credential`, + ); + } +} + +function assertNumericNarrowing(parentVal, childVal, label) { + if (childVal > parentVal) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `${label} must not exceed the parent credential numeric cap`, + ); + } +} + +function assertArraySubsetNarrowing(parentVal, childVal, label) { + const pArr = valueAsComparisonArray(parentVal); + const cArr = valueAsComparisonArray(childVal); + const parentSet = new Set(pArr.map(scalarTokenForSet)); + const ok = cArr.every((v) => parentSet.has(scalarTokenForSet(v))); + if (!ok) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `${label} is not a subset of the parent credential value`, + ); + } +} + +function assertComparableValueNarrowerOrEqual(parentVal, childVal, label) { + if (typeof parentVal === 'boolean' || typeof childVal === 'boolean') { + assertBooleanNarrowing(parentVal, childVal, label); + return; + } + const bothNumbers = typeof parentVal === 'number' && typeof childVal === 'number' + && !Number.isNaN(parentVal) && !Number.isNaN(childVal); + if (bothNumbers) { + assertNumericNarrowing(parentVal, childVal, label); + return; + } + if (Array.isArray(parentVal) || Array.isArray(childVal)) { + assertArraySubsetNarrowing(parentVal, childVal, label); + return; + } + if (objectValuesEqual(parentVal, childVal)) { + return; + } + if (parentVal !== childVal) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `${label} must match the parent credential value or narrow it`, + ); + } +} + +/** + * @param {object} parentVc + * @param {object} childVc + * @param {Set} capabilityNames + */ +export function assertAdjacentCredentialsMonotonic(parentVc, childVc, capabilityNames) { + const parentSub = parentVc.credentialSubject ?? {}; + const childSub = childVc.credentialSubject ?? {}; + for (const cap of capabilityNames) { + if (!Object.prototype.hasOwnProperty.call(childSub, cap)) { + // eslint-disable-next-line no-continue + continue; + } + if (!Object.prototype.hasOwnProperty.call(parentSub, cap)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `Credential ${childVc.id} includes capability field "${cap}" not present on parent ${parentVc.id}`, + ); + } + assertComparableValueNarrowerOrEqual( + parentSub[cap], + childSub[cap], + `Credential ${childVc.id} subject.${cap}`, + ); + } + for (const key of Object.keys(childSub)) { + if (RESERVED_SUBJECT_KEYS.has(key) || capabilityNames.has(key)) { + // eslint-disable-next-line no-continue + continue; + } + if (!Object.prototype.hasOwnProperty.call(parentSub, key)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION, + `Credential ${childVc.id} discloses "${key}" which is absent on parent ${parentVc.id}`, + ); + } + assertComparableValueNarrowerOrEqual( + parentSub[key], + childSub[key], + `Credential ${childVc.id} subject.${key}`, + ); + } +} + +/** + * Each credential after the root must not broaden subject claims vs its immediate predecessor. + * @param {object[]} chain + * @param {Set} capabilityNames + */ +export function assertChainCredentialMonotonicity(chain, capabilityNames) { + if (!Array.isArray(chain) || chain.length < 2) { + return; + } + for (let i = 1; i < chain.length; i += 1) { + assertAdjacentCredentialsMonotonic(chain[i - 1], chain[i], capabilityNames); + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-public.js b/packages/vc-delegation-engine/src/delegation-policy/chain-public.js new file mode 100644 index 000000000..b1b39a8b3 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-public.js @@ -0,0 +1,24 @@ +/** + * Public API for delegation credential chain checks (re-export surface for `delegation-policy-chain.js`). + */ +export { durationToMilliseconds } from '../utils/duration.js'; +export { + coerceCapabilityValueForSchema, + isDelegationCredentialType, + assertDelegationPolicyRootPlacement, + extractRootPolicyBinding, + assertPolicyBindingsConsistentInChain, +} from './chain-types-binding.js'; +export { assertMaxDelegationDepth } from './chain-depth.js'; +export { + isRoleAncestorOrEqual, + isRoleStrictSubRole, + assertChildCredentialExpiresBeforeOrEqualParent, +} from './chain-roles-lifetime.js'; +export { subjectFieldDisclosureAllowedByRole } from './chain-subject-role.js'; +export { + assertAdjacentCredentialsMonotonic, + assertChainCredentialMonotonicity, +} from './chain-monotonic.js'; +export { verifyDelegationPolicyChain } from './chain-verify.js'; +export { fetchDelegationPolicyJson, resolveAndVerifyDelegationPolicy } from './policy-resolve.js'; diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-roles-lifetime.js b/packages/vc-delegation-engine/src/delegation-policy/chain-roles-lifetime.js new file mode 100644 index 000000000..b0c5de0aa --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-roles-lifetime.js @@ -0,0 +1,96 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; + +/** + * @param {string} ancestorRoleId + * @param {string} descendantRoleId + * @param {Map} roleById + * @returns {boolean} + */ +export function isRoleAncestorOrEqual(ancestorRoleId, descendantRoleId, roleById) { + let current = descendantRoleId; + while (current != null) { + if (current === ancestorRoleId) { + return true; + } + current = roleById.get(current)?.parentRoleId ?? null; + } + return false; +} + +/** + * True when `descendantRoleId` is strictly below `ancestorRoleId` in the role graph (a sub-role, not the same node). + * @param {string} ancestorRoleId + * @param {string} descendantRoleId + * @param {Map} roleById + * @returns {boolean} + */ +export function isRoleStrictSubRole(ancestorRoleId, descendantRoleId, roleById) { + if (descendantRoleId === ancestorRoleId) { + return false; + } + return isRoleAncestorOrEqual(ancestorRoleId, descendantRoleId, roleById); +} + +/** + * @param {object} parentVc + * @param {object} childVc + */ +export function assertChildCredentialExpiresBeforeOrEqualParent(parentVc, childVc) { + const pExp = parentVc?.expirationDate; + const cExp = childVc?.expirationDate; + if (typeof pExp !== 'string' || pExp.length === 0 || typeof cExp !== 'string' || cExp.length === 0) { + return; + } + const tp = Date.parse(pExp); + const tc = Date.parse(cExp); + if (Number.isNaN(tp) || Number.isNaN(tc)) { + return; + } + if (tc > tp) { + throw new DelegationError( + DelegationErrorCodes.POLICY_LIFETIME_INVALID, + `Credential ${childVc.id} expirationDate must not be after parent credential ${parentVc.id} expirationDate`, + ); + } +} + +/** + * @param {object[]} delegationCreds + * @param {Map} roleById + */ +export function assertDelegationChainRoles(delegationCreds, roleById) { + for (let i = 0; i < delegationCreds.length; i += 1) { + const vc = delegationCreds[i]; + const roleId = vc.delegationRoleId; + if (typeof roleId !== 'string' || roleId.length === 0) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `Delegation credential ${vc.id} is missing delegationRoleId`, + ); + } + if (!roleById.has(roleId)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `Credential ${vc.id} delegationRoleId "${roleId}" is not defined in policy`, + ); + } + if (i > 0) { + const prev = delegationCreds[i - 1]; + const prevRoleId = prev.delegationRoleId; + if (!isRoleAncestorOrEqual(prevRoleId, roleId, roleById)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `Credential ${vc.id} delegationRoleId "${roleId}" must be the same role as or a descendant of the previous delegation step role "${prevRoleId}" per the policy role graph`, + ); + } + const prevRoleDef = roleById.get(prevRoleId); + if (prevRoleDef?.cannotDelegateToSameRole === true + && !isRoleStrictSubRole(prevRoleId, roleId, roleById)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `Credential ${vc.id} delegationRoleId "${roleId}" must be a strict sub-role of "${prevRoleId}"; policy disallows delegating from "${prevRoleId}" to the same role`, + ); + } + } + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-subject-role.js b/packages/vc-delegation-engine/src/delegation-policy/chain-subject-role.js new file mode 100644 index 000000000..3b47cfb10 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-subject-role.js @@ -0,0 +1,90 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { validateCapabilityValueAgainstSchema } from './capability-value-schema.js'; +import { coerceCapabilityValueForSchema } from './chain-types-binding.js'; +import { RESERVED_SUBJECT_KEYS } from './reserved-subject-keys.js'; + +/** + * @param {object} subject + * @param {object} grant + * @param {string} credentialId + */ +function validateSubjectCapabilityGrant(subject, grant, credentialId) { + if (!Object.prototype.hasOwnProperty.call(subject, grant.capability)) { + return; + } + const raw = subject[grant.capability]; + const coerced = coerceCapabilityValueForSchema(raw, grant.schema); + validateCapabilityValueAgainstSchema( + coerced, + grant.schema, + `${credentialId} subject.${grant.capability}`, + ); +} + +/** + * @param {string} key credentialSubject key (compact term) + * @param {object} roleDef + * @returns {boolean} + */ +export function subjectFieldDisclosureAllowedByRole(key, roleDef) { + const attrs = roleDef.attributes; + const granted = Array.isArray(roleDef.capabilityGrants) + && roleDef.capabilityGrants.some((g) => g.capability === key); + if (!Array.isArray(attrs)) { + return granted; + } + if (attrs.includes('*')) { + return true; + } + const path = `credentialSubject.${key}`; + if (attrs.includes(path) || attrs.includes(key)) { + return true; + } + return granted; +} + +function validateSubjectDisclosuresForRole(subject, roleDef, credentialId) { + for (const key of Object.keys(subject)) { + const skipKey = RESERVED_SUBJECT_KEYS.has(key) || subject[key] === undefined; + if (!skipKey && !subjectFieldDisclosureAllowedByRole(key, roleDef)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `Credential ${credentialId} discloses subject field "${key}" which is not allowed by role "${roleDef.roleId}" attributes (or an explicit capability grant for that field)`, + ); + } + } +} + +function validateVcSubjectAgainstEffectiveRole(vc, roleDef, effectiveRoleId) { + if (!roleDef) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ROLE_INVALID, + `No policy role for effective role "${effectiveRoleId}"`, + ); + } + const subject = vc.credentialSubject ?? {}; + for (const grant of roleDef.capabilityGrants) { + validateSubjectCapabilityGrant(subject, grant, vc.id); + } + validateSubjectDisclosuresForRole(subject, roleDef, vc.id); +} + +/** + * @param {object[]} chain + * @param {Map} roleById + */ +export function assertChainSubjectCapabilitiesMatchPolicy(chain, roleById) { + let lastDelegationRoleId = null; + for (const vc of chain) { + if (typeof vc.delegationRoleId === 'string' && vc.delegationRoleId.length > 0) { + lastDelegationRoleId = vc.delegationRoleId; + } + if (lastDelegationRoleId) { + validateVcSubjectAgainstEffectiveRole( + vc, + roleById.get(lastDelegationRoleId), + lastDelegationRoleId, + ); + } + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-types-binding.js b/packages/vc-delegation-engine/src/delegation-policy/chain-types-binding.js new file mode 100644 index 000000000..837ba8e94 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-types-binding.js @@ -0,0 +1,115 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; + +/** + * credentialSubject normalization may collapse single-element arrays to scalars; JSON Schema expects arrays. + * @param {unknown} value + * @param {object} schema + * @returns {unknown} + */ +export function coerceCapabilityValueForSchema(value, schema) { + if (schema?.type === 'array' && value != null && !Array.isArray(value)) { + return [value]; + } + return value; +} + +/** + * @param {object} vc + * @returns {boolean} + */ +export function isDelegationCredentialType(vc) { + return Array.isArray(vc?.type) && vc.type.includes('DelegationCredential'); +} + +/** + * Ensures no credential has a partial policy binding; if any credential carries a policy, root must too. + * @param {object[]} chain + */ +export function assertDelegationPolicyRootPlacement(chain) { + if (!Array.isArray(chain) || chain.length === 0) { + return; + } + let anyFullBinding = false; + for (const vc of chain) { + const hasId = typeof vc.delegationPolicyId === 'string' && vc.delegationPolicyId.length > 0; + const hasDigest = typeof vc.delegationPolicyDigest === 'string' && vc.delegationPolicyDigest.length > 0; + if (hasId !== hasDigest) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + `Credential ${vc.id} must set both delegationPolicyId and delegationPolicyDigest or neither`, + ); + } + if (hasId) { + anyFullBinding = true; + } + } + if (!anyFullBinding) { + return; + } + const root = chain[0]; + const rootOk = typeof root.delegationPolicyId === 'string' && root.delegationPolicyId.length > 0 + && typeof root.delegationPolicyDigest === 'string' && root.delegationPolicyDigest.length > 0; + if (!rootOk) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'When any credential references a delegation policy, the root credential must include delegationPolicyId and delegationPolicyDigest', + ); + } +} + +/** + * @param {object[]} chain Normalized credentials root..tail + * @returns {{ hasPolicy: boolean, rootPolicyId?: string, rootPolicyDigest?: string }} + */ +export function extractRootPolicyBinding(chain) { + if (!Array.isArray(chain) || chain.length === 0) { + return { hasPolicy: false }; + } + const root = chain[0]; + const id = root.delegationPolicyId; + const digest = root.delegationPolicyDigest; + const hasId = typeof id === 'string' && id.length > 0; + const hasDigest = typeof digest === 'string' && digest.length > 0; + if (hasId !== hasDigest) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'delegationPolicyId and delegationPolicyDigest must both be set or both omitted on root credential', + ); + } + if (hasId) { + return { hasPolicy: true, rootPolicyId: id, rootPolicyDigest: digest }; + } + return { hasPolicy: false }; +} + +/** + * @param {object[]} chain + * @param {string} rootPolicyId + * @param {string} rootPolicyDigest + */ +export function assertPolicyBindingsConsistentInChain(chain, rootPolicyId, rootPolicyDigest) { + for (const vc of chain) { + const id = vc.delegationPolicyId; + const digest = vc.delegationPolicyDigest; + const hasId = typeof id === 'string' && id.length > 0; + const hasDigest = typeof digest === 'string' && digest.length > 0; + if (hasId !== hasDigest) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + `Credential ${vc.id} must set both delegationPolicyId and delegationPolicyDigest or neither`, + ); + } + if (hasId && id !== rootPolicyId) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ID_MISMATCH, + `Credential ${vc.id} delegationPolicyId must match root policy id`, + ); + } + if (hasDigest && digest !== rootPolicyDigest) { + throw new DelegationError( + DelegationErrorCodes.POLICY_DIGEST_MISMATCH, + `Credential ${vc.id} delegationPolicyDigest must match root policy digest`, + ); + } + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/chain-verify.js b/packages/vc-delegation-engine/src/delegation-policy/chain-verify.js new file mode 100644 index 000000000..820ff6575 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/chain-verify.js @@ -0,0 +1,27 @@ +import { assertMaxDelegationDepth } from './chain-depth.js'; +import { assertChainCredentialMonotonicity } from './chain-monotonic.js'; +import { + assertChildCredentialExpiresBeforeOrEqualParent, + assertDelegationChainRoles, +} from './chain-roles-lifetime.js'; +import { assertChainSubjectCapabilitiesMatchPolicy } from './chain-subject-role.js'; +import { isDelegationCredentialType } from './chain-types-binding.js'; + +/** + * @param {object[]} chain + * @param {object} policyJson + */ +export function verifyDelegationPolicyChain(chain, policyJson) { + const { ruleset } = policyJson; + const roleById = new Map(ruleset.roles.map((r) => [r.roleId, r])); + const capabilityNames = new Set(ruleset.capabilities.map((c) => c.name)); + assertMaxDelegationDepth(chain, ruleset.overallConstraints.maxDelegationDepth); + + const delegationCreds = chain.filter((vc) => isDelegationCredentialType(vc)); + assertDelegationChainRoles(delegationCreds, roleById); + assertChainSubjectCapabilitiesMatchPolicy(chain, roleById); + for (let i = 1; i < chain.length; i += 1) { + assertChildCredentialExpiresBeforeOrEqualParent(chain[i - 1], chain[i]); + } + assertChainCredentialMonotonicity(chain, capabilityNames); +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/grant-schema-narrowing.js b/packages/vc-delegation-engine/src/delegation-policy/grant-schema-narrowing.js new file mode 100644 index 000000000..3b8835cea --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/grant-schema-narrowing.js @@ -0,0 +1,224 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { DelegationError, DelegationErrorCodes } from '../errors.js'; + +function throwInvalidGrantSchemas(capabilityName) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Invalid capability grant schemas for "${capabilityName}"`, + ); +} + +function assertMinMaxItemsNarrowing(parentSchema, childSchema, capabilityName) { + const pMin = parentSchema.minItems; + const cMin = childSchema.minItems; + if (typeof pMin === 'number' && typeof cMin === 'number' && cMin < pMin) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" minItems must be >= parent minItems`, + ); + } + const pMax = parentSchema.maxItems; + const cMax = childSchema.maxItems; + if (typeof pMax === 'number' && typeof cMax === 'number' && cMax > pMax) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" maxItems must be <= parent maxItems`, + ); + } +} + +function assertNumericBoundsNarrowing(parentSchema, childSchema, capabilityName) { + const pMin = parentSchema.minimum; + const cMin = childSchema.minimum; + if (typeof pMin === 'number' && typeof cMin === 'number' && cMin < pMin) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" minimum must be >= parent minimum`, + ); + } + const pMax = parentSchema.maximum; + const cMax = childSchema.maximum; + if (typeof pMax === 'number' && typeof cMax === 'number' && cMax > pMax) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" maximum must be <= parent maximum`, + ); + } +} + +function assertGrantArrayPairNarrowing(childSchema, parentSchema, capabilityName, isNested) { + assertMinMaxItemsNarrowing(parentSchema, childSchema, capabilityName); + const pi = parentSchema.items; + const ci = childSchema.items; + if ( + pi + && ci + && typeof pi === 'object' + && typeof ci === 'object' + && !Array.isArray(pi) + && !Array.isArray(ci) + ) { + assertGrantSchemaNarrowing(ci, pi, `${capabilityName}.items`, true); + } + if (!isNested) { + assertTopLevelGrantKeysAllowed(childSchema, capabilityName); + } +} + +function assertGrantTypesMatch(parentType, childType, capabilityName) { + if (parentType !== undefined && childType !== undefined && parentType !== childType) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" grant type must match parent (got ${childType}, parent ${parentType})`, + ); + } +} + +function assertConstNarrowing(childSchema, parentSchema, capabilityName) { + if (!Object.prototype.hasOwnProperty.call(parentSchema, 'const')) { + return; + } + if (!Object.prototype.hasOwnProperty.call(childSchema, 'const')) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" child grant must repeat parent const`, + ); + } + if (childSchema.const !== parentSchema.const) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" const must equal parent const`, + ); + } +} + +function assertEnumNarrowingBothDefined(childSchema, parentSchema, capabilityName) { + const parentSet = new Set(parentSchema.enum); + const ok = childSchema.enum.every((v) => parentSet.has(v)); + if (!ok) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" grant enum must be subset of parent enum`, + ); + } +} + +function assertEnumNarrowingChildOnly(childSchema, parentSchema, capabilityName) { + const ajv = new Ajv2020({ strict: false }); + addFormats(ajv); + try { + const validate = ajv.compile(parentSchema); + const ok = childSchema.enum.every((v) => validate(v)); + if (!ok) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" grant enum values must satisfy parent schema`, + ); + } + } catch (e) { + if (e instanceof DelegationError) { + throw e; + } + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" could not verify enum against parent: ${e.message ?? e}`, + ); + } +} + +function assertEnumNarrowing(childSchema, parentSchema, capabilityName) { + if (Array.isArray(parentSchema.enum) && Array.isArray(childSchema.enum)) { + assertEnumNarrowingBothDefined(childSchema, parentSchema, capabilityName); + return; + } + if (Array.isArray(childSchema.enum) && !Array.isArray(parentSchema.enum)) { + assertEnumNarrowingChildOnly(childSchema, parentSchema, capabilityName); + } +} + +function assertPatternNarrowing(childSchema, parentSchema, capabilityName) { + if ( + typeof parentSchema.pattern === 'string' + && typeof childSchema.pattern === 'string' + && parentSchema.pattern !== childSchema.pattern + ) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" pattern must match parent pattern unless identical narrowing is documented`, + ); + } +} + +function assertItemsEnumSubset(childSchema, parentSchema, capabilityName) { + const pItems = parentSchema.items; + const cItems = childSchema.items; + if ( + !pItems + || !cItems + || typeof pItems !== 'object' + || typeof cItems !== 'object' + || Array.isArray(pItems) + || Array.isArray(cItems) + || !Array.isArray(pItems.enum) + || !Array.isArray(cItems.enum) + ) { + return; + } + const pSet = new Set(pItems.enum); + if (!cItems.enum.every((v) => pSet.has(v))) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" items.enum must be subset of parent items.enum`, + ); + } +} + +function assertTopLevelGrantKeysAllowed(childSchema, capabilityName) { + const allowedKeys = new Set([ + 'type', 'enum', 'const', 'minItems', 'maxItems', 'uniqueItems', 'items', 'pattern', 'minimum', 'maximum', + ]); + const childKeys = Object.keys(childSchema).filter((k) => !k.startsWith('$')); + const unknown = childKeys.filter((k) => !allowedKeys.has(k)); + if (unknown.length > 0) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Capability "${capabilityName}" grant schema contains unsupported keywords for narrowing check: ${unknown.join(', ')}`, + ); + } +} + +/** + * Practical JSON-schema narrowing check (child stricter or equal to parent). + * Unknown keywords cause validation to fail (strict verifier) at top level only. + * @param {object} childSchema + * @param {object} parentSchema + * @param {string} capabilityName + * @param {boolean} [isNested] + * @returns {void} + */ +export function assertGrantSchemaNarrowing(childSchema, parentSchema, capabilityName, isNested = false) { + if (!childSchema || typeof childSchema !== 'object' || !parentSchema || typeof parentSchema !== 'object') { + throwInvalidGrantSchemas(capabilityName); + } + + const parentType = parentSchema.type; + const childType = childSchema.type; + + if (parentType === 'array' && childType === 'array') { + assertGrantArrayPairNarrowing(childSchema, parentSchema, capabilityName, isNested); + return; + } + + assertGrantTypesMatch(parentType, childType, capabilityName); + assertConstNarrowing(childSchema, parentSchema, capabilityName); + assertEnumNarrowing(childSchema, parentSchema, capabilityName); + assertMinMaxItemsNarrowing(parentSchema, childSchema, capabilityName); + assertNumericBoundsNarrowing(parentSchema, childSchema, capabilityName); + assertPatternNarrowing(childSchema, parentSchema, capabilityName); + assertItemsEnumSubset(childSchema, parentSchema, capabilityName); + + if (!isNested) { + assertTopLevelGrantKeysAllowed(childSchema, capabilityName); + } +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/policy-resolve.js b/packages/vc-delegation-engine/src/delegation-policy/policy-resolve.js new file mode 100644 index 000000000..183380145 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/policy-resolve.js @@ -0,0 +1,72 @@ +import { verifyPolicyDigest } from '../delegation-policy-digest.js'; +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { validateDelegationPolicy } from './policy-semantics.js'; +import { assertPolicyBindingsConsistentInChain } from './chain-types-binding.js'; +import { verifyDelegationPolicyChain } from './chain-verify.js'; + +/** + * Load delegation policy JSON using the same documentLoader contract as JSON-LD (returns `{ document }` or compatible). + * @param {(url: string) => Promise<{document?: unknown, contextUrl?: null, documentUrl?: string}>} documentLoader + * @param {string} policyId + * @returns {Promise} + */ +export async function fetchDelegationPolicyJson(documentLoader, policyId) { + if (typeof documentLoader !== 'function') { + throw new DelegationError( + DelegationErrorCodes.POLICY_DOCUMENT_LOADER_REQUIRED, + 'documentLoader is required when verifying credentials that reference a delegation policy', + ); + } + let loaded; + try { + loaded = await documentLoader(policyId); + } catch (error) { + throw new DelegationError( + DelegationErrorCodes.POLICY_DOCUMENT_LOAD_FAILED, + `Could not load delegation policy "${policyId}": ${error?.message ?? error}`, + ); + } + const hasDocumentField = loaded && typeof loaded === 'object' + && Object.prototype.hasOwnProperty.call(loaded, 'document'); + const policyJson = hasDocumentField ? loaded.document : loaded; + if (!policyJson || typeof policyJson !== 'object') { + throw new DelegationError( + DelegationErrorCodes.POLICY_DOCUMENT_LOAD_FAILED, + `documentLoader did not return a delegation policy document for "${policyId}"`, + ); + } + return policyJson; +} + +/** + * Resolve policy via documentLoader, verify id/digest, validate document and chain constraints. + * @param {object} options + * @param {object[]} options.chain + * @param {string} options.rootPolicyId + * @param {string} options.rootPolicyDigest + * @param {(url: string) => Promise<{document?: unknown}>} options.documentLoader + */ +export async function resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId, + rootPolicyDigest, + documentLoader, +}) { + const policyJson = await fetchDelegationPolicyJson(documentLoader, rootPolicyId); + if (policyJson.id !== rootPolicyId) { + throw new DelegationError( + DelegationErrorCodes.POLICY_ID_MISMATCH, + `Resolved policy id "${policyJson.id}" does not match credential delegationPolicyId`, + ); + } + if (!verifyPolicyDigest(policyJson, rootPolicyDigest)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_DIGEST_MISMATCH, + 'delegationPolicyDigest does not match SHA-256 of canonical JSON policy document', + ); + } + validateDelegationPolicy(policyJson); + assertPolicyBindingsConsistentInChain(chain, rootPolicyId, rootPolicyDigest); + verifyDelegationPolicyChain(chain, policyJson); + return policyJson; +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/policy-semantics.js b/packages/vc-delegation-engine/src/delegation-policy/policy-semantics.js new file mode 100644 index 000000000..632820ef3 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/policy-semantics.js @@ -0,0 +1,218 @@ +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { assertGrantSchemaNarrowing } from './grant-schema-narrowing.js'; + +/** + * @param {string[]} childAttrs + * @param {string[]} parentAttrs + * @returns {boolean} + */ +export function attributesAreNarrowerOrEqual(childAttrs, parentAttrs) { + const child = Array.isArray(childAttrs) ? childAttrs : []; + const parent = Array.isArray(parentAttrs) ? parentAttrs : []; + if (parent.includes('*')) { + return true; + } + const parentSet = new Set(parent); + return child.every((a) => parentSet.has(a)); +} + +function validateRoleParentsExist(roles, roleIds) { + for (const role of roles) { + if (role.parentRoleId !== null && !roleIds.has(role.parentRoleId)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" parentRoleId "${role.parentRoleId}" does not exist`, + ); + } + } +} + +function validateRolesGrantsAgainstCapabilities(roles, capabilities, capabilityNames) { + for (const role of roles) { + const grantCaps = role.capabilityGrants.map((g) => g.capability); + if (new Set(grantCaps).size !== grantCaps.length) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" capabilityGrants must use unique capability names`, + ); + } + for (const grant of role.capabilityGrants) { + if (!capabilityNames.has(grant.capability)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" references unknown capability "${grant.capability}"`, + ); + } + const baseDef = capabilities.find((c) => c.name === grant.capability); + assertGrantSchemaNarrowing(grant.schema, baseDef.schema, grant.capability); + } + } +} + +function validateRolesNarrowingVersusParents(roles, roleById) { + for (const role of roles) { + if (role.parentRoleId !== null) { + const parent = roleById.get(role.parentRoleId); + if (parent) { + validateSingleRoleVersusParent(role, parent); + } + } + } +} + +function validateSingleRoleVersusParent(role, parent) { + if (!attributesAreNarrowerOrEqual(role.attributes, parent.attributes)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" attributes must be narrower or equal to parent "${role.parentRoleId}"`, + ); + } + const parentGrantByCap = new Map(parent.capabilityGrants.map((g) => [g.capability, g])); + for (const grant of role.capabilityGrants) { + const pGrant = parentGrantByCap.get(grant.capability); + if (!pGrant) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" capability "${grant.capability}" not granted to parent "${role.parentRoleId}"`, + ); + } + assertGrantSchemaNarrowing(grant.schema, pGrant.schema, grant.capability); + } +} + +/** + * When `cannotDelegateToSameRole` is true, the role must have at least one child role in the graph + * so a delegator can move to a strict sub-role. + * @param {object[]} roles + */ +function validateCannotDelegateToSameRoleOnRoles(roles) { + for (const role of roles) { + const flag = role.cannotDelegateToSameRole; + if (flag !== undefined && typeof flag !== 'boolean') { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" cannotDelegateToSameRole must be a boolean when present`, + ); + } + if (flag === true) { + const hasSubRole = roles.some((r) => r.parentRoleId === role.roleId); + if (!hasSubRole) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Role "${role.roleId}" sets cannotDelegateToSameRole but the policy defines no sub-role of that role`, + ); + } + } + } +} + +function assertRoleHierarchyAcyclic(roles, roleById) { + const visited = new Set(); + const stack = new Set(); + function dfs(roleId) { + if (stack.has(roleId)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + `Cycle detected in role hierarchy at "${roleId}"`, + ); + } + if (visited.has(roleId)) { + return; + } + visited.add(roleId); + stack.add(roleId); + const role = roleById.get(roleId); + if (role?.parentRoleId) { + dfs(role.parentRoleId); + } + stack.delete(roleId); + } + for (const r of roles) { + if (!visited.has(r.roleId)) { + dfs(r.roleId); + } + } +} + +/** + * @param {object} policyJson + * @returns {void} + */ +export function validateDelegationPolicySemantics(policyJson) { + const { roles, capabilities } = policyJson.ruleset; + const capabilityNames = validateUniqueCapabilityNames(capabilities); + const { roleById, roleIds } = buildRoleMaps(roles); + + validateRoleParentsExist(roles, roleIds); + validateCannotDelegateToSameRoleOnRoles(roles); + validateRolesGrantsAgainstCapabilities(roles, capabilities, capabilityNames); + validateRolesNarrowingVersusParents(roles, roleById); + assertRoleHierarchyAcyclic(roles, roleById); +} + +function assertRulesetShape(ruleset) { + if (!Array.isArray(ruleset.roles)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'Delegation policy ruleset.roles must be an array', + ); + } + if (!Array.isArray(ruleset.capabilities)) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'Delegation policy ruleset.capabilities must be an array', + ); + } + const maxDepth = ruleset.overallConstraints?.maxDelegationDepth; + const isValidDepth = Number.isInteger(maxDepth) && maxDepth >= 0; + if (!isValidDepth) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'Delegation policy ruleset.overallConstraints.maxDelegationDepth must be a non-negative integer', + ); + } +} + +function validateUniqueCapabilityNames(capabilities) { + const capabilityNames = new Set(capabilities.map((c) => c.name)); + if (capabilityNames.size !== capabilities.length) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'ruleset.capabilities names must be unique', + ); + } + return capabilityNames; +} + +function buildRoleMaps(roles) { + const roleById = new Map(roles.map((r) => [r.roleId, r])); + const roleIds = new Set(roleById.keys()); + if (roleIds.size !== roles.length) { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'ruleset.roles roleId values must be unique', + ); + } + return { roleById, roleIds }; +} + +/** + * Semantic checks only (role graph, grant narrowing, capability registry, cannotDelegateToSameRole shape). No JSON Schema for the policy document. + * @param {unknown} policyJson + */ +export function validateDelegationPolicy(policyJson) { + if (!policyJson || typeof policyJson !== 'object') { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'Delegation policy must be a non-null object', + ); + } + if (!policyJson.ruleset || typeof policyJson.ruleset !== 'object') { + throw new DelegationError( + DelegationErrorCodes.POLICY_SEMANTIC_INVALID, + 'Delegation policy must include a ruleset object', + ); + } + assertRulesetShape(policyJson.ruleset); + validateDelegationPolicySemantics(policyJson); +} diff --git a/packages/vc-delegation-engine/src/delegation-policy/reserved-subject-keys.js b/packages/vc-delegation-engine/src/delegation-policy/reserved-subject-keys.js new file mode 100644 index 000000000..0676ed863 --- /dev/null +++ b/packages/vc-delegation-engine/src/delegation-policy/reserved-subject-keys.js @@ -0,0 +1,3 @@ +import { MAY_CLAIM_IRI, MAY_CLAIM_ALIAS_KEYS } from '../constants.js'; + +export const RESERVED_SUBJECT_KEYS = new Set(['id', MAY_CLAIM_IRI, ...MAY_CLAIM_ALIAS_KEYS]); diff --git a/packages/vc-delegation-engine/src/engine.js b/packages/vc-delegation-engine/src/engine.js index 2cd9ce27e..a117017c8 100644 --- a/packages/vc-delegation-engine/src/engine.js +++ b/packages/vc-delegation-engine/src/engine.js @@ -1,45 +1,18 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import jsonld from 'jsonld'; -import { infer } from 'rify'; -import base64url from 'base64url'; - import { normalizeContextMap, - shortenTerm, extractExpandedPresentationNode, extractExpandedVerificationMethod, - firstExpandedValue, - findExpandedTermId, - normalizeSubject, } from './jsonld-utils.js'; -import { baseEntities, collectActorIds, applyCredentialFacts } from './cedar-utils.js'; -import { firstArrayItem, toArray } from './utils.js'; -import { buildRifyPremisesFromChain, buildRifyRules } from './rify-helpers.js'; -import { collectAuthorizedClaims } from './claim-deduction.js'; -import { - VC_VC, - VC_NS, - VC_ISSUER, - VC_PREVIOUS_CREDENTIAL_ID, - VC_ROOT_CREDENTIAL_ID, - VC_TYPE_DELEGATION_CREDENTIAL, - ACTION_VERIFY, - UNKNOWN_IDENTIFIER, -} from './constants.js'; -import { summarizeDelegationChain, summarizeStandaloneCredential } from './summarize.js'; +import { VC_VC, UNKNOWN_IDENTIFIER } from './constants.js'; import { DelegationError, DelegationErrorCodes, normalizeDelegationFailure } from './errors.js'; - -const CONTROL_PREDICATES = new Set(['allows', 'delegatesTo', 'listsClaim', 'inheritsParent']); -const DELEGATION_TYPE_NAME = shortenTerm(VC_TYPE_DELEGATION_CREDENTIAL); -const RESERVED_RESOURCE_TYPES = new Set([ - `${VC_NS}VerifiableCredential`, - 'VerifiableCredential', - VC_TYPE_DELEGATION_CREDENTIAL, - 'DelegationCredential', -]); -const VC_JWT_PATTERN = /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/; -const VC_JWT_ID_PREFIX = 'urn:vcjwt:'; -const AUTHORIZED_GRAPH_PREFIX = 'urn:authorized'; +import { + ingestAllPresentationCredentials, + assertDelegationReferencesResolved, +} from './engine/presentation-ingest.js'; +import { + buildCredentialChainFromTail, + evaluateDelegationChainForPresentation, +} from './engine/chain-evaluation.js'; /** * @typedef {Object} DelegationSummary @@ -100,265 +73,10 @@ const AUTHORIZED_GRAPH_PREFIX = 'urn:authorized'; * @property {Array<{credentialId:string,issuerId?:string,subjectId?:string,types?:string[],claims?:Record}>} [skippedCredentials] */ -function findUnauthorizedClaims(premises = [], derived = [], authorizedGraphId) { - if (!authorizedGraphId) { - return []; - } - - const remainingPremises = new Map(); - premises.forEach(([subject, predicate, value, graph]) => { - if ( - !subject - || !predicate - || CONTROL_PREDICATES.has(predicate) - || typeof value === 'undefined' - || typeof graph === 'undefined' - ) { - return; - } - const key = `${subject}:::${predicate}:::${value}`; - if (!remainingPremises.has(key)) { - remainingPremises.set(key, { - subject, claim: predicate, value, issuer: graph, - }); - } - }); - - derived.forEach(([subject, predicate, value, graph]) => { - if (graph !== authorizedGraphId || CONTROL_PREDICATES.has(predicate)) { - return; - } - const key = `${subject}:::${predicate}:::${value}`; - remainingPremises.delete(key); - }); - - return Array.from(remainingPremises.values()); -} - -function ensureSubjectId(subject, fallbackId) { - if (!subject || typeof subject !== 'object' || subject.id) { - return subject; - } - if (fallbackId) { - return { ...subject, id: fallbackId }; - } - return subject; -} - -function normalizeScopeClaims(subject = {}) { - if (!subject || typeof subject !== 'object') { - return subject ?? {}; - } - const normalized = { ...subject }; - if (Object.prototype.hasOwnProperty.call(normalized, 'permittedSystems')) { - normalized.permittedSystems = toArray(normalized.permittedSystems ?? []); - } - if (Object.prototype.hasOwnProperty.call(normalized, 'contractTypes')) { - normalized.contractTypes = toArray(normalized.contractTypes ?? []); - } - return normalized; -} - -function buildParentClaimsChain(chain) { - if (!Array.isArray(chain) || chain.length < 2) { - return null; - } - const orderedSegments = []; - for (let index = chain.length - 2; index >= 0; index -= 1) { - const subjectClaims = normalizeScopeClaims(chain[index]?.credentialSubject); - if (subjectClaims && typeof subjectClaims === 'object') { - orderedSegments.push(subjectClaims); - } - } - let head = null; - let previousNode = null; - orderedSegments.forEach((claims) => { - const node = { ...claims }; - if (!head) { - head = node; - } - if (previousNode) { - previousNode.parentClaims = node; - } - previousNode = node; - }); - return head; -} - -function resolveCredentialContext(credentialId, contexts, contextOverride) { - const context = contexts.get(credentialId) ?? contextOverride; - if (!context) { - throw new DelegationError( - DelegationErrorCodes.MISSING_CONTEXT, - `Missing compaction context for credential ${credentialId}`, - ); - } - return context; -} - -function createSkippedCredential({ - credentialId = UNKNOWN_IDENTIFIER, - issuerId = UNKNOWN_IDENTIFIER, - subjectId = UNKNOWN_IDENTIFIER, - types = [], - claims = {}, -}) { - return { - credentialId, - issuerId, - subjectId, - types: toArray(types).filter(Boolean), - claims, - }; -} - -async function expandJwtCredential(jwt, index, documentLoader) { - const parts = jwt.split('.'); - if (parts.length !== 3) { - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - 'Malformed VC-JWT encountered in presentation', - ); - } - - let payload; - try { - payload = JSON.parse(base64url.decode(parts[1])); - } catch (error) { - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - 'Unable to decode VC-JWT payload', - ); - } - - const credential = payload?.vc; - if (!credential || typeof credential !== 'object') { - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - 'VC-JWT payload missing vc object', - ); - } - - const normalizedCredential = { ...credential }; - normalizedCredential.id = normalizedCredential.id ?? payload?.jti ?? `${VC_JWT_ID_PREFIX}${index}`; - const subjectId = payload?.sub; - if (!normalizedCredential.credentialSubject) { - normalizedCredential.credentialSubject = subjectId ? { id: subjectId } : {}; - } else if (Array.isArray(normalizedCredential.credentialSubject)) { - normalizedCredential.credentialSubject = normalizedCredential.credentialSubject.map( - (subject) => ensureSubjectId(subject, subjectId), - ); - } else { - normalizedCredential.credentialSubject = ensureSubjectId( - normalizedCredential.credentialSubject, - subjectId, - ); - } - - if (!normalizedCredential.issuer && payload?.iss) { - normalizedCredential.issuer = payload.iss; - } - - const hasJsonLdContext = Array.isArray(normalizedCredential['@context']) - || typeof normalizedCredential['@context'] === 'string'; - if (!hasJsonLdContext) { - return { - kind: 'skipped', - metadata: { - credentialId: normalizedCredential.id, - claims: normalizedCredential.credentialSubject ?? {}, - issuerId: normalizedCredential.issuer ?? payload?.iss ?? UNKNOWN_IDENTIFIER, - subjectId: normalizedCredential.credentialSubject?.id ?? subjectId ?? UNKNOWN_IDENTIFIER, - types: toArray(normalizedCredential.type), - }, - }; - } - if (!Array.isArray(normalizedCredential['@context'])) { - normalizedCredential['@context'] = [normalizedCredential['@context']]; - } - - if (!normalizedCredential.type) { - normalizedCredential.type = ['VerifiableCredential']; - } else if (!Array.isArray(normalizedCredential.type)) { - normalizedCredential.type = [normalizedCredential.type]; - } - - const loader = documentLoader ?? jsonld.documentLoaders?.node?.(); - const expandOptions = loader ? { documentLoader: loader } : {}; - const expanded = await jsonld.expand(normalizedCredential, expandOptions); - const credentialNode = firstArrayItem( - expanded, - 'Expanded VC-JWT credential missing node', - ); - - return { - kind: 'expanded', - credentialNode, - contextOverride: normalizedCredential['@context'], - }; -} - -async function normalizeJwtEntry(value, index, documentLoader) { - const outcome = await expandJwtCredential(value, index, documentLoader); - if (outcome.kind === 'skipped') { - return { skipped: createSkippedCredential(outcome.metadata) }; - } - return { - credentialNode: outcome.credentialNode, - contextOverride: outcome.contextOverride, - }; -} - -async function normalizeCredentialEntry(entry, index, { documentLoader }) { - if (Array.isArray(entry?.['@graph'])) { - const credentialNode = firstArrayItem( - entry['@graph'], - 'Expanded verifiableCredential entry is missing @graph node', - ); - const referenceId = credentialNode?.['@id']; - const onlyHasId = referenceId - && Object.keys(credentialNode).every((key) => key === '@id'); - if (onlyHasId && VC_JWT_PATTERN.test(referenceId)) { - return normalizeJwtEntry(referenceId, index, documentLoader); - } - return { - credentialNode, - contextOverride: null, - }; - } - - const literalValue = entry?.['@value']; - if (typeof literalValue === 'string' && VC_JWT_PATTERN.test(literalValue)) { - return normalizeJwtEntry(literalValue, index, documentLoader); - } - - const referenceId = entry?.['@id']; - if (typeof referenceId === 'string' && VC_JWT_PATTERN.test(referenceId)) { - return normalizeJwtEntry(referenceId, index, documentLoader); - } - - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - 'Unsupported credential entry encountered in presentation', - ); -} - -// Normalizes thrown errors into a consistent failure record. function normalizeFailure(error) { return normalizeDelegationFailure(error); } -function deriveResourceTypesFromChain(chain) { - const root = chain[0]; - if (!root || !Array.isArray(root.type)) { - return []; - } - const filtered = root.type.filter( - (typeName) => typeof typeName === 'string' && !RESERVED_RESOURCE_TYPES.has(typeName), - ); - return filtered.length > 0 ? filtered : []; -} - // High-level helper: parse VP, build entities, and return decision plus failures. /** * @param {Object} options @@ -366,6 +84,9 @@ function deriveResourceTypesFromChain(chain) { * @param {Map|Object} options.credentialContexts * @param {boolean} [options.failOnUnauthorizedClaims=false] * @param {Function} [options.documentLoader] + * @param {object} [options.delegationPolicy] + * @param {boolean} [options.delegationPolicy.enabled=true] When false, skip delegation policy digest/semantics even if credentials reference a policy. + * @description When a chain references delegationPolicyId/digest and policy checks are enabled, `documentLoader` is required and must return the policy JSON for that id (null or non-object responses fail verification). The verifier checks digest, semantic policy rules (including unique capability names per role and globally), each delegation credential’s role as descendant-or-self of the previous delegation role on the policy graph, child `expirationDate` not after its parent credential in the chain, disclosed `credentialSubject` keys against the effective role’s attributes (with `["*"]`) and capability grants, grant value validation, and monotonic narrowing of subject claims along the chain. Whether each credential is currently valid by its issuance and expiration dates is enforced by verifiable-credential verification, not by this engine. * @returns {Promise} */ export async function verifyVPWithDelegation({ @@ -373,6 +94,7 @@ export async function verifyVPWithDelegation({ credentialContexts, failOnUnauthorizedClaims = false, documentLoader, + delegationPolicy, }) { const skippedCredentialIds = new Set(); const skippedCredentials = []; @@ -390,58 +112,17 @@ export async function verifyVPWithDelegation({ ? extractedSigner : UNKNOWN_IDENTIFIER; - const normalizedById = new Map(); const contexts = normalizeContextMap(credentialContexts); - const referencedPreviousIds = new Set(); const vcEntries = presentationNode[VC_VC] ?? []; - - for (let index = 0; index < vcEntries.length; index += 1) { - const entry = vcEntries[index]; - // eslint-disable-next-line no-await-in-loop - const normalizedEntry = await normalizeCredentialEntry( - entry, - index, - { documentLoader }, - ); - if (normalizedEntry?.skipped) { - const { skipped } = normalizedEntry; - skippedCredentialIds.add(skipped.credentialId); - skippedCredentials.push(skipped); - // eslint-disable-next-line no-continue - continue; - } - const { credentialNode, contextOverride } = normalizedEntry; - const credentialId = credentialNode?.['@id']; - if (typeof credentialId !== 'string' || credentialId.length === 0) { - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - 'Expanded credential node must include an @id', - ); - } - - const context = resolveCredentialContext(credentialId, contexts, contextOverride); - - const compactOptions = documentLoader ? { documentLoader } : undefined; - // eslint-disable-next-line no-await-in-loop - const compacted = await jsonld.compact(credentialNode, { '@context': context }, compactOptions); - const credentialSubject = normalizeSubject(compacted?.credentialSubject); - - const credential = { - id: credentialId, - type: toArray(credentialNode['@type']).map((value) => shortenTerm(value)), - issuer: firstExpandedValue(credentialNode[VC_ISSUER]), - previousCredentialId: findExpandedTermId(credentialNode, VC_PREVIOUS_CREDENTIAL_ID), - rootCredentialId: findExpandedTermId(credentialNode, VC_ROOT_CREDENTIAL_ID), - credentialSubject, - expandedNode: credentialNode, - }; - - normalizedById.set(credentialId, credential); - const prevId = credential.previousCredentialId; - if (typeof prevId === 'string' && prevId.length > 0) { - referencedPreviousIds.add(prevId); - } - } + const { normalizedById, referencedPreviousIds } = await ingestAllPresentationCredentials( + vcEntries, + { + contexts, + documentLoader, + skippedCredentialIds, + skippedCredentials, + }, + ); const normalizedCredentials = Array.from(normalizedById.values()); if (normalizedCredentials.length === 0) { @@ -458,33 +139,8 @@ export async function verifyVPWithDelegation({ }; } - // Early out failure if missing credential from chain normalizedCredentials.forEach((credential) => { - const { - id, previousCredentialId, rootCredentialId, type, - } = credential; - const isDelegationCredential = Array.isArray(type) && type.includes(DELEGATION_TYPE_NAME); - if (!isDelegationCredential) { - return; - } - if (previousCredentialId && !normalizedById.has(previousCredentialId)) { - throw new DelegationError( - DelegationErrorCodes.MISSING_CREDENTIAL, - `Missing credential ${previousCredentialId} referenced as previous by ${id}`, - ); - } - if (!rootCredentialId || typeof rootCredentialId !== 'string' || rootCredentialId.length === 0) { - throw new DelegationError( - DelegationErrorCodes.INVALID_CREDENTIAL, - `Delegation credential ${id} is missing rootCredentialId`, - ); - } - if (!normalizedById.has(rootCredentialId)) { - throw new DelegationError( - DelegationErrorCodes.MISSING_CREDENTIAL, - `Missing root credential ${rootCredentialId} referenced by ${id}`, - ); - } + assertDelegationReferencesResolved(credential, normalizedById); }); const tailCredentials = normalizedCredentials.filter((vc) => !referencedPreviousIds.has(vc.id)); @@ -501,157 +157,26 @@ export async function verifyVPWithDelegation({ let lastFacts = null; let lastEntities = null; - const buildChain = (tailId) => { - if (seenChains.has(tailId)) { - return null; - } - const chain = []; - const visited = new Set(); - let current = normalizedById.get(tailId); - if (!current) { - throw new DelegationError( - DelegationErrorCodes.MISSING_CREDENTIAL, - `Tail credential ${tailId} not found in presentation`, - ); - } - while (current) { - if (visited.has(current.id)) { - throw new DelegationError( - DelegationErrorCodes.CHAIN_CYCLE, - 'Delegation chain contains a cycle', - ); - } - visited.add(current.id); - chain.unshift(current); - const prevId = current.previousCredentialId; - if (!prevId) { - break; - } - current = normalizedById.get(prevId); - if (!current) { - throw new DelegationError( - DelegationErrorCodes.MISSING_CREDENTIAL, - `Missing credential ${prevId} referenced by ${chain[0].id}`, - ); - } - } - seenChains.add(tailId); - return chain; - }; - - const evaluateChain = async (chain) => { - const rootCredentialId = chain[0]?.id; - const hasDelegationLinks = chain.some( - (vc) => typeof vc.previousCredentialId === 'string' && vc.previousCredentialId.length > 0, - ); - const summary = hasDelegationLinks - ? summarizeDelegationChain(chain.map((chainItem) => chainItem.expandedNode)) - : summarizeStandaloneCredential(chain[chain.length - 1]); - const { resourceId } = summary; - const resolvedPrincipalId = presentationSigner; - const authorizedGraphId = rootCredentialId - ? `${AUTHORIZED_GRAPH_PREFIX}:${rootCredentialId}` - : AUTHORIZED_GRAPH_PREFIX; - const resourceTypes = deriveResourceTypesFromChain(chain); - - const rootCredential = chain[0]; - const tailCredential = chain[chain.length - 1]; - const rootClaims = normalizeScopeClaims(rootCredential?.credentialSubject ?? {}); - const tailClaims = normalizeScopeClaims(tailCredential?.credentialSubject ?? {}); - const parentClaimsChain = buildParentClaimsChain(chain); - const facts = { - ...summary, - resourceId, - actionIds: [ACTION_VERIFY], - principalId: resolvedPrincipalId, - presentationSigner, - resourceTypes, - rootClaims, - tailClaims, - parentClaims: parentClaimsChain, - }; - const actorIds = collectActorIds(chain, presentationSigner); - const entities = baseEntities(actorIds, facts.actionIds); - applyCredentialFacts(entities, facts); - - const premises = buildRifyPremisesFromChain(chain, rootCredentialId); - if (!Array.isArray(premises) || premises.some((quad) => quad.length !== 4)) { - throw new DelegationError( - DelegationErrorCodes.RIFY_FAILURE, - 'Invalid premises generated for rify inference', - ); - } - const rules = buildRifyRules(rootCredentialId, authorizedGraphId); - let derived; - try { - derived = infer(premises, rules); - } catch (error) { - throw new DelegationError( - DelegationErrorCodes.RIFY_FAILURE, - `rify inference failed: ${error.message ?? error}`, - ); - } - - const { union: authorizedClaimUnion, perSubject: authorizedPerSubject } = collectAuthorizedClaims( - chain, - derived, - authorizedGraphId, - normalizedCredentials, - ); - - const unauthorizedClaims = findUnauthorizedClaims(premises, derived, authorizedGraphId); - if (unauthorizedClaims.length > 0 && failOnUnauthorizedClaims) { - const details = unauthorizedClaims - .slice(0, 5) - .map((claim) => `${claim.subject}.${claim.claim}`) - .join(', '); - throw new DelegationError( - DelegationErrorCodes.UNAUTHORIZED_CLAIM, - `Unauthorized claims detected in delegation chain${details ? `: ${details}` : ''}`, - ); - } - - facts.authorizedClaims = authorizedClaimUnion; - facts.authorizedClaimsBySubject = authorizedPerSubject; - facts.resourceTypes = resourceTypes; - const parentCredential = chain.length > 1 ? chain[chain.length - 2] : null; - facts.parentClaims = parentClaimsChain ?? parentCredential?.credentialSubject ?? {}; - summary.authorizedClaims = authorizedClaimUnion; - summary.authorizedClaimsBySubject = authorizedPerSubject; - summary.resourceTypes = resourceTypes; - summary.rootClaims = rootClaims; - summary.tailClaims = tailClaims; - summary.parentClaims = parentClaimsChain; - summary.tailClaims = tailClaims; - - return { - summary, - facts, - entities, - chain, - premises, - derived, - authorizedClaims: authorizedClaimUnion, - authorizedClaimsBySubject: authorizedPerSubject, - resourceTypes, - }; - }; - for (const tail of tailCredentials) { - const chain = buildChain(tail.id); - if (!chain) { - // eslint-disable-next-line no-continue - continue; + const chain = buildCredentialChainFromTail(normalizedById, tail.id, seenChains); + if (chain) { + // eslint-disable-next-line no-await-in-loop + const evaluation = await evaluateDelegationChainForPresentation({ + chain, + delegationPolicy, + documentLoader, + failOnUnauthorizedClaims, + normalizedCredentials, + presentationSigner, + }); + const { + summary, facts, entities, + } = evaluation; + summaries.push(summary); + evaluations.push(evaluation); + lastFacts = facts; + lastEntities = entities; } - // eslint-disable-next-line no-await-in-loop - const evaluation = await evaluateChain(chain); - const { - summary, facts, entities, - } = evaluation; - summaries.push(summary); - evaluations.push(evaluation); - lastFacts = facts; - lastEntities = entities; } return { diff --git a/packages/vc-delegation-engine/src/engine/chain-evaluation.js b/packages/vc-delegation-engine/src/engine/chain-evaluation.js new file mode 100644 index 000000000..542d10db6 --- /dev/null +++ b/packages/vc-delegation-engine/src/engine/chain-evaluation.js @@ -0,0 +1,314 @@ +import { infer } from 'rify'; + +import { baseEntities, collectActorIds, applyCredentialFacts } from '../cedar-utils.js'; +import { toArray } from '../utils.js'; +import { buildRifyPremisesFromChain, buildRifyRules } from '../rify-helpers.js'; +import { collectAuthorizedClaims } from '../claim-deduction.js'; +import { ACTION_VERIFY } from '../constants.js'; +import { + assertDelegationPolicyRootPlacement, + extractRootPolicyBinding, + resolveAndVerifyDelegationPolicy, +} from '../delegation-policy-chain.js'; +import { summarizeDelegationChain, summarizeStandaloneCredential } from '../summarize.js'; +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { + AUTHORIZED_GRAPH_PREFIX, + CONTROL_PREDICATES, + RESERVED_RESOURCE_TYPES, +} from './engine-constants.js'; + +function findUnauthorizedClaims(premises = [], derived = [], authorizedGraphId) { + if (!authorizedGraphId) { + return []; + } + + const remainingPremises = new Map(); + premises.forEach(([subject, predicate, value, graph]) => { + if ( + !subject + || !predicate + || CONTROL_PREDICATES.has(predicate) + || typeof value === 'undefined' + || typeof graph === 'undefined' + ) { + return; + } + const key = `${subject}:::${predicate}:::${value}`; + if (!remainingPremises.has(key)) { + remainingPremises.set(key, { + subject, claim: predicate, value, issuer: graph, + }); + } + }); + + derived.forEach(([subject, predicate, value, graph]) => { + if (graph !== authorizedGraphId || CONTROL_PREDICATES.has(predicate)) { + return; + } + const key = `${subject}:::${predicate}:::${value}`; + remainingPremises.delete(key); + }); + + return Array.from(remainingPremises.values()); +} + +function normalizeScopeClaims(subject = {}) { + if (!subject || typeof subject !== 'object') { + return subject ?? {}; + } + const normalized = { ...subject }; + if (Object.prototype.hasOwnProperty.call(normalized, 'permittedSystems')) { + normalized.permittedSystems = toArray(normalized.permittedSystems ?? []); + } + if (Object.prototype.hasOwnProperty.call(normalized, 'contractTypes')) { + normalized.contractTypes = toArray(normalized.contractTypes ?? []); + } + return normalized; +} + +function buildParentClaimsChain(chain) { + if (!Array.isArray(chain) || chain.length < 2) { + return null; + } + const orderedSegments = []; + for (let index = chain.length - 2; index >= 0; index -= 1) { + const subjectClaims = normalizeScopeClaims(chain[index]?.credentialSubject); + if (subjectClaims && typeof subjectClaims === 'object') { + orderedSegments.push(subjectClaims); + } + } + let head = null; + let previousNode = null; + orderedSegments.forEach((claims) => { + const node = { ...claims }; + if (!head) { + head = node; + } + if (previousNode) { + previousNode.parentClaims = node; + } + previousNode = node; + }); + return head; +} + +function deriveResourceTypesFromChain(chain) { + const root = chain[0]; + if (!root || !Array.isArray(root.type)) { + return []; + } + const filtered = root.type.filter( + (typeName) => typeof typeName === 'string' && !RESERVED_RESOURCE_TYPES.has(typeName), + ); + return filtered.length > 0 ? filtered : []; +} + +export function buildCredentialChainFromTail(normalizedById, tailId, seenChains) { + if (seenChains.has(tailId)) { + return null; + } + const chain = []; + const visited = new Set(); + let current = normalizedById.get(tailId); + if (!current) { + throw new DelegationError( + DelegationErrorCodes.MISSING_CREDENTIAL, + `Tail credential ${tailId} not found in presentation`, + ); + } + while (current) { + if (visited.has(current.id)) { + throw new DelegationError( + DelegationErrorCodes.CHAIN_CYCLE, + 'Delegation chain contains a cycle', + ); + } + visited.add(current.id); + chain.unshift(current); + const prevId = current.previousCredentialId; + if (!prevId) { + break; + } + current = normalizedById.get(prevId); + if (!current) { + throw new DelegationError( + DelegationErrorCodes.MISSING_CREDENTIAL, + `Missing credential ${prevId} referenced by ${chain[0].id}`, + ); + } + } + seenChains.add(tailId); + return chain; +} + +async function resolveDelegationPolicyForChain(chain, binding, delegationPolicy, documentLoader) { + if (!binding.hasPolicy || delegationPolicy?.enabled === false) { + return; + } + if (typeof documentLoader !== 'function') { + throw new DelegationError( + DelegationErrorCodes.POLICY_DOCUMENT_LOADER_REQUIRED, + 'documentLoader is required to fetch the delegation policy document when credentials include delegationPolicyId and delegationPolicyDigest', + ); + } + await resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: binding.rootPolicyId, + rootPolicyDigest: binding.rootPolicyDigest, + documentLoader, + }); +} + +function summarizeNormalizedChain(chain) { + const hasDelegationLinks = chain.some( + (vc) => typeof vc.previousCredentialId === 'string' && vc.previousCredentialId.length > 0, + ); + return hasDelegationLinks + ? summarizeDelegationChain(chain.map((chainItem) => chainItem.expandedNode)) + : summarizeStandaloneCredential(chain[chain.length - 1]); +} + +function runRifyOnChain(chain, rootCredentialId, authorizedGraphId) { + const premises = buildRifyPremisesFromChain(chain, rootCredentialId); + if (!Array.isArray(premises) || premises.some((quad) => quad.length !== 4)) { + throw new DelegationError( + DelegationErrorCodes.RIFY_FAILURE, + 'Invalid premises generated for rify inference', + ); + } + const rules = buildRifyRules(rootCredentialId, authorizedGraphId); + try { + const derived = infer(premises, rules); + return { premises, derived }; + } catch (error) { + throw new DelegationError( + DelegationErrorCodes.RIFY_FAILURE, + `rify inference failed: ${error.message ?? error}`, + ); + } +} + +function assertAuthorizedClaimsAllowed(premises, derived, authorizedGraphId, failOnUnauthorizedClaims) { + const unauthorizedClaims = findUnauthorizedClaims(premises, derived, authorizedGraphId); + if (unauthorizedClaims.length === 0 || !failOnUnauthorizedClaims) { + return; + } + const details = unauthorizedClaims + .slice(0, 5) + .map((claim) => `${claim.subject}.${claim.claim}`) + .join(', '); + throw new DelegationError( + DelegationErrorCodes.UNAUTHORIZED_CLAIM, + `Unauthorized claims detected in delegation chain${details ? `: ${details}` : ''}`, + ); +} + +function extendFactsAndSummaryAfterInference({ + summary, + facts, + authorizedClaimUnion, + authorizedPerSubject, + resourceTypes, + rootClaims, + tailClaims, + parentClaimsChain, + chain, +}) { + const parentCredential = chain.length > 1 ? chain[chain.length - 2] : null; + return { + facts: { + ...facts, + authorizedClaims: authorizedClaimUnion, + authorizedClaimsBySubject: authorizedPerSubject, + resourceTypes, + parentClaims: parentClaimsChain ?? parentCredential?.credentialSubject ?? {}, + }, + summary: { + ...summary, + authorizedClaims: authorizedClaimUnion, + authorizedClaimsBySubject: authorizedPerSubject, + resourceTypes, + rootClaims, + tailClaims, + parentClaims: parentClaimsChain, + }, + }; +} + +export async function evaluateDelegationChainForPresentation({ + chain, + delegationPolicy, + documentLoader, + failOnUnauthorizedClaims, + normalizedCredentials, + presentationSigner, +}) { + assertDelegationPolicyRootPlacement(chain); + const binding = extractRootPolicyBinding(chain); + await resolveDelegationPolicyForChain(chain, binding, delegationPolicy, documentLoader); + + const rootCredentialId = chain[0]?.id; + const summary = summarizeNormalizedChain(chain); + const { resourceId } = summary; + const resolvedPrincipalId = presentationSigner; + const authorizedGraphId = rootCredentialId + ? `${AUTHORIZED_GRAPH_PREFIX}:${rootCredentialId}` + : AUTHORIZED_GRAPH_PREFIX; + const resourceTypes = deriveResourceTypesFromChain(chain); + + const rootCredential = chain[0]; + const tailCredential = chain[chain.length - 1]; + const rootClaims = normalizeScopeClaims(rootCredential?.credentialSubject ?? {}); + const tailClaims = normalizeScopeClaims(tailCredential?.credentialSubject ?? {}); + const parentClaimsChain = buildParentClaimsChain(chain); + const facts = { + ...summary, + resourceId, + actionIds: [ACTION_VERIFY], + principalId: resolvedPrincipalId, + presentationSigner, + resourceTypes, + rootClaims, + tailClaims, + parentClaims: parentClaimsChain, + }; + const actorIds = collectActorIds(chain, presentationSigner); + const entities = baseEntities(actorIds, facts.actionIds); + applyCredentialFacts(entities, facts); + + const { premises, derived } = runRifyOnChain(chain, rootCredentialId, authorizedGraphId); + + const { union: authorizedClaimUnion, perSubject: authorizedPerSubject } = collectAuthorizedClaims( + chain, + derived, + authorizedGraphId, + normalizedCredentials, + ); + + assertAuthorizedClaimsAllowed(premises, derived, authorizedGraphId, failOnUnauthorizedClaims); + + const { facts: factsOut, summary: summaryOut } = extendFactsAndSummaryAfterInference({ + summary, + facts, + authorizedClaimUnion, + authorizedPerSubject, + resourceTypes, + rootClaims, + tailClaims, + parentClaimsChain, + chain, + }); + + return { + summary: summaryOut, + facts: factsOut, + entities, + chain, + premises, + derived, + authorizedClaims: authorizedClaimUnion, + authorizedClaimsBySubject: authorizedPerSubject, + resourceTypes, + }; +} diff --git a/packages/vc-delegation-engine/src/engine/engine-constants.js b/packages/vc-delegation-engine/src/engine/engine-constants.js new file mode 100644 index 000000000..1f585b727 --- /dev/null +++ b/packages/vc-delegation-engine/src/engine/engine-constants.js @@ -0,0 +1,18 @@ +import { shortenTerm } from '../jsonld-utils.js'; +import { VC_NS, VC_TYPE_DELEGATION_CREDENTIAL } from '../constants.js'; + +export const CONTROL_PREDICATES = new Set(['allows', 'delegatesTo', 'listsClaim', 'inheritsParent']); + +export const DELEGATION_TYPE_NAME = shortenTerm(VC_TYPE_DELEGATION_CREDENTIAL); + +export const RESERVED_RESOURCE_TYPES = new Set([ + `${VC_NS}VerifiableCredential`, + 'VerifiableCredential', + VC_TYPE_DELEGATION_CREDENTIAL, + 'DelegationCredential', +]); + +export const VC_JWT_PATTERN = /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/; +export const VC_JWT_ID_PREFIX = 'urn:vcjwt:'; + +export const AUTHORIZED_GRAPH_PREFIX = 'urn:authorized'; diff --git a/packages/vc-delegation-engine/src/engine/presentation-ingest.js b/packages/vc-delegation-engine/src/engine/presentation-ingest.js new file mode 100644 index 000000000..1ac028d05 --- /dev/null +++ b/packages/vc-delegation-engine/src/engine/presentation-ingest.js @@ -0,0 +1,304 @@ +import jsonld from 'jsonld'; +import base64url from 'base64url'; + +import { + shortenTerm, + firstExpandedValue, + findExpandedTermId, + normalizeSubject, +} from '../jsonld-utils.js'; +import { firstArrayItem, toArray } from '../utils.js'; +import { + VC_ISSUER, + VC_PREVIOUS_CREDENTIAL_ID, + VC_ROOT_CREDENTIAL_ID, + VC_DELEGATION_POLICY_ID, + VC_DELEGATION_POLICY_DIGEST, + VC_DELEGATION_ROLE_ID, + VC_ISSUANCE_DATE, + VC_EXPIRATION_DATE, + UNKNOWN_IDENTIFIER, +} from '../constants.js'; +import { DelegationError, DelegationErrorCodes } from '../errors.js'; +import { DELEGATION_TYPE_NAME, VC_JWT_ID_PREFIX, VC_JWT_PATTERN } from './engine-constants.js'; + +function ensureSubjectId(subject, fallbackId) { + if (!subject || typeof subject !== 'object' || subject.id) { + return subject; + } + if (fallbackId) { + return { ...subject, id: fallbackId }; + } + return subject; +} + +function resolveCredentialContext(credentialId, contexts, contextOverride) { + const context = contexts.get(credentialId) ?? contextOverride; + if (!context) { + throw new DelegationError( + DelegationErrorCodes.MISSING_CONTEXT, + `Missing compaction context for credential ${credentialId}`, + ); + } + return context; +} + +function createSkippedCredential({ + credentialId = UNKNOWN_IDENTIFIER, + issuerId = UNKNOWN_IDENTIFIER, + subjectId = UNKNOWN_IDENTIFIER, + types = [], + claims = {}, +}) { + return { + credentialId, + issuerId, + subjectId, + types: toArray(types).filter(Boolean), + claims, + }; +} + +function parseJwtPayloadParts(jwt) { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'Malformed VC-JWT encountered in presentation', + ); + } + try { + return JSON.parse(base64url.decode(parts[1])); + } catch { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'Unable to decode VC-JWT payload', + ); + } +} + +function normalizeJwtCredentialSubject(credentialSubject, subjectId) { + if (!credentialSubject) { + return subjectId ? { id: subjectId } : {}; + } + if (Array.isArray(credentialSubject)) { + return credentialSubject.map((subject) => ensureSubjectId(subject, subjectId)); + } + return ensureSubjectId(credentialSubject, subjectId); +} + +function buildJwtSkipOutcome(normalizedCredential, payload, subjectId) { + return { + kind: 'skipped', + metadata: { + credentialId: normalizedCredential.id, + claims: normalizedCredential.credentialSubject ?? {}, + issuerId: normalizedCredential.issuer ?? payload?.iss ?? UNKNOWN_IDENTIFIER, + subjectId: normalizedCredential.credentialSubject?.id ?? subjectId ?? UNKNOWN_IDENTIFIER, + types: toArray(normalizedCredential.type), + }, + }; +} + +function ensureJwtContextAndTypeArrays(normalizedCredential) { + const next = { ...normalizedCredential }; + if (!Array.isArray(next['@context'])) { + next['@context'] = [next['@context']]; + } + if (!next.type) { + next.type = ['VerifiableCredential']; + } else if (!Array.isArray(next.type)) { + next.type = [next.type]; + } + return next; +} + +async function expandJwtCredential(jwt, index, documentLoader) { + const payload = parseJwtPayloadParts(jwt); + const credential = payload?.vc; + if (!credential || typeof credential !== 'object') { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'VC-JWT payload missing vc object', + ); + } + + const normalizedCredential = { + ...credential, + id: credential.id ?? payload?.jti ?? `${VC_JWT_ID_PREFIX}${index}`, + credentialSubject: normalizeJwtCredentialSubject(credential.credentialSubject, payload?.sub), + }; + if (!normalizedCredential.issuer && payload?.iss) { + normalizedCredential.issuer = payload.iss; + } + + const hasJsonLdContext = Array.isArray(normalizedCredential['@context']) + || typeof normalizedCredential['@context'] === 'string'; + if (!hasJsonLdContext) { + return buildJwtSkipOutcome(normalizedCredential, payload, payload?.sub); + } + + const forExpand = ensureJwtContextAndTypeArrays(normalizedCredential); + const loader = documentLoader ?? jsonld.documentLoaders?.node?.(); + const expandOptions = loader ? { documentLoader: loader } : {}; + const expanded = await jsonld.expand(forExpand, expandOptions); + const credentialNode = firstArrayItem( + expanded, + 'Expanded VC-JWT credential missing node', + ); + + return { + kind: 'expanded', + credentialNode, + contextOverride: forExpand['@context'], + }; +} + +async function normalizeJwtEntry(value, index, documentLoader) { + const outcome = await expandJwtCredential(value, index, documentLoader); + if (outcome.kind === 'skipped') { + return { skipped: createSkippedCredential(outcome.metadata) }; + } + return { + credentialNode: outcome.credentialNode, + contextOverride: outcome.contextOverride, + }; +} + +async function normalizeCredentialEntry(entry, index, { documentLoader }) { + if (Array.isArray(entry?.['@graph'])) { + const credentialNode = firstArrayItem( + entry['@graph'], + 'Expanded verifiableCredential entry is missing @graph node', + ); + const referenceId = credentialNode?.['@id']; + const onlyHasId = referenceId + && Object.keys(credentialNode).every((key) => key === '@id'); + if (onlyHasId && VC_JWT_PATTERN.test(referenceId)) { + return normalizeJwtEntry(referenceId, index, documentLoader); + } + return { + credentialNode, + contextOverride: null, + }; + } + + const literalValue = entry?.['@value']; + if (typeof literalValue === 'string' && VC_JWT_PATTERN.test(literalValue)) { + return normalizeJwtEntry(literalValue, index, documentLoader); + } + + const referenceId = entry?.['@id']; + if (typeof referenceId === 'string' && VC_JWT_PATTERN.test(referenceId)) { + return normalizeJwtEntry(referenceId, index, documentLoader); + } + + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'Unsupported credential entry encountered in presentation', + ); +} + +function buildCompactCredentialRecord(credentialNode, credentialSubject) { + const credentialId = credentialNode?.['@id']; + return { + id: credentialId, + type: toArray(credentialNode['@type']).map((value) => shortenTerm(value)), + issuer: firstExpandedValue(credentialNode[VC_ISSUER]), + previousCredentialId: findExpandedTermId(credentialNode, VC_PREVIOUS_CREDENTIAL_ID), + rootCredentialId: findExpandedTermId(credentialNode, VC_ROOT_CREDENTIAL_ID), + credentialSubject, + delegationPolicyId: findExpandedTermId(credentialNode, VC_DELEGATION_POLICY_ID), + delegationPolicyDigest: findExpandedTermId(credentialNode, VC_DELEGATION_POLICY_DIGEST), + delegationRoleId: findExpandedTermId(credentialNode, VC_DELEGATION_ROLE_ID), + issuanceDate: findExpandedTermId(credentialNode, VC_ISSUANCE_DATE), + expirationDate: findExpandedTermId(credentialNode, VC_EXPIRATION_DATE), + expandedNode: credentialNode, + }; +} + +async function compactCredentialFromNode(credentialNode, context, documentLoader) { + const compactOptions = documentLoader ? { documentLoader } : undefined; + const compacted = await jsonld.compact( + credentialNode, + { '@context': context }, + compactOptions, + ); + return normalizeSubject(compacted?.credentialSubject); +} + +async function ingestOnePresentationCredential(entry, index, { + normalizedById, + referencedPreviousIds, + contexts, + documentLoader, + skippedCredentialIds, + skippedCredentials, +}) { + const normalizedEntry = await normalizeCredentialEntry(entry, index, { documentLoader }); + if (normalizedEntry?.skipped) { + const { skipped } = normalizedEntry; + skippedCredentialIds.add(skipped.credentialId); + skippedCredentials.push(skipped); + return; + } + const { credentialNode, contextOverride } = normalizedEntry; + const credentialId = credentialNode?.['@id']; + if (typeof credentialId !== 'string' || credentialId.length === 0) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + 'Expanded credential node must include an @id', + ); + } + const context = resolveCredentialContext(credentialId, contexts, contextOverride); + const credentialSubject = await compactCredentialFromNode(credentialNode, context, documentLoader); + const credential = buildCompactCredentialRecord(credentialNode, credentialSubject); + normalizedById.set(credentialId, credential); + const prevId = credential.previousCredentialId; + if (typeof prevId === 'string' && prevId.length > 0) { + referencedPreviousIds.add(prevId); + } +} + +export async function ingestAllPresentationCredentials(vcEntries, ingestCtx) { + const normalizedById = new Map(); + const referencedPreviousIds = new Set(); + for (let index = 0; index < vcEntries.length; index += 1) { + const entry = vcEntries[index]; + // eslint-disable-next-line no-await-in-loop + await ingestOnePresentationCredential(entry, index, { + ...ingestCtx, + normalizedById, + referencedPreviousIds, + }); + } + return { normalizedById, referencedPreviousIds }; +} + +export function assertDelegationReferencesResolved(credential, normalizedById) { + const { + id, previousCredentialId, rootCredentialId, type, + } = credential; + const isDelegationCredential = Array.isArray(type) && type.includes(DELEGATION_TYPE_NAME); + if (!isDelegationCredential) { + return; + } + if (previousCredentialId && !normalizedById.has(previousCredentialId)) { + throw new DelegationError( + DelegationErrorCodes.MISSING_CREDENTIAL, + `Missing credential ${previousCredentialId} referenced as previous by ${id}`, + ); + } + if (!rootCredentialId || typeof rootCredentialId !== 'string' || rootCredentialId.length === 0) { + throw new DelegationError( + DelegationErrorCodes.INVALID_CREDENTIAL, + `Delegation credential ${id} is missing rootCredentialId`, + ); + } + if (!normalizedById.has(rootCredentialId)) { + throw new DelegationError( + DelegationErrorCodes.MISSING_CREDENTIAL, + `Missing root credential ${rootCredentialId} referenced by ${id}`, + ); + } +} diff --git a/packages/vc-delegation-engine/src/errors.js b/packages/vc-delegation-engine/src/errors.js index 0a3af6209..eeb899711 100644 --- a/packages/vc-delegation-engine/src/errors.js +++ b/packages/vc-delegation-engine/src/errors.js @@ -6,6 +6,16 @@ export const DelegationErrorCodes = { MISSING_CREDENTIAL: 'MISSING_CREDENTIAL', RIFY_FAILURE: 'RIFY_FAILURE', UNAUTHORIZED_CLAIM: 'UNAUTHORIZED_CLAIM', + POLICY_DOCUMENT_LOADER_REQUIRED: 'POLICY_DOCUMENT_LOADER_REQUIRED', + POLICY_DOCUMENT_LOAD_FAILED: 'POLICY_DOCUMENT_LOAD_FAILED', + POLICY_DIGEST_MISMATCH: 'POLICY_DIGEST_MISMATCH', + POLICY_ID_MISMATCH: 'POLICY_ID_MISMATCH', + POLICY_SEMANTIC_INVALID: 'POLICY_SEMANTIC_INVALID', + POLICY_ROLE_INVALID: 'POLICY_ROLE_INVALID', + POLICY_DEPTH_EXCEEDED: 'POLICY_DEPTH_EXCEEDED', + POLICY_CAPABILITY_INVALID: 'POLICY_CAPABILITY_INVALID', + POLICY_LIFETIME_INVALID: 'POLICY_LIFETIME_INVALID', + POLICY_MONOTONIC_VIOLATION: 'POLICY_MONOTONIC_VIOLATION', GENERAL: 'CHAIN_VALIDATION_ERROR', }; diff --git a/packages/vc-delegation-engine/src/index.js b/packages/vc-delegation-engine/src/index.js index e9c56ce05..fccdf4343 100644 --- a/packages/vc-delegation-engine/src/index.js +++ b/packages/vc-delegation-engine/src/index.js @@ -6,4 +6,23 @@ export { runCedarAuthorization, } from './authorization/cedar/index.js'; export { DelegationError, DelegationErrorCodes } from './errors.js'; -export { MAY_CLAIM_IRI } from './constants.js'; +export { + MAY_CLAIM_IRI, + VC_DELEGATION_POLICY_ID, + VC_DELEGATION_POLICY_DIGEST, + VC_DELEGATION_ROLE_ID, +} from './constants.js'; +export { + canonicalPolicyJson, + computePolicyDigestHex, + verifyPolicyDigest, +} from './delegation-policy-digest.js'; +export { validateDelegationPolicy } from './delegation-policy-validate.js'; +export { + assertDelegationPolicyRootPlacement, + coerceCapabilityValueForSchema, + durationToMilliseconds, + fetchDelegationPolicyJson, + resolveAndVerifyDelegationPolicy, + verifyDelegationPolicyChain, +} from './delegation-policy-chain.js'; diff --git a/packages/vc-delegation-engine/src/jsonld-utils.js b/packages/vc-delegation-engine/src/jsonld-utils.js index aa0e50a96..7d38ec990 100644 --- a/packages/vc-delegation-engine/src/jsonld-utils.js +++ b/packages/vc-delegation-engine/src/jsonld-utils.js @@ -1,4 +1,3 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { VC_TYPE, SECURITY_PROOF, @@ -103,6 +102,57 @@ export function findExpandedTermId(node, term) { return undefined; } +function mapJsonLdArrayElement(item) { + if (item && typeof item === 'object') { + if ('@value' in item) { + return item['@value']; + } + if ('@id' in item) { + return item['@id']; + } + } + return item; +} + +function unwrapJsonLdScalar(value) { + if (value && typeof value === 'object') { + if ('@value' in value) { + return value['@value']; + } + if ('@id' in value) { + return value['@id']; + } + } + return value; +} + +function normalizeSubjectFieldValue(key, value) { + if (Array.isArray(value)) { + const mapped = value.map(mapJsonLdArrayElement); + if (!MAY_CLAIM_ALIAS_KEYS.includes(key) && mapped.length === 1) { + return mapped[0]; + } + return mapped; + } + return unwrapJsonLdScalar(value); +} + +function withMayClaimAliasesFromNormalizedKeys(normalized) { + const mayClaims = MAY_CLAIM_ALIAS_KEYS.map((alias) => normalized[alias]).find( + (val) => val !== undefined, + ); + if (mayClaims === undefined) { + return normalized; + } + const list = Array.isArray(mayClaims) ? mayClaims : [mayClaims]; + const canonical = list.map((claim) => String(claim)); + return { + ...normalized, + [MAY_CLAIM_IRI]: canonical, + mayClaim: canonical, + }; +} + export function normalizeSubject(compactedSubject) { const subjectValue = Array.isArray(compactedSubject) ? compactedSubject[0] @@ -112,42 +162,11 @@ export function normalizeSubject(compactedSubject) { } const normalized = { ...subjectValue }; - Object.entries(normalized).forEach(([key, value]) => { - if (Array.isArray(value)) { - normalized[key] = value.map((item) => { - if (item && typeof item === 'object') { - if ('@value' in item) { - return item['@value']; - } - if ('@id' in item) { - return item['@id']; - } - } - return item; - }); - if (!MAY_CLAIM_ALIAS_KEYS.includes(key) && normalized[key].length === 1) { - const [singleValue] = normalized[key]; - normalized[key] = singleValue; - } - } else if (value && typeof value === 'object') { - if ('@value' in value) { - normalized[key] = value['@value']; - } else if ('@id' in value) { - normalized[key] = value['@id']; - } - } + Object.keys(normalized).forEach((key) => { + normalized[key] = normalizeSubjectFieldValue(key, normalized[key]); }); - const mayClaims = MAY_CLAIM_ALIAS_KEYS.map((alias) => normalized[alias]).find( - (val) => val !== undefined, - ); - if (mayClaims !== undefined) { - const list = Array.isArray(mayClaims) ? mayClaims : [mayClaims]; - normalized[MAY_CLAIM_IRI] = list.map((claim) => String(claim)); - normalized.mayClaim = normalized[MAY_CLAIM_IRI]; - } - - return normalized; + return withMayClaimAliasesFromNormalizedKeys(normalized); } export function matchesType(vc, typeName) { diff --git a/packages/vc-delegation-engine/src/rify-helpers.js b/packages/vc-delegation-engine/src/rify-helpers.js index e680a6dd0..324c19cb9 100644 --- a/packages/vc-delegation-engine/src/rify-helpers.js +++ b/packages/vc-delegation-engine/src/rify-helpers.js @@ -1,4 +1,3 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { matchesType } from './jsonld-utils.js'; import { extractMayClaims, collectSubjectClaimEntries } from './utils.js'; @@ -43,6 +42,67 @@ export function buildRifyRules(rootGraphId, authorizedGraphId) { ]; } +function pushRootMayClaimPremises(rootSubjectId, rootGraphId, rootCredential) { + const premises = []; + if (!rootSubjectId) { + return premises; + } + const rootMayClaims = extractMayClaims(rootCredential.credentialSubject ?? {}); + rootMayClaims.forEach((claim) => { + premises.push([rootSubjectId, 'allows', claim, rootGraphId]); + }); + return premises; +} + +function pushDelegationVcPremises(vc, rootGraphId) { + const premises = []; + if (!matchesType(vc, 'DelegationCredential')) { + return premises; + } + const parent = vc.issuer; + const child = vc.credentialSubject?.id; + if (!parent || !child) { + return premises; + } + premises.push([parent, 'delegatesTo', child, rootGraphId]); + const listed = extractMayClaims(vc.credentialSubject ?? {}); + listed.forEach((claim) => { + premises.push([child, 'listsClaim', claim, rootGraphId]); + }); + return premises; +} + +function valueForAttestationPremise(value) { + if (value && typeof value === 'object') { + return JSON.stringify(value); + } + if (typeof value === 'string') { + return value; + } + return String(value); +} + +function pushVcAttestationPremises(vc, rootId, rootGraphId) { + const premises = []; + const subjectId = vc.credentialSubject?.id; + const issuerId = vc.issuer; + if (!subjectId || !issuerId) { + return premises; + } + const claimEntries = collectSubjectClaimEntries(vc.credentialSubject ?? {}); + claimEntries.forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + const valueForPremise = valueForAttestationPremise(value); + premises.push([subjectId, key, valueForPremise, issuerId]); + if (vc.id === rootId) { + premises.push([issuerId, 'allows', key, rootGraphId]); + } + }); + return premises; +} + export function buildRifyPremisesFromChain(chain, rootGraphId) { if (!Array.isArray(chain) || chain.length === 0) { throw new Error('buildRifyPremisesFromChain requires a non-empty chain'); @@ -50,69 +110,22 @@ export function buildRifyPremisesFromChain(chain, rootGraphId) { if (typeof rootGraphId !== 'string' || rootGraphId.length === 0) { throw new Error('Root credential must include an id for rify premises'); } - const premises = []; const rootCredential = chain[0]; const rootSubjectId = rootCredential.credentialSubject?.id; - if (rootSubjectId) { - const rootMayClaims = extractMayClaims(rootCredential.credentialSubject ?? {}); - rootMayClaims.forEach((claim) => { - premises.push([rootSubjectId, 'allows', claim, rootGraphId]); - }); - } - - for (const vc of chain) { - if (!matchesType(vc, 'DelegationCredential')) { - // eslint-disable-next-line no-continue - continue; - } - - const parent = vc.issuer; - const child = vc.credentialSubject?.id; - if (!parent || !child) { - // eslint-disable-next-line no-continue - continue; - } - - premises.push([parent, 'delegatesTo', child, rootGraphId]); - - const listed = extractMayClaims(vc.credentialSubject ?? {}); - if (listed.length > 0) { - listed.forEach((claim) => { - premises.push([child, 'listsClaim', claim, rootGraphId]); - }); - } - } + const premises = [ + ...pushRootMayClaimPremises(rootSubjectId, rootGraphId, rootCredential), + ]; + chain.forEach((vc) => { + premises.push(...pushDelegationVcPremises(vc, rootGraphId)); + }); const rootId = rootCredential.id; const attestations = (chain ?? []).filter( (vc) => vc.rootCredentialId === rootId || vc.id === rootId, ); - attestations.forEach((vc) => { - const subjectId = vc.credentialSubject?.id; - const issuerId = vc.issuer; - if (!subjectId || !issuerId) { - return; - } - const claimEntries = collectSubjectClaimEntries(vc.credentialSubject ?? {}); - claimEntries.forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - let valueForPremise; - if (value && typeof value === 'object') { - valueForPremise = JSON.stringify(value); - } else if (typeof value === 'string') { - valueForPremise = value; - } else { - valueForPremise = String(value); - } - premises.push([subjectId, key, valueForPremise, issuerId]); - if (vc.id === rootId) { - premises.push([issuerId, 'allows', key, rootGraphId]); - } - }); + premises.push(...pushVcAttestationPremises(vc, rootId, rootGraphId)); }); const invalidPremises = premises.filter( diff --git a/packages/vc-delegation-engine/src/summarize.js b/packages/vc-delegation-engine/src/summarize.js index e434fa6ba..e5e8f12fd 100644 --- a/packages/vc-delegation-engine/src/summarize.js +++ b/packages/vc-delegation-engine/src/summarize.js @@ -1,4 +1,3 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { matchesType, extractGraphId, @@ -24,12 +23,7 @@ function normalizeTypeList(value) { return []; } -// Derives delegation-chain facts (root/tail info, depth, mayClaim, etc.) from chain of expanded JSON-LD credentials -export function summarizeDelegationChain(credentials) { - if (credentials.length === 0) { - throw new Error('Chain must include credentials to summarize'); - } - +function buildCredentialMap(credentials) { const credentialMap = new Map(); credentials.forEach((vc) => { const vcId = vc['@id']; @@ -38,7 +32,10 @@ export function summarizeDelegationChain(credentials) { } credentialMap.set(vcId, vc); }); + return credentialMap; +} +function collectReferencedPreviousIds(credentials) { const referencedPreviousIds = new Set(); credentials.forEach((vc) => { const prevId = findExpandedTermId(vc, VC_PREVIOUS_CREDENTIAL_ID); @@ -46,32 +43,34 @@ export function summarizeDelegationChain(credentials) { referencedPreviousIds.add(prevId); } }); + return referencedPreviousIds; +} +function getUniqueTailCredential(credentials, referencedPreviousIds) { const tailCandidates = credentials.filter((vc) => !referencedPreviousIds.has(vc['@id'])); - if (tailCandidates.length === 0) { throw new Error('Unable to determine tail credential'); } if (tailCandidates.length > 1) { throw new Error('Unable to determine unique tail credential'); } + return tailCandidates[0]; +} - const tailCredential = tailCandidates[0]; - +function assertTailHasPreviousAndIssuer(tailCredential) { if (typeof findExpandedTermId(tailCredential, VC_PREVIOUS_CREDENTIAL_ID) !== 'string') { throw new Error('Tail credential must include previousCredentialId'); } - - const resourceId = tailCredential['@id']; const tailIssuer = extractGraphId(tailCredential, VC_ISSUER); if (typeof tailIssuer !== 'string' || tailIssuer.length === 0) { throw new Error('Tail credential must include a string issuer'); } +} +function walkChainFromTail(tailCredential, credentialMap) { const chain = []; const visited = new Set(); let current = tailCredential; - while (current) { if (visited.has(current['@id'])) { throw new Error('Credential chain contains a cycle'); @@ -88,7 +87,10 @@ export function summarizeDelegationChain(credentials) { } current = prev; } + return chain; +} +function validateIssuerDelegateBinding(chain) { for (let i = 1; i < chain.length; i += 1) { const parent = chain[i - 1]; const child = chain[i]; @@ -103,8 +105,9 @@ export function summarizeDelegationChain(credentials) { ); } } +} - const rootCredential = chain[0]; +function validateRootIdsAlongChain(rootCredential, chainRest) { const expectedRootId = rootCredential['@id']; const rootDeclaredId = extractGraphId(rootCredential, VC_ROOT_CREDENTIAL_ID); if (rootDeclaredId && rootDeclaredId !== expectedRootId) { @@ -112,8 +115,7 @@ export function summarizeDelegationChain(credentials) { `Root credential ${rootCredential['@id']} must declare rootCredentialId equal to its own id`, ); } - - chain.slice(1).forEach((vc) => { + chainRest.forEach((vc) => { const declaredRootId = findExpandedTermId(vc, VC_ROOT_CREDENTIAL_ID); if (typeof declaredRootId !== 'string' || declaredRootId.length === 0) { throw new Error( @@ -126,24 +128,22 @@ export function summarizeDelegationChain(credentials) { ); } }); +} +function assertRootDelegationCredential(rootCredential) { if (!matchesType(rootCredential, VC_TYPE_DELEGATION_CREDENTIAL)) { throw new Error('Root credential must be a DelegationCredential'); } - const rootIssuerId = extractGraphId(rootCredential, VC_ISSUER); if (typeof rootIssuerId !== 'string' || rootIssuerId.length === 0) { throw new Error('Root credential must include a string issuer'); } + return rootIssuerId; +} - const rootTypes = normalizeTypeList(rootCredential['@type']); - - const rootClaims = extractGraphObject(rootCredential, VC_SUBJECT) ?? {}; - - const depthByActor = new Map([[rootIssuerId, 0]]); +function createDelegationRegistrar() { const delegations = []; const registeredDelegations = new Set(); - const registerDelegation = (principalId, depth) => { if (typeof principalId !== 'string' || principalId.length === 0) { throw new Error('Tried to register delegation with no principal ID'); @@ -154,33 +154,63 @@ export function summarizeDelegationChain(credentials) { registeredDelegations.add(principalId); delegations.push({ principalId, depth, actions: [ACTION_VERIFY] }); }; + return { delegations, registerDelegation }; +} +function accumulateDelegationDepths(chain, rootIssuerId, rootClaims, registerDelegation) { + const depthByActor = new Map([[rootIssuerId, 0]]); + const delegationCredentialsInChain = []; const rootDelegateId = extractGraphId(rootClaims, VC_SUBJECT); if (typeof rootDelegateId === 'string' && rootDelegateId.length > 0) { registerDelegation(rootDelegateId, 1); depthByActor.set(rootDelegateId, 1); } - - const delegationCredentialsInChain = []; - chain.forEach((vc) => { if (!matchesType(vc, VC_TYPE_DELEGATION_CREDENTIAL)) { return; } - delegationCredentialsInChain.push(vc); - const delegator = extractGraphId(vc, VC_ISSUER); const delegate = extractGraphId(vc, VC_SUBJECT); if (typeof delegate !== 'string' || delegate.length === 0) { return; } - const parentDepth = depthByActor.get(delegator) ?? 0; const depth = parentDepth + 1; depthByActor.set(delegate, depth); registerDelegation(delegate, depth); }); + return { depthByActor, delegationCredentialsInChain }; +} + +// Derives delegation-chain facts (root/tail info, depth, mayClaim, etc.) from chain of expanded JSON-LD credentials +export function summarizeDelegationChain(credentials) { + if (credentials.length === 0) { + throw new Error('Chain must include credentials to summarize'); + } + + const credentialMap = buildCredentialMap(credentials); + const referencedPreviousIds = collectReferencedPreviousIds(credentials); + const tailCredential = getUniqueTailCredential(credentials, referencedPreviousIds); + assertTailHasPreviousAndIssuer(tailCredential); + + const resourceId = tailCredential['@id']; + const chain = walkChainFromTail(tailCredential, credentialMap); + validateIssuerDelegateBinding(chain); + + const rootCredential = chain[0]; + validateRootIdsAlongChain(rootCredential, chain.slice(1)); + const rootIssuerId = assertRootDelegationCredential(rootCredential); + const rootTypes = normalizeTypeList(rootCredential['@type']); + const rootClaims = extractGraphObject(rootCredential, VC_SUBJECT) ?? {}; + + const { delegations, registerDelegation } = createDelegationRegistrar(); + const { depthByActor, delegationCredentialsInChain } = accumulateDelegationDepths( + chain, + rootIssuerId, + rootClaims, + registerDelegation, + ); const tailTypes = normalizeTypeList(tailCredential['@type']); const tailDelegateId = extractGraphId(tailCredential, VC_SUBJECT); diff --git a/packages/vc-delegation-engine/src/utils/duration.js b/packages/vc-delegation-engine/src/utils/duration.js new file mode 100644 index 000000000..40e18c718 --- /dev/null +++ b/packages/vc-delegation-engine/src/utils/duration.js @@ -0,0 +1,28 @@ +const MS_MINUTE = 60 * 1000; +const MS_HOUR = 60 * MS_MINUTE; +const MS_DAY = 24 * MS_HOUR; + +/** + * @param {{ value: number, unit: string }} duration + * @returns {number} + */ +export function durationToMilliseconds(duration) { + if (!duration || typeof duration.value !== 'number' || typeof duration.unit !== 'string') { + return NaN; + } + const { value, unit } = duration; + switch (unit) { + case 'minutes': + return value * MS_MINUTE; + case 'hours': + return value * MS_HOUR; + case 'days': + return value * MS_DAY; + case 'months': + return value * 30 * MS_DAY; + case 'years': + return value * 365 * MS_DAY; + default: + return NaN; + } +} diff --git a/packages/vc-delegation-engine/tests/examples.test.js b/packages/vc-delegation-engine/tests/examples.test.js index b0b8cc16c..a3ca3f727 100644 --- a/packages/vc-delegation-engine/tests/examples.test.js +++ b/packages/vc-delegation-engine/tests/examples.test.js @@ -113,7 +113,8 @@ describe('delegation engine examples', () => { pharmacyPresentations.guardianDenied, pharmacyPolicies, ); - expect(deniedResult.authorization.decision).toBe('deny'); + expect(deniedResult.verification.decision).toBe('deny'); + expect(deniedResult.verification.failures.some((f) => f.code === 'POLICY_CAPABILITY_INVALID')).toBe(true); }); it('accepts multi-delegation presentation', async () => { diff --git a/packages/vc-delegation-engine/tests/fixtures/delegation-pharmacy-policy.json b/packages/vc-delegation-engine/tests/fixtures/delegation-pharmacy-policy.json new file mode 100644 index 000000000..04bb50232 --- /dev/null +++ b/packages/vc-delegation-engine/tests/fixtures/delegation-pharmacy-policy.json @@ -0,0 +1,207 @@ +{ + "type": "DelegationPolicy", + "version": "1.0", + "createdAt": "2026-03-18", + "id": "urn:uuid:2f77f5d0-2e31-4a52-9f24-3d28c6eb8a11", + "ruleset": { + "rulesetVersion": "2026-03-pharmacy-v1", + "delegationTarget": "single-credential", + "overallConstraints": { + "maxDelegationDepth": 3 + }, + "capabilities": [ + { + "name": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay", "Cancel"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "name": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "name": "canPickUp", + "schema": { + "type": "boolean" + } + }, + { + "name": "canPay", + "schema": { + "type": "boolean" + } + }, + { + "name": "canCancel", + "schema": { + "type": "boolean" + } + } + ], + "roles": [ + { + "roleId": "doctor", + "parentRoleId": null, + "label": "Prescribing Doctor", + "attributes": ["*"], + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay", "Cancel"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canPay", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canCancel", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "pharmacy", + "parentRoleId": "doctor", + "label": "Pharmacy", + "cannotDelegateToSameRole": true, + "attributes": ["*"], + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canPay", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "patient", + "parentRoleId": "pharmacy", + "label": "Patient", + "attributes": ["credentialSubject.PickUp", "credentialSubject.Pay", "credentialSubject.canPickUp"], + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp"] + }, + "minItems": 1, + "maxItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "maxItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "guardian", + "parentRoleId": "patient", + "label": "Guardian", + "attributes": ["credentialSubject.PickUp", "credentialSubject.canPickUp"], + "capabilityGrants": [] + } + ] + } +} diff --git a/packages/vc-delegation-engine/tests/fixtures/multiDelegation.js b/packages/vc-delegation-engine/tests/fixtures/multiDelegation.js index 16e328bcb..b8b207423 100644 --- a/packages/vc-delegation-engine/tests/fixtures/multiDelegation.js +++ b/packages/vc-delegation-engine/tests/fixtures/multiDelegation.js @@ -55,9 +55,6 @@ export const multiDelegationPresentation = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['creditScore'], diff --git a/packages/vc-delegation-engine/tests/fixtures/pharmacy.js b/packages/vc-delegation-engine/tests/fixtures/pharmacy.js index 1461c488c..4efed006e 100644 --- a/packages/vc-delegation-engine/tests/fixtures/pharmacy.js +++ b/packages/vc-delegation-engine/tests/fixtures/pharmacy.js @@ -1,3 +1,13 @@ +import pharmacyDelegationPolicyDoc from './delegation-pharmacy-policy.json' with { type: 'json' }; + +export { pharmacyDelegationPolicyDoc }; + +/** SHA-256 hex of json-canonicalize(policy document) */ +export const PHARMACY_DELEGATION_POLICY_DIGEST = + 'e63a871e132696b8a50fb29515ddfc4d88fd01f35cee9df8b230cf472c409e3f'; + +export const PHARMACY_DELEGATION_POLICY_ID = pharmacyDelegationPolicyDoc.id; + export const pharmacyPolicy = ` permit( principal in Credential::Chain::"Action:Verify", @@ -27,9 +37,25 @@ const BASE_DELEGATION_CONTEXT = [ 'https://ld.truvera.io/credentials/delegation', ]; +const VC_DATES = { + issuanceDate: '2026-03-20T12:00:00Z', + expirationDate: '2026-06-10T12:00:00Z', +}; + +const CAPABILITY_CONTEXT = { + '@version': 1.1, + ex: 'https://example.org/credentials#', + allowedClaims: 'ex:allowedClaims', + prescriptionResourceIds: 'ex:prescriptionResourceIds', + canPickUp: { '@id': 'ex:canPickUp', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, + canPay: { '@id': 'ex:canPay', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, + canCancel: { '@id': 'ex:canCancel', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, +}; + export const PRESCRIPTION_CREDENTIAL = { '@context': [ ...BASE_DELEGATION_CONTEXT, + CAPABILITY_CONTEXT, { '@version': 1.1, ex: 'https://example.org/credentials#', @@ -40,14 +66,22 @@ export const PRESCRIPTION_CREDENTIAL = { id: 'urn:cred:pres-001', type: ['VerifiableCredential', 'Prescription', 'DelegationCredential'], issuer: 'did:test:doctor', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, rootCredentialId: 'urn:cred:pres-001', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', + delegationPolicyId: PHARMACY_DELEGATION_POLICY_ID, + delegationPolicyDigest: PHARMACY_DELEGATION_POLICY_DIGEST, + delegationRoleId: 'pharmacy', credentialSubject: { id: 'did:test:pharmacy', 'https://rdf.dock.io/alpha/2021#mayClaim': ['Cancel', 'PickUp', 'Pay'], prescribes: { id: 'urn:rx:789' }, + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: ['urn:rx:789'], + canPickUp: true, + canPay: true, + PickUp: true, + Pay: true, }, }; @@ -69,6 +103,7 @@ export const PRESENTATION_FIELDS = { export const PRESCRIPTION_USAGE_BASE_FIELDS = { '@context': [ ...BASE_DELEGATION_CONTEXT, + CAPABILITY_CONTEXT, { '@version': 1.1, ex: 'https://example.org/credentials#', @@ -91,13 +126,19 @@ export const pharmacyPresentations = { id: 'urn:cred:pp-001', type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], issuer: 'did:test:pharmacy', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, previousCredentialId: 'urn:cred:pres-001', rootCredentialId: 'urn:cred:pres-001', + delegationRoleId: 'patient', credentialSubject: { id: 'did:test:patient', 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], PickUp: true, Pay: true, + allowedClaims: ['PickUp'], + prescriptionResourceIds: ['urn:rx:789'], + canPickUp: true, }, }, { @@ -105,11 +146,14 @@ export const pharmacyPresentations = { id: 'urn:cred:pg-001', type: ['VerifiableCredential', 'PrescriptionUsage'], issuer: 'did:test:patient', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, previousCredentialId: 'urn:cred:pp-001', rootCredentialId: 'urn:cred:pres-001', credentialSubject: { id: 'did:test:guardian', PickUp: true, + canPickUp: true, }, }, ], @@ -124,13 +168,19 @@ export const pharmacyPresentations = { id: 'urn:cred:pp-001', type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], issuer: 'did:test:pharmacy', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, previousCredentialId: 'urn:cred:pres-001', rootCredentialId: 'urn:cred:pres-001', + delegationRoleId: 'patient', credentialSubject: { 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], id: 'did:test:patient', PickUp: true, Pay: true, + allowedClaims: ['PickUp'], + prescriptionResourceIds: ['urn:rx:789'], + canPickUp: true, }, }, ], @@ -145,13 +195,19 @@ export const pharmacyPresentations = { id: 'urn:cred:pp-001', type: ['VerifiableCredential', 'PrescriptionUsage', 'DelegationCredential'], issuer: 'did:test:pharmacy', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, previousCredentialId: 'urn:cred:pres-001', rootCredentialId: 'urn:cred:pres-001', + delegationRoleId: 'patient', credentialSubject: { id: 'did:test:patient', 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], PickUp: true, Pay: true, + allowedClaims: ['PickUp'], + prescriptionResourceIds: ['urn:rx:789'], + canPickUp: true, }, }, { @@ -159,12 +215,15 @@ export const pharmacyPresentations = { id: 'urn:cred:pg-001', type: ['VerifiableCredential', 'PrescriptionUsage'], issuer: 'did:test:patient', + issuanceDate: VC_DATES.issuanceDate, + expirationDate: VC_DATES.expirationDate, previousCredentialId: 'urn:cred:pp-001', rootCredentialId: 'urn:cred:pres-001', credentialSubject: { id: 'did:test:guardian', PickUp: false, Pay: true, + canPickUp: false, }, }, ], diff --git a/packages/vc-delegation-engine/tests/fixtures/policy-integration-pharmacy.json b/packages/vc-delegation-engine/tests/fixtures/policy-integration-pharmacy.json new file mode 100644 index 000000000..a5b94f5e7 --- /dev/null +++ b/packages/vc-delegation-engine/tests/fixtures/policy-integration-pharmacy.json @@ -0,0 +1,203 @@ +{ + "type": "DelegationPolicy", + "version": "1.0", + "createdAt": "2026-03-18", + "id": "urn:test:delegation-policy:pharmacy-example", + "ruleset": { + "rulesetVersion": "2026-03-pharmacy-v1", + "delegationTarget": "single-credential", + "overallConstraints": { + "maxDelegationDepth": 3 + }, + "capabilities": [ + { + "name": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay", "Cancel"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "name": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "name": "canPickUp", + "schema": { + "type": "boolean" + } + }, + { + "name": "canPay", + "schema": { + "type": "boolean" + } + }, + { + "name": "canCancel", + "schema": { + "type": "boolean" + } + } + ], + "roles": [ + { + "roleId": "doctor", + "parentRoleId": null, + "label": "Prescribing Doctor", + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay", "Cancel"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canPay", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canCancel", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "pharmacy", + "parentRoleId": "doctor", + "label": "Pharmacy", + "cannotDelegateToSameRole": true, + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp", "Pay"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "canPay", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "patient", + "parentRoleId": "pharmacy", + "label": "Patient", + "capabilityGrants": [ + { + "capability": "allowedClaims", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["PickUp"] + }, + "minItems": 1, + "maxItems": 1, + "uniqueItems": true + } + }, + { + "capability": "prescriptionResourceIds", + "schema": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:rx:[A-Za-z0-9._:-]+$" + }, + "minItems": 1, + "maxItems": 1, + "uniqueItems": true + } + }, + { + "capability": "canPickUp", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "guardian", + "parentRoleId": "patient", + "label": "Guardian", + "capabilityGrants": [] + } + ] + } +} diff --git a/packages/vc-delegation-engine/tests/fixtures/policy-integration-travel.json b/packages/vc-delegation-engine/tests/fixtures/policy-integration-travel.json new file mode 100644 index 000000000..56d9934ff --- /dev/null +++ b/packages/vc-delegation-engine/tests/fixtures/policy-integration-travel.json @@ -0,0 +1,155 @@ +{ + "type": "DelegationPolicy", + "version": "1.0", + "id": "urn:test:delegation-policy:travel-example", + "ruleset": { + "createdAt": "2026-03-17", + "delegationTarget": "single-credential", + "overallConstraints": { + "maxDelegationDepth": 2 + }, + "capabilities": [ + { + "name": "allowedRoutes", + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "name": "purchaseLimit", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "reserveFlights", + "schema": { + "type": "boolean" + } + }, + { + "name": "reserveHotels", + "schema": { + "type": "boolean" + } + } + ], + "roles": [ + { + "roleId": "travel-agency", + "parentRoleId": null, + "label": "Travel Agency", + "capabilityGrants": [ + { + "capability": "allowedRoutes", + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "purchaseLimit", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + } + }, + { + "capability": "reserveFlights", + "schema": { + "type": "boolean", + "const": true + } + }, + { + "capability": "reserveHotels", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "regional-manager", + "parentRoleId": "travel-agency", + "label": "Regional Manager", + "capabilityGrants": [ + { + "capability": "allowedRoutes", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["US-NYC-LAX", "US-SFO-SEA", "US-ORD-MIA"] + }, + "minItems": 1, + "uniqueItems": true + } + }, + { + "capability": "purchaseLimit", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 5000 + } + }, + { + "capability": "reserveFlights", + "schema": { + "type": "boolean", + "const": true + } + } + ] + }, + { + "roleId": "travel-agent", + "parentRoleId": "regional-manager", + "label": "Travel Agent", + "capabilityGrants": [ + { + "capability": "allowedRoutes", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["US-NYC-LAX", "US-SFO-SEA"] + }, + "minItems": 1, + "uniqueItems": true, + "maxItems": 2 + } + }, + { + "capability": "purchaseLimit", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + } + }, + { + "capability": "reserveFlights", + "schema": { + "type": "boolean", + "const": true + } + } + ] + } + ] + } +} diff --git a/packages/vc-delegation-engine/tests/fixtures/policyIntegrationExamples.js b/packages/vc-delegation-engine/tests/fixtures/policyIntegrationExamples.js new file mode 100644 index 000000000..fb9634c21 --- /dev/null +++ b/packages/vc-delegation-engine/tests/fixtures/policyIntegrationExamples.js @@ -0,0 +1,270 @@ +import baseDocumentLoader from '../../examples/document-loader.js'; +import pharmacyIntegrationPolicy from './policy-integration-pharmacy.json' with { type: 'json' }; +import travelIntegrationPolicy from './policy-integration-travel.json' with { type: 'json' }; +import { computePolicyDigestHex } from '../../src/delegation-policy-digest.js'; + +export { pharmacyIntegrationPolicy, travelIntegrationPolicy }; + +export const PHARMACY_INTEGRATION_DIGEST = computePolicyDigestHex(pharmacyIntegrationPolicy); +export const TRAVEL_INTEGRATION_DIGEST = computePolicyDigestHex(travelIntegrationPolicy); + +const BASE_DELEGATION_CONTEXT = [ + 'https://www.w3.org/2018/credentials/v1', + 'https://ld.truvera.io/credentials/delegation', +]; + +const PHARMACY_EX_CONTEXT = { + '@version': 1.1, + ex: 'https://example.org/credentials#', + allowedClaims: 'ex:allowedClaims', + prescriptionResourceIds: 'ex:prescriptionResourceIds', + canPickUp: { '@id': 'ex:canPickUp', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, + canPay: { '@id': 'ex:canPay', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, + canCancel: { '@id': 'ex:canCancel', '@type': 'http://www.w3.org/2001/XMLSchema#boolean' }, + PharmacyIntegrationRoot: 'ex:PharmacyIntegrationRoot', + PharmacyIntegrationLeaf: 'ex:PharmacyIntegrationLeaf', +}; + +export const PHARMACY_INTEGRATION_CONTEXT = [ + ...BASE_DELEGATION_CONTEXT, + PHARMACY_EX_CONTEXT, +]; + +const TRAVEL_EX_CONTEXT = { + '@version': 1.1, + tx: 'https://example.org/travel#', + xsd: 'http://www.w3.org/2001/XMLSchema#', + allowedRoutes: 'tx:allowedRoutes', + purchaseLimit: { '@id': 'tx:purchaseLimit', '@type': 'xsd:integer' }, + reserveFlights: { '@id': 'tx:reserveFlights', '@type': 'xsd:boolean' }, + reserveHotels: { '@id': 'tx:reserveHotels', '@type': 'xsd:boolean' }, + TravelAgencyCredential: 'tx:TravelAgencyCredential', + TravelRegionalCredential: 'tx:TravelRegionalCredential', + TravelAgentCredential: 'tx:TravelAgentCredential', +}; + +export const TRAVEL_INTEGRATION_CONTEXT = [ + ...BASE_DELEGATION_CONTEXT, + TRAVEL_EX_CONTEXT, +]; + +export const PHARMACY_INTEGRATION_DATES_OK = { + issuanceDate: '2026-03-20T12:00:00Z', + expirationDate: '2026-06-10T12:00:00Z', +}; + +export const PHARMACY_INTEGRATION_DATES_CHILD_AFTER_PARENT = { + issuanceDate: '2026-03-20T12:00:00Z', + expirationDate: '2026-06-15T12:00:00Z', +}; + +export const TRAVEL_INTEGRATION_DATES_OK = { + issuanceDate: '2026-01-10T00:00:00Z', + expirationDate: '2027-01-05T00:00:00Z', +}; + +export const PRESENTATION_SHELL = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + id: 'urn:pres:policy-integration-example', + type: ['VerifiablePresentation'], + proof: { + type: 'Ed25519Signature2018', + created: '2026-01-10T12:00:00Z', + verificationMethod: 'did:test:holder#key', + proofPurpose: 'authentication', + challenge: 'policy-integration', + domain: 'test', + jws: 'test..test', + }, +}; + +/** + * @param {object} options + * @param {string} options.id + * @param {string} options.issuer + * @param {string} options.roleId + * @param {object} options.credentialSubject + * @param {string} [options.previousCredentialId] + * @param {string} [options.rootCredentialId] + * @param {string[]} [options.type] + * @param {object} [options.dateFields] + * @param {string} [options.delegationPolicyId] + * @param {string} [options.delegationPolicyDigest] + */ +export function buildPharmacyIntegrationDelegationVc(options) { + const { + id, + issuer, + roleId, + credentialSubject, + previousCredentialId, + rootCredentialId, + type = ['VerifiableCredential', 'PharmacyIntegrationRoot', 'DelegationCredential'], + dateFields = PHARMACY_INTEGRATION_DATES_OK, + delegationPolicyId = pharmacyIntegrationPolicy.id, + delegationPolicyDigest = PHARMACY_INTEGRATION_DIGEST, + } = options; + return { + '@context': PHARMACY_INTEGRATION_CONTEXT, + id, + type, + issuer, + ...dateFields, + rootCredentialId: rootCredentialId ?? id, + previousCredentialId, + delegationPolicyId, + delegationPolicyDigest, + delegationRoleId: roleId, + credentialSubject, + }; +} + +/** + * @param {object} options + * @param {string} options.id + * @param {string} options.issuer + * @param {object} options.credentialSubject + * @param {string} options.previousCredentialId + * @param {string} options.rootCredentialId + * @param {object} [options.dateFields] + */ +export function buildPharmacyIntegrationLeafVc(options) { + const { + id, + issuer, + credentialSubject, + previousCredentialId, + rootCredentialId, + dateFields = PHARMACY_INTEGRATION_DATES_OK, + } = options; + return { + '@context': PHARMACY_INTEGRATION_CONTEXT, + id, + type: ['VerifiableCredential', 'PharmacyIntegrationLeaf'], + issuer, + ...dateFields, + rootCredentialId, + previousCredentialId, + credentialSubject, + }; +} + +/** + * @param {object} options + * @param {string} options.id + * @param {string} options.issuer + * @param {string} options.roleId + * @param {object} options.credentialSubject + * @param {string} [options.previousCredentialId] + * @param {string} [options.rootCredentialId] + * @param {string[]} [options.type] + * @param {object} [options.dateFields] + */ +export function buildTravelIntegrationDelegationVc(options) { + const { + id, + issuer, + roleId, + credentialSubject, + previousCredentialId, + rootCredentialId, + type = ['VerifiableCredential', 'TravelAgencyCredential', 'DelegationCredential'], + dateFields = TRAVEL_INTEGRATION_DATES_OK, + delegationPolicyId = travelIntegrationPolicy.id, + delegationPolicyDigest = TRAVEL_INTEGRATION_DIGEST, + } = options; + return { + '@context': TRAVEL_INTEGRATION_CONTEXT, + id, + type, + issuer, + ...dateFields, + rootCredentialId: rootCredentialId ?? id, + previousCredentialId, + delegationPolicyId, + delegationPolicyDigest, + delegationRoleId: roleId, + credentialSubject, + }; +} + +export function buildPolicyIntegrationPresentation(verifiableCredential) { + return { + ...PRESENTATION_SHELL, + verifiableCredential, + }; +} + +export function createPolicyIntegrationDocumentLoader() { + const byId = { + [pharmacyIntegrationPolicy.id]: pharmacyIntegrationPolicy, + [travelIntegrationPolicy.id]: travelIntegrationPolicy, + }; + return async (url) => { + const hit = byId[url]; + if (hit) { + return { + contextUrl: null, + documentUrl: url, + document: structuredClone(hit), + }; + } + return baseDocumentLoader(url); + }; +} + +export const pharmacySubjectDoctor = { + id: 'did:test:doctor', + 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay', 'Cancel'], + allowedClaims: ['PickUp', 'Pay', 'Cancel'], + prescriptionResourceIds: ['urn:rx:101'], + canPickUp: true, + canPay: true, + canCancel: true, +}; + +export const pharmacySubjectPharmacy = { + id: 'did:test:pharmacy', + 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp', 'Pay'], + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: ['urn:rx:101'], + canPickUp: true, + canPay: true, +}; + +export const pharmacySubjectPatient = { + id: 'did:test:patient', + 'https://rdf.dock.io/alpha/2021#mayClaim': ['PickUp'], + allowedClaims: ['PickUp'], + prescriptionResourceIds: ['urn:rx:101'], + canPickUp: true, +}; + +export const pharmacySubjectLeaf = { + id: 'did:test:leaf', + allowedClaims: ['PickUp'], + prescriptionResourceIds: ['urn:rx:101'], + canPickUp: true, +}; + +export const travelSubjectAgency = { + id: 'did:test:travel-agency', + allowedRoutes: ['US-NYC-LAX', 'US-SFO-SEA', 'US-ORD-MIA'], + purchaseLimit: 10000, + reserveFlights: true, + reserveHotels: true, +}; + +export const travelSubjectRegional = { + id: 'did:test:regional', + allowedRoutes: ['US-NYC-LAX', 'US-SFO-SEA', 'US-ORD-MIA'], + purchaseLimit: 5000, + reserveFlights: true, +}; + +export const travelSubjectAgent = { + id: 'did:test:agent', + allowedRoutes: ['US-NYC-LAX', 'US-SFO-SEA'], + purchaseLimit: 1000, + reserveFlights: true, +}; diff --git a/packages/vc-delegation-engine/tests/fixtures/simpleDelegation.js b/packages/vc-delegation-engine/tests/fixtures/simpleDelegation.js index e4e45db5d..f2ce501c8 100644 --- a/packages/vc-delegation-engine/tests/fixtures/simpleDelegation.js +++ b/packages/vc-delegation-engine/tests/fixtures/simpleDelegation.js @@ -46,9 +46,6 @@ const authorizedDelegation = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['creditScore'], @@ -76,9 +73,6 @@ const unauthorizedDelegation = { issuer: 'did:dock:a', previousCredentialId: null, rootCredentialId: 'urn:cred:deleg-a-b', - delegationPolicyId: 'urn:uuid:4f4f0f7b-4c55-4c88-bc44-43f2e7eb2f10', - delegationPolicyDigest: - '3f2d2d6f2d7b6e0e9b0cfd5b6ac1e8f5f31d2d41e8d39d6b8d36b1d4c3a8d72a', credentialSubject: { id: 'did:dock:b', 'https://rdf.dock.io/alpha/2021#mayClaim': ['noClaim'], diff --git a/packages/vc-delegation-engine/tests/integration/policy-examples.integration.test.js b/packages/vc-delegation-engine/tests/integration/policy-examples.integration.test.js new file mode 100644 index 000000000..e1253cba2 --- /dev/null +++ b/packages/vc-delegation-engine/tests/integration/policy-examples.integration.test.js @@ -0,0 +1,661 @@ +import { + beforeAll, + describe, + expect, + it, +} from 'vitest'; +import jsonld from 'jsonld'; + +import { verifyVPWithDelegation } from '../../src/index.js'; +import { DelegationErrorCodes } from '../../src/errors.js'; +import { computePolicyDigestHex } from '../../src/delegation-policy-digest.js'; +import { validateDelegationPolicy } from '../../src/delegation-policy-validate.js'; +import { + pharmacyIntegrationPolicy, + travelIntegrationPolicy, + PHARMACY_INTEGRATION_DIGEST, + TRAVEL_INTEGRATION_DIGEST, + createPolicyIntegrationDocumentLoader, + buildPolicyIntegrationPresentation, + buildPharmacyIntegrationDelegationVc, + buildPharmacyIntegrationLeafVc, + buildTravelIntegrationDelegationVc, + PHARMACY_INTEGRATION_DATES_OK, + PHARMACY_INTEGRATION_DATES_CHILD_AFTER_PARENT, + pharmacySubjectDoctor, + pharmacySubjectPharmacy, + pharmacySubjectPatient, + pharmacySubjectLeaf, + travelSubjectAgency, + travelSubjectRegional, + travelSubjectAgent, +} from '../fixtures/policyIntegrationExamples.js'; + +const ISSUER_DOCTOR = 'did:test:doctor'; +const ISSUER_PHARMACY = 'did:test:pharmacy'; +const ISSUER_PATIENT = 'did:test:patient'; +const ISSUER_CORP = 'did:test:corp'; +const ISSUER_TRAVEL_AGENCY_ENTITY = 'did:test:travel-agency'; +const ISSUER_REGIONAL = 'did:test:regional'; +const ISSUER_AGENT = 'did:test:agent'; + +const TRAVEL_VC_AGENCY = ['VerifiableCredential', 'TravelAgencyCredential', 'DelegationCredential']; +const TRAVEL_VC_REGIONAL = ['VerifiableCredential', 'TravelRegionalCredential', 'DelegationCredential']; +const TRAVEL_VC_AGENT = ['VerifiableCredential', 'TravelAgentCredential', 'DelegationCredential']; + +const ROLE_TRAVEL_AGENCY = 'travel-agency'; +const ROLE_REGIONAL_MANAGER = 'regional-manager'; +const ROLE_TRAVEL_AGENT = 'travel-agent'; +const ROUTE_NYC_LAX = 'US-NYC-LAX'; +const ROUTE_SFO_SEA = 'US-SFO-SEA'; +const ROUTES_PARENT_ONE = [ROUTE_NYC_LAX]; +const ROUTES_CHILD_PAIR = [ROUTE_NYC_LAX, ROUTE_SFO_SEA]; + +async function verifyIntegrationPresentation(presentation, documentLoader) { + const expandedPresentation = await jsonld.expand(JSON.parse(JSON.stringify(presentation)), { documentLoader }); + const credentialContexts = new Map(); + (presentation.verifiableCredential ?? []).forEach((vc) => { + if (vc && typeof vc.id === 'string' && vc['@context']) { + credentialContexts.set(vc.id, vc['@context']); + } + }); + return verifyVPWithDelegation({ + expandedPresentation, + credentialContexts, + documentLoader, + }); +} + +function shallowPharmacyPolicyDepth0() { + const shallow = structuredClone(pharmacyIntegrationPolicy); + shallow.id = 'urn:test:delegation-policy:pharmacy-shallow-depth'; + shallow.ruleset.overallConstraints.maxDelegationDepth = 0; + return shallow; +} + +describe('policy example integration (pharmacy ruleset)', () => { + let documentLoader; + + beforeAll(() => { + validateDelegationPolicy(pharmacyIntegrationPolicy); + documentLoader = createPolicyIntegrationDocumentLoader(); + }); + + it('accepts a single delegation credential (pharmacy role)', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:single', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const presentation = buildPolicyIntegrationPresentation([root]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('allow'); + expect(result.failures).toHaveLength(0); + }); + + it('accepts pharmacy → patient → non-delegation leaf', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + }); + const leaf = buildPharmacyIntegrationLeafVc({ + id: 'urn:pharm:int:leaf', + issuer: ISSUER_PATIENT, + previousCredentialId: patient.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectLeaf, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient, leaf]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('allow'); + }); + + it('policy chain allows doctor → patient (role graph skip); verified via resolveAndVerifyDelegationPolicy', async () => { + const { resolveAndVerifyDelegationPolicy } = await import('../../src/delegation-policy-chain.js'); + const chain = [ + { + id: 'urn:pharm:int:doc-root', + type: ['DelegationCredential'], + delegationPolicyId: pharmacyIntegrationPolicy.id, + delegationPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + delegationRoleId: 'doctor', + issuanceDate: PHARMACY_INTEGRATION_DATES_OK.issuanceDate, + expirationDate: PHARMACY_INTEGRATION_DATES_OK.expirationDate, + credentialSubject: pharmacySubjectDoctor, + }, + { + id: 'urn:pharm:int:pat-direct', + type: ['DelegationCredential'], + delegationPolicyId: pharmacyIntegrationPolicy.id, + delegationPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + delegationRoleId: 'patient', + issuanceDate: PHARMACY_INTEGRATION_DATES_OK.issuanceDate, + expirationDate: PHARMACY_INTEGRATION_DATES_OK.expirationDate, + credentialSubject: pharmacySubjectPatient, + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyIntegrationPolicy.id, + rootPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + documentLoader: async () => ({ document: structuredClone(pharmacyIntegrationPolicy) }), + }), + ).resolves.toBeDefined(); + }); + + it('accepts guardian delegation credential with empty capability subject', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:g-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:g-patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + }); + const guardian = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:g-guardian', + issuer: ISSUER_PATIENT, + roleId: 'guardian', + previousCredentialId: patient.id, + rootCredentialId: root.id, + credentialSubject: { id: 'did:test:guardian' }, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient, guardian]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('allow'); + }); + + it('rejects delegationPolicyDigest mismatch', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:bad-digest', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + delegationPolicyDigest: '0'.repeat(64), + }); + const presentation = buildPolicyIntegrationPresentation([root]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_DIGEST_MISMATCH)).toBe(true); + }); + + it('rejects child credential expiring after parent', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:exp-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + dateFields: PHARMACY_INTEGRATION_DATES_OK, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:exp-patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + dateFields: PHARMACY_INTEGRATION_DATES_CHILD_AFTER_PARENT, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_LIFETIME_INVALID)).toBe(true); + }); + + it('rejects subject field not allowed by role (capability-only disclosure)', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:extra-field', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: { + ...pharmacySubjectPharmacy, + internalNote: 'must-not-appear', + }, + }); + const presentation = buildPolicyIntegrationPresentation([root]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ROLE_INVALID)).toBe(true); + }); + + it('rejects patient allowedClaims broader than role grant schema', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:br-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:br-patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: { + ...pharmacySubjectPatient, + allowedClaims: ['PickUp', 'Pay'], + }, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_CAPABILITY_INVALID)).toBe(true); + }); + + it('rejects guardian role credential that discloses a capability field', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:gr-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:gr-patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + }); + const guardian = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:gr-bad', + issuer: ISSUER_PATIENT, + roleId: 'guardian', + previousCredentialId: patient.id, + rootCredentialId: root.id, + credentialSubject: { + id: 'did:test:guardian', + canPickUp: true, + }, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient, guardian]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ROLE_INVALID)).toBe(true); + }); + + it('rejects inverted role ancestry (patient then pharmacy)', async () => { + const patientFirst = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:inv-p', + issuer: ISSUER_DOCTOR, + roleId: 'patient', + credentialSubject: pharmacySubjectPatient, + }); + const pharmacySecond = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:inv-ph', + issuer: ISSUER_PHARMACY, + roleId: 'pharmacy', + previousCredentialId: patientFirst.id, + rootCredentialId: patientFirst.id, + credentialSubject: pharmacySubjectPatient, + }); + const presentation = buildPolicyIntegrationPresentation([patientFirst, pharmacySecond]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ROLE_INVALID)).toBe(true); + }); + + it('rejects leaf allowedClaims broader than patient role (capability validation before monotonicity)', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:mono-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:mono-p', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + }); + const leaf = buildPharmacyIntegrationLeafVc({ + id: 'urn:pharm:int:mono-leaf', + issuer: ISSUER_PATIENT, + previousCredentialId: patient.id, + rootCredentialId: root.id, + credentialSubject: { + ...pharmacySubjectLeaf, + allowedClaims: ['PickUp', 'Pay'], + }, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient, leaf]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_CAPABILITY_INVALID)).toBe(true); + }); + + it('rejects chain when maxDelegationDepth is exceeded', async () => { + const shallow = shallowPharmacyPolicyDepth0(); + const shallowDigest = computePolicyDigestHex(shallow); + const loader = async (url) => { + if (url === shallow.id) { + return { contextUrl: null, documentUrl: url, document: structuredClone(shallow) }; + } + return documentLoader(url); + }; + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:shallow-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + delegationPolicyId: shallow.id, + delegationPolicyDigest: shallowDigest, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:shallow-p', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + delegationPolicyId: shallow.id, + delegationPolicyDigest: shallowDigest, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient]); + const result = await verifyIntegrationPresentation(presentation, loader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_DEPTH_EXCEEDED)).toBe(true); + }); + + it('rejects child delegationPolicyId that does not match root', async () => { + const root = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:id-root', + issuer: ISSUER_DOCTOR, + roleId: 'pharmacy', + credentialSubject: pharmacySubjectPharmacy, + }); + const patient = buildPharmacyIntegrationDelegationVc({ + id: 'urn:pharm:int:id-patient', + issuer: ISSUER_PHARMACY, + roleId: 'patient', + previousCredentialId: root.id, + rootCredentialId: root.id, + credentialSubject: pharmacySubjectPatient, + delegationPolicyId: 'urn:test:delegation-policy:other', + delegationPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + }); + const presentation = buildPolicyIntegrationPresentation([root, patient]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ID_MISMATCH)).toBe(true); + }); + + it('stable digest for bundled pharmacy example policy', () => { + expect(PHARMACY_INTEGRATION_DIGEST).toBe( + 'f9be6bd54c970faf8068517f53323ffb9f3237942b6b4720ffe2ee73b062db73', + ); + }); +}); + +describe('policy example integration (travel ruleset)', () => { + let documentLoader; + + beforeAll(() => { + validateDelegationPolicy(travelIntegrationPolicy); + documentLoader = createPolicyIntegrationDocumentLoader(); + }); + + it('accepts travel-agency → regional-manager → travel-agent chain', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:agency', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const regional = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:regional', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_REGIONAL_MANAGER, + type: TRAVEL_VC_REGIONAL, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: travelSubjectRegional, + }); + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:agent', + issuer: ISSUER_REGIONAL, + roleId: ROLE_TRAVEL_AGENT, + type: TRAVEL_VC_AGENT, + previousCredentialId: regional.id, + rootCredentialId: agency.id, + credentialSubject: travelSubjectAgent, + }); + const presentation = buildPolicyIntegrationPresentation([agency, regional, agent]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('allow'); + }); + + it('rejects purchaseLimit greater than parent credential (numeric monotonicity)', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:pl-a', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const regional = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:pl-r', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_REGIONAL_MANAGER, + type: TRAVEL_VC_REGIONAL, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectRegional, + purchaseLimit: 800, + }, + }); + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:pl-g', + issuer: ISSUER_REGIONAL, + roleId: ROLE_TRAVEL_AGENT, + type: TRAVEL_VC_AGENT, + previousCredentialId: regional.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectAgent, + purchaseLimit: 900, + }, + }); + const presentation = buildPolicyIntegrationPresentation([agency, regional, agent]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION)).toBe(true); + }); + + it('rejects allowedRoutes not a subset of parent', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rt-a', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const regional = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rt-r', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_REGIONAL_MANAGER, + type: TRAVEL_VC_REGIONAL, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectRegional, + allowedRoutes: ROUTES_PARENT_ONE, + }, + }); + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rt-g', + issuer: ISSUER_REGIONAL, + roleId: ROLE_TRAVEL_AGENT, + type: TRAVEL_VC_AGENT, + previousCredentialId: regional.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectAgent, + allowedRoutes: ROUTES_CHILD_PAIR, + }, + }); + const presentation = buildPolicyIntegrationPresentation([agency, regional, agent]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_MONOTONIC_VIOLATION)).toBe(true); + }); + + it('rejects travel-agent disclosing reserveHotels (not granted to travel-agent role)', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rh-a', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const regional = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rh-r', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_REGIONAL_MANAGER, + type: TRAVEL_VC_REGIONAL, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: travelSubjectRegional, + }); + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:rh-g', + issuer: ISSUER_REGIONAL, + roleId: ROLE_TRAVEL_AGENT, + type: TRAVEL_VC_AGENT, + previousCredentialId: regional.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectAgent, + reserveHotels: true, + }, + }); + const presentation = buildPolicyIntegrationPresentation([agency, regional, agent]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ROLE_INVALID)).toBe(true); + }); + + it('rejects regional-manager allowedRoutes enum violation vs grant schema', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:en-a', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const regional = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:en-r', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_REGIONAL_MANAGER, + type: TRAVEL_VC_REGIONAL, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: { + ...travelSubjectRegional, + allowedRoutes: [ROUTE_NYC_LAX, 'EU-PAR-LON'], + }, + }); + const presentation = buildPolicyIntegrationPresentation([agency, regional]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_CAPABILITY_INVALID)).toBe(true); + }); + + it('rejects travel-agent → travel-agency (role order inconsistent with policy graph)', async () => { + const sharedNarrowSubject = { + id: 'did:test:shared', + allowedRoutes: ROUTES_PARENT_ONE, + purchaseLimit: 500, + reserveFlights: true, + }; + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:inv-agent', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENT, + credentialSubject: sharedNarrowSubject, + }); + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:inv-agency', + issuer: ISSUER_AGENT, + roleId: ROLE_TRAVEL_AGENCY, + type: TRAVEL_VC_AGENCY, + previousCredentialId: agent.id, + rootCredentialId: agent.id, + credentialSubject: sharedNarrowSubject, + }); + const presentation = buildPolicyIntegrationPresentation([agent, agency]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('deny'); + expect(result.failures.some((f) => f.code === DelegationErrorCodes.POLICY_ROLE_INVALID)).toBe(true); + }); + + it('allows travel-agency → travel-agent when regional-manager credential is omitted (graph descendant)', async () => { + const agency = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:skip-a', + issuer: ISSUER_CORP, + roleId: ROLE_TRAVEL_AGENCY, + credentialSubject: travelSubjectAgency, + }); + const agent = buildTravelIntegrationDelegationVc({ + id: 'urn:travel:int:skip-g', + issuer: ISSUER_TRAVEL_AGENCY_ENTITY, + roleId: ROLE_TRAVEL_AGENT, + type: TRAVEL_VC_AGENT, + previousCredentialId: agency.id, + rootCredentialId: agency.id, + credentialSubject: travelSubjectAgent, + }); + const presentation = buildPolicyIntegrationPresentation([agency, agent]); + const result = await verifyIntegrationPresentation(presentation, documentLoader); + expect(result.decision).toBe('allow'); + }); + + it('stable digest for bundled travel example policy', () => { + expect(TRAVEL_INTEGRATION_DIGEST).toBe( + 'a8539feda57be9b15a1c0545f6ade5de86632d9e8a8095692be9dbb5f996342c', + ); + }); +}); + +describe('policy example integration (documentLoader)', () => { + it('resolveAndVerifyDelegationPolicy requires documentLoader (VP path may fail earlier on remote @context without a loader)', async () => { + const { resolveAndVerifyDelegationPolicy } = await import('../../src/delegation-policy-chain.js'); + const chain = [ + { + id: 'urn:pharm:int:no-loader', + type: ['DelegationCredential'], + delegationPolicyId: pharmacyIntegrationPolicy.id, + delegationPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + delegationRoleId: 'pharmacy', + issuanceDate: PHARMACY_INTEGRATION_DATES_OK.issuanceDate, + expirationDate: PHARMACY_INTEGRATION_DATES_OK.expirationDate, + credentialSubject: pharmacySubjectPharmacy, + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyIntegrationPolicy.id, + rootPolicyDigest: PHARMACY_INTEGRATION_DIGEST, + documentLoader: undefined, + }), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_DOCUMENT_LOADER_REQUIRED }); + }); +}); diff --git a/packages/vc-delegation-engine/tests/unit/constants.test.js b/packages/vc-delegation-engine/tests/unit/constants.test.js index a283ed3b9..ce7dfd613 100644 --- a/packages/vc-delegation-engine/tests/unit/constants.test.js +++ b/packages/vc-delegation-engine/tests/unit/constants.test.js @@ -11,6 +11,11 @@ import { VC_TYPE_DELEGATION_CREDENTIAL, VC_PREVIOUS_CREDENTIAL_ID, VC_ROOT_CREDENTIAL_ID, + VC_DELEGATION_POLICY_ID, + VC_DELEGATION_POLICY_DIGEST, + VC_DELEGATION_ROLE_ID, + VC_ISSUANCE_DATE, + VC_EXPIRATION_DATE, ACTION_VERIFY, VERIFY_CHAIN_ID, UNKNOWN_IDENTIFIER, @@ -36,6 +41,11 @@ describe('constants', () => { expect(VC_TYPE_DELEGATION_CREDENTIAL).toBe(`${DELEGATION_NS}DelegationCredential`); expect(VC_PREVIOUS_CREDENTIAL_ID).toBe(`${DELEGATION_NS}previousCredentialId`); expect(VC_ROOT_CREDENTIAL_ID).toBe(`${DELEGATION_NS}rootCredentialId`); + expect(VC_DELEGATION_POLICY_ID).toBe(`${DELEGATION_NS}delegationPolicyId`); + expect(VC_DELEGATION_POLICY_DIGEST).toBe(`${DELEGATION_NS}delegationPolicyDigest`); + expect(VC_DELEGATION_ROLE_ID).toBe(`${DELEGATION_NS}delegationRoleId`); + expect(VC_ISSUANCE_DATE).toBe(`${VC_NS}issuanceDate`); + expect(VC_EXPIRATION_DATE).toBe(`${VC_NS}expirationDate`); }); it('defines shared action values', () => { diff --git a/packages/vc-delegation-engine/tests/unit/delegation-policy.test.js b/packages/vc-delegation-engine/tests/unit/delegation-policy.test.js new file mode 100644 index 000000000..8fb4be185 --- /dev/null +++ b/packages/vc-delegation-engine/tests/unit/delegation-policy.test.js @@ -0,0 +1,410 @@ +import { describe, it, expect } from 'vitest'; + +import pharmacyPolicy from '../fixtures/delegation-pharmacy-policy.json' with { type: 'json' }; +import { computePolicyDigestHex, verifyPolicyDigest } from '../../src/delegation-policy-digest.js'; +import { + attributesAreNarrowerOrEqual, + assertGrantSchemaNarrowing, + validateDelegationPolicy, +} from '../../src/delegation-policy-validate.js'; +import { + assertAdjacentCredentialsMonotonic, + assertMaxDelegationDepth, + durationToMilliseconds, + fetchDelegationPolicyJson, + isRoleAncestorOrEqual, + isRoleStrictSubRole, + resolveAndVerifyDelegationPolicy, +} from '../../src/delegation-policy-chain.js'; +import { DelegationError, DelegationErrorCodes } from '../../src/errors.js'; + +const mockChainIssuanceDate = '2026-03-20T12:00:00Z'; +const mockChainRootExpirationDate = '2026-06-10T12:00:00Z'; +const mockPrescriptionResourceId = 'urn:rx:789'; + +describe('delegation policy digest', () => { + it('computes stable SHA-256 for pharmacy fixture', () => { + expect(computePolicyDigestHex(pharmacyPolicy)).toBe( + 'e63a871e132696b8a50fb29515ddfc4d88fd01f35cee9df8b230cf472c409e3f', + ); + }); + + it('verifyPolicyDigest returns false on tampered policy', () => { + const tampered = { ...pharmacyPolicy, version: '9.9' }; + expect(verifyPolicyDigest(tampered, computePolicyDigestHex(pharmacyPolicy))).toBe(false); + }); +}); + +describe('delegation policy validation', () => { + it('accepts pharmacy policy document', () => { + expect(() => validateDelegationPolicy(pharmacyPolicy)).not.toThrow(); + }); + + it('rejects policy with non-array ruleset fields', () => { + const bad = { ruleset: {} }; + expect(() => validateDelegationPolicy(bad)).toThrow(DelegationError); + expect(() => validateDelegationPolicy(bad)).toThrow(/ruleset\.roles must be an array/); + }); + + it('rejects policy with unknown capability on role', () => { + const bad = structuredClone(pharmacyPolicy); + bad.ruleset.roles[1].capabilityGrants.push({ capability: 'nope', schema: { type: 'string' } }); + expect(() => validateDelegationPolicy(bad)).toThrow(DelegationError); + }); + + it('rejects duplicate capability grant names on one role', () => { + const bad = structuredClone(pharmacyPolicy); + const pharmacyRole = bad.ruleset.roles.find((r) => r.roleId === 'pharmacy'); + pharmacyRole.capabilityGrants.push({ ...pharmacyRole.capabilityGrants[0] }); + expect(() => validateDelegationPolicy(bad)).toThrow(DelegationError); + expect(() => validateDelegationPolicy(bad)).toThrow(/unique capability names/); + }); + + it('rejects cannotDelegateToSameRole when role has no sub-role in the policy', () => { + const bad = structuredClone(pharmacyPolicy); + const guardian = bad.ruleset.roles.find((r) => r.roleId === 'guardian'); + guardian.cannotDelegateToSameRole = true; + expect(() => validateDelegationPolicy(bad)).toThrow(DelegationError); + expect(() => validateDelegationPolicy(bad)).toThrow(/no sub-role/); + }); + + it('rejects cannotDelegateToSameRole when not a boolean', () => { + const bad = structuredClone(pharmacyPolicy); + const pharmacyRole = bad.ruleset.roles.find((r) => r.roleId === 'pharmacy'); + pharmacyRole.cannotDelegateToSameRole = 'yes'; + expect(() => validateDelegationPolicy(bad)).toThrow(DelegationError); + expect(() => validateDelegationPolicy(bad)).toThrow(/cannotDelegateToSameRole must be a boolean/); + }); + + it('treats empty child attributes as narrower than wildcard parent', () => { + expect(attributesAreNarrowerOrEqual([], ['*'])).toBe(true); + }); +}); + +describe('delegation policy role graph', () => { + it('treats descendant roles as valid successors of an ancestor role id', () => { + const roleById = new Map(pharmacyPolicy.ruleset.roles.map((r) => [r.roleId, r])); + expect(isRoleAncestorOrEqual('doctor', 'patient', roleById)).toBe(true); + expect(isRoleAncestorOrEqual('doctor', 'pharmacy', roleById)).toBe(true); + expect(isRoleAncestorOrEqual('pharmacy', 'patient', roleById)).toBe(true); + expect(isRoleAncestorOrEqual('patient', 'doctor', roleById)).toBe(false); + }); + + it('isRoleStrictSubRole is false for same role and true for proper descendants', () => { + const roleById = new Map(pharmacyPolicy.ruleset.roles.map((r) => [r.roleId, r])); + expect(isRoleStrictSubRole('pharmacy', 'pharmacy', roleById)).toBe(false); + expect(isRoleStrictSubRole('pharmacy', 'patient', roleById)).toBe(true); + expect(isRoleStrictSubRole('doctor', 'pharmacy', roleById)).toBe(true); + }); +}); + +describe('delegation policy chain resolution', () => { + it('resolves and verifies digest for mock chain', async () => { + const chain = [ + { + id: 'urn:root', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: computePolicyDigestHex(pharmacyPolicy), + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + }, + type: ['DelegationCredential'], + }, + ]; + const out = await resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyPolicy.id, + rootPolicyDigest: computePolicyDigestHex(pharmacyPolicy), + documentLoader: async () => ({ + contextUrl: null, + documentUrl: pharmacyPolicy.id, + document: structuredClone(pharmacyPolicy), + }), + }); + expect(out.id).toBe(pharmacyPolicy.id); + }); + + it('rejects two delegation steps with the same role when policy sets cannotDelegateToSameRole', async () => { + const digest = computePolicyDigestHex(pharmacyPolicy); + const pharmacySubject = { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + }; + const chain = [ + { + id: 'urn:root', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: digest, + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { ...pharmacySubject }, + type: ['DelegationCredential'], + }, + { + id: 'urn:second', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: digest, + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { ...pharmacySubject }, + type: ['DelegationCredential'], + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyPolicy.id, + rootPolicyDigest: digest, + documentLoader: async () => ({ document: structuredClone(pharmacyPolicy) }), + }), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_ROLE_INVALID }); + }); + + it('accepts pharmacy then patient when pharmacy cannotDelegateToSameRole', async () => { + const digest = computePolicyDigestHex(pharmacyPolicy); + const chain = [ + { + id: 'urn:root', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: digest, + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + PickUp: true, + Pay: true, + }, + type: ['DelegationCredential'], + }, + { + id: 'urn:patient', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: digest, + delegationRoleId: 'patient', + issuanceDate: mockChainIssuanceDate, + expirationDate: '2026-06-10T12:00:00Z', + credentialSubject: { + allowedClaims: ['PickUp'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + PickUp: true, + Pay: true, + }, + type: ['DelegationCredential'], + }, + ]; + const out = await resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyPolicy.id, + rootPolicyDigest: digest, + documentLoader: async () => ({ document: structuredClone(pharmacyPolicy) }), + }); + expect(out.id).toBe(pharmacyPolicy.id); + }); + + it('throws when documentLoader returns a null document', async () => { + await expect( + fetchDelegationPolicyJson(async () => ({ document: null }), pharmacyPolicy.id), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_DOCUMENT_LOAD_FAILED }); + }); + + it('rejects child credential expiring after parent', async () => { + const baseSubject = { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + PickUp: true, + Pay: true, + }; + const chain = [ + { + id: 'urn:root', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: computePolicyDigestHex(pharmacyPolicy), + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { ...baseSubject }, + type: ['DelegationCredential'], + }, + { + id: 'urn:child', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: computePolicyDigestHex(pharmacyPolicy), + delegationRoleId: 'patient', + issuanceDate: mockChainIssuanceDate, + expirationDate: '2026-08-10T12:00:00Z', + credentialSubject: { + allowedClaims: ['PickUp'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + PickUp: true, + Pay: true, + }, + type: ['DelegationCredential'], + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyPolicy.id, + rootPolicyDigest: computePolicyDigestHex(pharmacyPolicy), + documentLoader: async () => ({ document: structuredClone(pharmacyPolicy) }), + }), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_LIFETIME_INVALID }); + }); + + it('throws on digest mismatch', async () => { + const chain = [ + { + id: 'urn:root', + delegationPolicyId: pharmacyPolicy.id, + delegationPolicyDigest: '0'.repeat(64), + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + }, + type: ['DelegationCredential'], + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: pharmacyPolicy.id, + rootPolicyDigest: '0'.repeat(64), + documentLoader: async () => ({ + document: structuredClone(pharmacyPolicy), + }), + }), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_DIGEST_MISMATCH }); + }); + + it('rejects policy missing overallConstraints.maxDelegationDepth with typed policy error', async () => { + const badPolicy = structuredClone(pharmacyPolicy); + delete badPolicy.ruleset.overallConstraints; + const chain = [ + { + id: 'urn:root', + delegationPolicyId: badPolicy.id, + delegationPolicyDigest: computePolicyDigestHex(badPolicy), + delegationRoleId: 'pharmacy', + issuanceDate: mockChainIssuanceDate, + expirationDate: mockChainRootExpirationDate, + credentialSubject: { + allowedClaims: ['PickUp', 'Pay'], + prescriptionResourceIds: [mockPrescriptionResourceId], + canPickUp: true, + canPay: true, + }, + type: ['DelegationCredential'], + }, + ]; + await expect( + resolveAndVerifyDelegationPolicy({ + chain, + rootPolicyId: badPolicy.id, + rootPolicyDigest: computePolicyDigestHex(badPolicy), + documentLoader: async () => ({ document: structuredClone(badPolicy) }), + }), + ).rejects.toMatchObject({ code: DelegationErrorCodes.POLICY_SEMANTIC_INVALID }); + }); +}); + +describe('durationToMilliseconds', () => { + it('converts days', () => { + expect(durationToMilliseconds({ value: 90, unit: 'days' })).toBe(90 * 86400000); + }); +}); + +describe('delegation policy edge cases', () => { + it('counts delegation steps after root even when root is not DelegationCredential', () => { + const chain = [ + { id: 'urn:root', type: ['VerifiableCredential'] }, + { id: 'urn:delegated', type: ['DelegationCredential'] }, + ]; + expect(() => assertMaxDelegationDepth(chain, 0)).toThrow(DelegationError); + expect(() => assertMaxDelegationDepth(chain, 1)).not.toThrow(); + }); + + it('accepts equivalent object claims with different key order in monotonic check', () => { + const capabilityNames = new Set(['scope']); + const parent = { + id: 'urn:parent', + credentialSubject: { + scope: [{ a: 1, b: 2 }], + }, + }; + const child = { + id: 'urn:child', + credentialSubject: { + scope: [{ b: 2, a: 1 }], + }, + }; + expect(() => assertAdjacentCredentialsMonotonic(parent, child, capabilityNames)).not.toThrow(); + }); + + it('accepts equivalent direct object claims with different key order in monotonic check', () => { + const capabilityNames = new Set(); + const parent = { + id: 'urn:parent', + credentialSubject: { + profile: { + creditScore: 760, + tier: 'gold', + }, + }, + }; + const child = { + id: 'urn:child', + credentialSubject: { + profile: { + tier: 'gold', + creditScore: 760, + }, + }, + }; + expect(() => assertAdjacentCredentialsMonotonic(parent, child, capabilityNames)).not.toThrow(); + }); + + it('rejects numeric grant schemas that broaden parent minimum/maximum bounds', () => { + expect(() => assertGrantSchemaNarrowing( + { type: 'number', minimum: 0 }, + { type: 'number', minimum: 700 }, + 'creditScore', + )).toThrow(/minimum must be >= parent minimum/); + + expect(() => assertGrantSchemaNarrowing( + { type: 'number', maximum: 1000 }, + { type: 'number', maximum: 100 }, + 'creditScore', + )).toThrow(/maximum must be <= parent maximum/); + }); +}); + +describe('fetchDelegationPolicyJson', () => { + it('throws when documentLoader is missing', async () => { + await expect(fetchDelegationPolicyJson(null, 'urn:x')).rejects.toMatchObject({ + code: DelegationErrorCodes.POLICY_DOCUMENT_LOADER_REQUIRED, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 22240c5f8..9b4c2f457 100644 --- a/yarn.lock +++ b/yarn.lock @@ -92,6 +92,15 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/eslint-parser@^7.25.9": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz#6a294a4add732ebe7ded8a8d2792dd03dd81dc3f" + integrity sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA== + dependencies: + "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1" + eslint-visitor-keys "^2.1.0" + semver "^6.3.1" + "@babel/generator@^7.28.3", "@babel/generator@^7.7.2": version "7.28.3" resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz" @@ -1022,9 +1031,9 @@ integrity sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ== "@bufbuild/protobuf@^2.2.2", "@bufbuild/protobuf@^2.5.1": - version "2.7.0" - resolved "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.7.0.tgz" - integrity sha512-qn6tAIZEw5i/wiESBF4nQxZkl86aY4KoO0IkUa2Lh+rya64oTOdJQFlZuMwI1Qz9VBJQrQC4QlSA2DNek5gCOA== + version "2.11.0" + resolved "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz" + integrity sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ== "@cedar-policy/cedar-wasm@^4.5.0": version "4.7.0" @@ -1287,7 +1296,7 @@ "@cheqd/sdk@5.5.0": version "5.5.0" - resolved "https://registry.yarnpkg.com/@cheqd/sdk/-/sdk-5.5.0.tgz#12724edf804ab14142f5767611b47bbc67d52705" + resolved "https://registry.npmjs.org/@cheqd/sdk/-/sdk-5.5.0.tgz" integrity sha512-vtmu8GsGSvR0w08kRX1CXI+lwU5K+qJg+zxh3rQiSsid36DiakmrQAJF2bNsGhyDYDT0GjObsKohHxoiBXX0pw== dependencies: "@cheqd/ts-proto" "^4.2.0" @@ -1334,7 +1343,7 @@ "@cheqd/ts-proto-cjs@npm:@cheqd/ts-proto@~2.6.0": version "2.6.0" - resolved "https://registry.yarnpkg.com/@cheqd/ts-proto/-/ts-proto-2.6.0.tgz#b020a0db8e44667d93c423fc5fdb44999b75ba6f" + resolved "https://registry.npmjs.org/@cheqd/ts-proto/-/ts-proto-2.6.0.tgz" integrity sha512-0hbAFZzt4jvEQqCp0SjSM05f0l5HR4MhrWdlF4bk1jmHAz8U/FgCqXTBIJZ9nGG7RASX9t9MPbmhPOh6ED+4ow== dependencies: "@bufbuild/protobuf" "^2.2.2" @@ -1342,13 +1351,13 @@ protobufjs "^7.4.0" "@cheqd/ts-proto@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@cheqd/ts-proto/-/ts-proto-4.2.0.tgz#fa8ee83a18e5d5c809de9247a84aa4c62705e516" - integrity sha512-0/pKFSDtERs5euLW0mcry3Lr9mguDY3Sr+W9uzs7Iru6HEBxs+LjwcpaFr9073wlsKF9fLhsCDHvDJDwNxePkg== + version "4.2.1" + resolved "https://registry.npmjs.org/@cheqd/ts-proto/-/ts-proto-4.2.1.tgz" + integrity sha512-8O15Qaj/lyb/zNsKlUfP79XDnZw7lEToeONV8ji04AkFKf+hzPQNXhTMWp7Wf/vywUEzrHYZa1I4DITT6QmCJA== dependencies: "@bufbuild/protobuf" "^2.5.1" long "^5.3.2" - protobufjs "^7.5.3" + protobufjs "^8.0.0" "@colors/colors@1.6.0", "@colors/colors@^1.6.0": version "1.6.0" @@ -4507,7 +4516,7 @@ "@cosmjs/amino@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/amino/-/amino-0.36.2.tgz#cb18e0115e14212a237941df4ce4345cecc10e44" + resolved "https://registry.npmjs.org/@cosmjs/amino/-/amino-0.36.2.tgz" integrity sha512-r4yV1bhl412gwHGlyaUaJHIJnmldtyGsAwyz3oHHVxduiECj06Rv6wqeyLZfQa9W6hU+MlZwy7LabSUkkyGwjA== dependencies: "@cosmjs/crypto" "^0.36.2" @@ -4543,7 +4552,7 @@ "@cosmjs/crypto@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/crypto/-/crypto-0.36.2.tgz#c65344709c690e27d6fb103a9ee3decf300df29b" + resolved "https://registry.npmjs.org/@cosmjs/crypto/-/crypto-0.36.2.tgz" integrity sha512-QL4NHtcqR6DEKIN200aLeR8gKO433K0f5avKV0TVFP/g12UtnEGSk79PJq5Gv1PLc9GtATHgLLQI/3D8TEe+ig== dependencies: "@cosmjs/encoding" "^0.36.2" @@ -4574,7 +4583,7 @@ "@cosmjs/encoding@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/encoding/-/encoding-0.36.2.tgz#9f2ca496f7027b6652b1e4cce8b5707815454186" + resolved "https://registry.npmjs.org/@cosmjs/encoding/-/encoding-0.36.2.tgz" integrity sha512-i3+P1EKYoLcONAsmpJPhDAc3Wh3ajZNRHt/hczi/JEQXmleTJLVzv2mXUyllM6Qa+B6ybbr3Z2lnEFa8L3yLqg== dependencies: base64-js "^1.3.0" @@ -4591,7 +4600,7 @@ "@cosmjs/json-rpc@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/json-rpc/-/json-rpc-0.36.2.tgz#26af3b0ae246493ef51ef2496114c8704e8d98e2" + resolved "https://registry.npmjs.org/@cosmjs/json-rpc/-/json-rpc-0.36.2.tgz" integrity sha512-3IRamylHVCxBevXGlnIoWUdJCLsP5LwHbXYUsBnC9T8UttZ5oYRN5gDf6+2dQEPk+p9xOv2i8xrCwNWxo7675Q== dependencies: "@cosmjs/stream" "^0.36.2" @@ -4613,7 +4622,7 @@ "@cosmjs/math@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/math/-/math-0.36.2.tgz#94293cbdd86614b7a91f93d9c9dd3303dc5a964a" + resolved "https://registry.npmjs.org/@cosmjs/math/-/math-0.36.2.tgz" integrity sha512-uJZRzxqnBk3MgxFgeyUwLgUzWkAIcmznWSB/tgGCjGCnUNebzI+44dA3ncEDCMqQysi/MZ+cSwAcDU7IY2PFeA== "@cosmjs/proto-signing-cjs@npm:@cosmjs/proto-signing@~0.30.1": @@ -4644,7 +4653,7 @@ "@cosmjs/proto-signing@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/proto-signing/-/proto-signing-0.36.2.tgz#df908e0f6d5a416f51faa86e62806bba3896c4ea" + resolved "https://registry.npmjs.org/@cosmjs/proto-signing/-/proto-signing-0.36.2.tgz" integrity sha512-dyZsgZBQgGkaE4cazHVX8GDwrRJVKUVDnrODkyFXVNbxMnm4t6nxpK1qwgY9GHlWUhck3Dh9NT3BoMbXiMYTZQ== dependencies: "@cosmjs/amino" "^0.36.2" @@ -4666,7 +4675,7 @@ "@cosmjs/socket@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/socket/-/socket-0.36.2.tgz#38bddea95f3d89252822239ecfcf1732809db795" + resolved "https://registry.npmjs.org/@cosmjs/socket/-/socket-0.36.2.tgz" integrity sha512-Pb7JcTFWnq6yfY0IEejHrpSxNDJYcqjjAa1D29a6b/obk4qa4o3oIV5bIx6zAbdRq8uLoBfvWs0bHTNnVuBWJg== dependencies: "@cosmjs/stream" "^0.36.2" @@ -4694,7 +4703,7 @@ "@cosmjs/stargate@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/stargate/-/stargate-0.36.2.tgz#d5273373027d24020bf89fb626fa586c92aabd58" + resolved "https://registry.npmjs.org/@cosmjs/stargate/-/stargate-0.36.2.tgz" integrity sha512-vnNK4dXF+s2v1aKPfYxKVrvXPcnBQb8rPoBScnTpPWnRt3XXbLw7Oo6fTQQWwKYNKQzi6DOApeEB+bCYcaPAAw== dependencies: "@cosmjs/amino" "^0.36.2" @@ -4715,7 +4724,7 @@ "@cosmjs/stream@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/stream/-/stream-0.36.2.tgz#9a3f0ef27d27c84f91bbd6cda72f75e90c01d361" + resolved "https://registry.npmjs.org/@cosmjs/stream/-/stream-0.36.2.tgz" integrity sha512-FlZx2Buovem837LdTLPkPFcxzuQ7zierAqSXwMPr/MG3k+qMxHNfLFTTCXMNWQ4ZlbYedud8ZqCL3/HKdS5mig== dependencies: xstream "^11.14.0" @@ -4754,7 +4763,7 @@ "@cosmjs/tendermint-rpc@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/tendermint-rpc/-/tendermint-rpc-0.36.2.tgz#62e5d95f456c4a1f3aa62dc79123608e36e26206" + resolved "https://registry.npmjs.org/@cosmjs/tendermint-rpc/-/tendermint-rpc-0.36.2.tgz" integrity sha512-76Z99C1NVf/Yv/1bWU0wul8MhRwVdqiZxqU5bcHqvJLoQ2nKUfGpSSYRdbMHfZ63J8ryRqQ95uPvPTfrBb+agw== dependencies: "@cosmjs/crypto" "^0.36.2" @@ -4779,7 +4788,7 @@ "@cosmjs/utils@^0.36.2": version "0.36.2" - resolved "https://registry.yarnpkg.com/@cosmjs/utils/-/utils-0.36.2.tgz#a688deb0cd6d8db63d22066173e6066086fd488b" + resolved "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.36.2.tgz" integrity sha512-OOr2HU/Ph+/GI1Fx2UCf3LOyX9YTCP51d2HitTOjjEJRYnkfKXP3lMBl1FZo5QaFWxnfuBc+Cj+cSoiQUJRyzQ== "@dabh/diagnostics@^2.0.2": @@ -4791,9 +4800,18 @@ enabled "2.0.x" kuler "^2.0.0" +"@digitalbazaar/http-client@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@digitalbazaar/http-client/-/http-client-1.2.0.tgz#1ea3661e77000a15bd892a294f20dc6cc5d1c93b" + integrity sha512-W9KQQ5pUJcaR0I4c2HPJC0a7kRbZApIorZgPnEDwMBgj16iQzutGLrCXYaZOmxqVLVNqqlQ4aUJh+HBQZy4W6Q== + dependencies: + esm "^3.2.22" + ky "^0.25.1" + ky-universal "^0.8.2" + "@digitalbazaar/http-client@^3.2.0": version "3.4.1" - resolved "https://registry.yarnpkg.com/@digitalbazaar/http-client/-/http-client-3.4.1.tgz#5116fc44290d647cfe4b615d1f3fad9d6005e44d" + resolved "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-3.4.1.tgz" integrity sha512-Ahk1N+s7urkgj7WvvUND5f8GiWEPfUw0D41hdElaqLgu8wZScI8gdI0q+qWw5N1d35x7GCRH2uk9mi+Uzo9M3g== dependencies: ky "^0.33.3" @@ -5723,9 +5741,9 @@ integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== "@isaacs/brace-expansion@^5.0.0": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz#0ef5a92d91f2fff2a37646ce54da9e5f599f6eff" - integrity sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ== + version "5.0.0" + resolved "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== dependencies: "@isaacs/balanced-match" "^4.0.1" @@ -6582,6 +6600,13 @@ resolved "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz" integrity sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ== +"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": + version "5.1.1-v1" + resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" + integrity sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg== + dependencies: + eslint-scope "5.1.1" + "@noble/ciphers@^0.6.0": version "0.6.0" resolved "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.6.0.tgz" @@ -6751,6 +6776,19 @@ is-reference "1.2.1" magic-string "^0.27.0" +"@rollup/plugin-commonjs@^28.0.1": + version "28.0.9" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz#b875cd1590617a40c4916d561d75761c6ca3c6d1" + integrity sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA== + dependencies: + "@rollup/pluginutils" "^5.0.1" + commondir "^1.0.1" + estree-walker "^2.0.2" + fdir "^6.2.0" + is-reference "1.2.1" + magic-string "^0.30.3" + picomatch "^4.0.2" + "@rollup/plugin-json@^4.1.0": version "4.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz" @@ -6758,7 +6796,14 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^15.0.1": +"@rollup/plugin-json@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-6.1.0.tgz#fbe784e29682e9bb6dee28ea75a1a83702e7b805" + integrity sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA== + dependencies: + "@rollup/pluginutils" "^5.1.0" + +"@rollup/plugin-node-resolve@^15.0.1", "@rollup/plugin-node-resolve@^15.3.1": version "15.3.1" resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz" integrity sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA== @@ -6792,116 +6837,250 @@ estree-walker "^2.0.2" picomatch "^4.0.2" +"@rollup/pluginutils@^5.1.0": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + "@rollup/rollup-android-arm-eabi@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz#7131f3d364805067fd5596302aad9ebef1434b32" integrity sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA== +"@rollup/rollup-android-arm-eabi@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz#043f145716234529052ef9e1ce1d847ffbe9e674" + integrity sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA== + "@rollup/rollup-android-arm64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz#7ede14d7fcf7c57821a2731c04b29ccc03145d82" integrity sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g== +"@rollup/rollup-android-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz#023e1bd146e7519087dfd9e8b29e4cf9f8ecd35c" + integrity sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA== + "@rollup/rollup-darwin-arm64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz#d59bf9ed582b38838e86a17f91720c17db6575b9" integrity sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ== +"@rollup/rollup-darwin-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz#55ccb5487c02419954c57a7a80602885d616e1ee" + integrity sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw== + "@rollup/rollup-darwin-x64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz#a76278d9b9da9f84ea7909a14d93b915d5bbe01e" integrity sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw== +"@rollup/rollup-darwin-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz#254b65404b14488c83225e88b8819376ad71a784" + integrity sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew== + "@rollup/rollup-freebsd-arm64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz#1a94821a1f565b9eaa74187632d482e4c59a1707" integrity sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA== +"@rollup/rollup-freebsd-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz#6377ff38c052c76fcaffb7b2728d3172fe676fe6" + integrity sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w== + "@rollup/rollup-freebsd-x64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz#aad2274680106b2b6549b1e35e5d3a7a9f1f16af" integrity sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA== +"@rollup/rollup-freebsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz#ba3902309d088eaf7139b916f09b7140b28b406d" + integrity sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g== + "@rollup/rollup-linux-arm-gnueabihf@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz#100fe4306399ffeec47318a3c9b8c0e5e8b07ddb" integrity sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg== +"@rollup/rollup-linux-arm-gnueabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz#e011b9a14638267e53b446286e838dbdaf53f167" + integrity sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g== + "@rollup/rollup-linux-arm-musleabihf@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz#b84634952604b950e18fa11fddebde898c5928d8" integrity sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q== +"@rollup/rollup-linux-arm-musleabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz#0bce9ce9a009490abd28fd922dd97ed521311afe" + integrity sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg== + "@rollup/rollup-linux-arm64-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz#dad6f2fb41c2485f29a98e40e9bd78253255dbf3" integrity sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA== +"@rollup/rollup-linux-arm64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz#6f6cfbbf324fbb4ceff213abdf7f322fd45d25ff" + integrity sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ== + "@rollup/rollup-linux-arm64-musl@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz#0f3f77c8ce9fbf982f8a8378b70a73dc6704a706" integrity sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ== +"@rollup/rollup-linux-arm64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz#f7cb3eecaea9c151ef77342af05f38ae924bf795" + integrity sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA== + "@rollup/rollup-linux-loong64-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz#870bb94e9dad28bb3124ba49bd733deaa6aa2635" integrity sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ== +"@rollup/rollup-linux-loong64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz#499bfac6bb669fd88bb664357bf6be996a28b92f" + integrity sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ== + +"@rollup/rollup-linux-loong64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz#127dfac08764764396bbe04453c545d38a3ab518" + integrity sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw== + "@rollup/rollup-linux-ppc64-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz#188427d11abefc6c9926e3870b3e032170f5577c" integrity sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g== +"@rollup/rollup-linux-ppc64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz#6a72f4d95852aac18326c5bf708393e8f3a41b70" + integrity sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw== + +"@rollup/rollup-linux-ppc64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz#ba8674666b00d6f9066cb9a5771a8430c34d2de6" + integrity sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg== + "@rollup/rollup-linux-riscv64-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz#9dec6eadbbb5abd3b76fe624dc4f006913ff4a7f" integrity sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA== +"@rollup/rollup-linux-riscv64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz#17cc38b2a71e302547cad29bcf78d0db2618c922" + integrity sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg== + "@rollup/rollup-linux-riscv64-musl@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz#b26ba1c80b6f104dc5bd83ed83181fc0411a0c38" integrity sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ== +"@rollup/rollup-linux-riscv64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz#e36a41e2d8bd247331bd5cfc13b8c951d33454a2" + integrity sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg== + "@rollup/rollup-linux-s390x-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz#dc83647189b68ad8d56a956a6fcaa4ee9c728190" integrity sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w== +"@rollup/rollup-linux-s390x-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz#1687265f1f4bdea0726c761a58c2db9933609d68" + integrity sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ== + "@rollup/rollup-linux-x64-gnu@4.53.2": version "4.53.2" resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz" integrity sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw== +"@rollup/rollup-linux-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz#56a6a0d9076f2a05a976031493b24a20ddcc0e77" + integrity sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg== + "@rollup/rollup-linux-x64-musl@4.53.2": version "4.53.2" resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz" integrity sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA== +"@rollup/rollup-linux-x64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz#bc240ebb5b9fd8d41ca8a80cb458452e8c187e0f" + integrity sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w== + +"@rollup/rollup-openbsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz#6f80d48a006c4b2ffa7724e95a3e33f6975872af" + integrity sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw== + "@rollup/rollup-openharmony-arm64@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz#3acd0157cb8976f659442bfd8a99aca46f8a2931" integrity sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A== +"@rollup/rollup-openharmony-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz#8f6db6f70d0a48abd833b263cd6dd3e7199c4c0e" + integrity sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA== + "@rollup/rollup-win32-arm64-msvc@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz#3eb9e7d4d0e1d2e0850c4ee9aa2d0ddf89a8effa" integrity sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA== +"@rollup/rollup-win32-arm64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz#b68989bfa815d0b3d4e302ecd90bda744438b177" + integrity sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g== + "@rollup/rollup-win32-ia32-msvc@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz#d69280bc6680fe19e0956e965811946d542f6365" integrity sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg== +"@rollup/rollup-win32-ia32-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz#c098e45338c50f22f1b288476354f025b746285b" + integrity sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg== + "@rollup/rollup-win32-x64-gnu@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz#d182ce91e342bad9cbb8b284cf33ac542b126ead" integrity sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw== +"@rollup/rollup-win32-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz#2c9e15be155b79d05999953b1737b2903842e903" + integrity sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg== + "@rollup/rollup-win32-x64-msvc@4.53.2": version "4.53.2" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz#d9ab606437fd072b2cb7df7e54bcdc7f1ccbe8b4" integrity sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA== +"@rollup/rollup-win32-x64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz#23b860113e9f87eea015d1fa3a4240a52b42fcd4" + integrity sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz" @@ -7851,6 +8030,13 @@ ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv@^6.12.4: version "6.14.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" @@ -7861,9 +8047,9 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.12.0: +ajv@^8.0.0, ajv@^8.17.1: version "8.18.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz" integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== dependencies: fast-deep-equal "^3.1.3" @@ -7871,6 +8057,16 @@ ajv@^8.0.0, ajv@^8.12.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +ajv@^8.12.0: + version "8.17.1" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@^4.1.1, ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz" @@ -8717,7 +8913,7 @@ core-js@^3.30.2: cosmjs-types@^0.10.1: version "0.10.1" - resolved "https://registry.yarnpkg.com/cosmjs-types/-/cosmjs-types-0.10.1.tgz#6069a42673c36aa9567b8c5fb277ab3bda86dccd" + resolved "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.10.1.tgz" integrity sha512-CENXb4O5GN+VyB68HYXFT2SOhv126Z59631rZC56m8uMWa6/cSlFeai8BwZGT1NMepw0Ecf+U8XSOnBzZUWh9Q== cosmjs-types@^0.7.1: @@ -8787,6 +8983,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5, cross-spawn@^7.0.6: dependencies: buffer "^6.0.3" +data-uri-to-buffer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" + integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz" @@ -9105,7 +9306,7 @@ electron-to-chromium@^1.5.204: elliptic@6.5.4: version "6.5.4" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + resolved "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== dependencies: bn.js "^4.11.9" @@ -9118,7 +9319,7 @@ elliptic@6.5.4: elliptic@6.6.1, elliptic@^6.5.3, elliptic@^6.5.4, elliptic@^6.5.7: version "6.6.1" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" + resolved "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz" integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== dependencies: bn.js "^4.11.9" @@ -9476,7 +9677,7 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^5.1.1: +eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -9497,6 +9698,11 @@ eslint-visitor-keys@^1.0.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== +eslint-visitor-keys@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" + integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== + eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" @@ -9546,6 +9752,11 @@ eslint@^8.0.0: strip-ansi "^6.0.1" text-table "^0.2.0" +esm@^3.2.22: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz" @@ -9889,7 +10100,7 @@ fast-glob@3.3.2: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.0.3, fast-glob@^3.2.9: +fast-glob@3.3.3, fast-glob@^3.0.3, fast-glob@^3.2.9: version "3.3.3" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -9934,11 +10145,21 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fdir@^6.2.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + fecha@^4.2.0: version "4.2.3" resolved "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz" integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== +fetch-blob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.1.2.tgz#a7805db1361bd44c1ef62bb57fb5fe8ea173ef3c" + integrity sha512-YKqtUDwqLyfyMnmbw8XD6Q8j9i/HggKtPEI+pZ1+8bvheBu78biSmNaXWusx1TauGqtUUGx/cBb1mKdq2rLYow== + fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz" @@ -10394,7 +10615,7 @@ hash-base@^3.0.0: hash-wasm@^4.12.0: version "4.12.0" - resolved "https://registry.yarnpkg.com/hash-wasm/-/hash-wasm-4.12.0.tgz#f9f1a9f9121e027a9acbf6db5d59452ace1ef9bb" + resolved "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz" integrity sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ== hash.js@1.1.3: @@ -10542,14 +10763,14 @@ ignore@^5.1.1, ignore@^5.2.0: integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== immutable@^3.8.2: - version "3.8.3" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.3.tgz#0a8d2494a94d4b2d4f0e99986e74dd25d1e9a859" - integrity sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg== + version "3.8.2" + resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz" + integrity sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg== immutable@^4.1.0, immutable@^4.3.7: - version "4.3.8" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.8.tgz#02d183c7727fb2bb1d5d0380da0d779dce9296a7" - integrity sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw== + version "4.3.7" + resolved "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz" + integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== import-fresh@^3.2.1: version "3.3.1" @@ -11709,7 +11930,7 @@ json-buffer@3.0.1: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== -json-canonicalize@1.0.4: +json-canonicalize@1.0.4, json-canonicalize@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/json-canonicalize/-/json-canonicalize-1.0.4.tgz" integrity sha512-YNr/ePzgReHwlnAm3EVV1pcimwesI+1DZr5v7WBKOc1zE1t7pjxWAPRxJFT3ll6flLIdRe0DPia/8cl2FLAZNA== @@ -11908,6 +12129,19 @@ ky-universal@^0.11.0: abort-controller "^3.0.0" node-fetch "^3.2.10" +ky-universal@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/ky-universal/-/ky-universal-0.8.2.tgz#edc398d54cf495d7d6830aa1ab69559a3cc7f824" + integrity sha512-xe0JaOH9QeYxdyGLnzUOVGK4Z6FGvDVzcXFTdrYA1f33MZdEa45sUDaMBy98xQMcsd2XIBrTXRrRYnegcSdgVQ== + dependencies: + abort-controller "^3.0.0" + node-fetch "3.0.0-beta.9" + +ky@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/ky/-/ky-0.25.1.tgz#0df0bd872a9cc57e31acd5dbc1443547c881bfbc" + integrity sha512-PjpCEWlIU7VpiMVrTwssahkYXX1by6NCT0fhTUX34F3DTinARlgMpriuroolugFPcMgpPWrOW4mTb984Qm1RXA== + ky@^0.33.3: version "0.33.3" resolved "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz" @@ -12109,7 +12343,7 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.12: +magic-string@^0.30.12, magic-string@^0.30.3: version "0.30.21" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -12454,6 +12688,14 @@ node-environment-flags@^1.0.5: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" +node-fetch@3.0.0-beta.9: + version "3.0.0-beta.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.0.0-beta.9.tgz#0a7554cfb824380dd6812864389923c783c80d9b" + integrity sha512-RdbZCEynH2tH46+tj0ua9caUHVWrd/RHnRfvly2EVdqGmI3ndS1Vn/xjm5KuGejDt2RNDQsVRLPNd2QPwcewVg== + dependencies: + data-uri-to-buffer "^3.0.1" + fetch-blob "^2.1.1" + node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" @@ -12991,7 +13233,7 @@ protobufjs@^6.8.8, protobufjs@~6.11.2, protobufjs@~6.11.3: "@types/node" ">=13.7.0" long "^4.0.0" -protobufjs@^7.4.0, protobufjs@^7.5.3: +protobufjs@^7.4.0: version "7.5.4" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz" integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== @@ -13009,6 +13251,24 @@ protobufjs@^7.4.0, protobufjs@^7.5.3: "@types/node" ">=13.7.0" long "^5.0.0" +protobufjs@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz" + integrity sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + protons-runtime@^5.0.0, protons-runtime@^5.2.1, protons-runtime@^5.4.0, protons-runtime@^5.5.0: version "5.6.0" resolved "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz" @@ -13522,6 +13782,13 @@ rollup-plugin-multi-input@^1.3.2: dependencies: fast-glob "3.3.2" +rollup-plugin-multi-input@^1.4.1: + version "1.8.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-multi-input/-/rollup-plugin-multi-input-1.8.0.tgz#86db72bab69ef4e78c14947109d7780391e4af54" + integrity sha512-X8nSq29ZzFrc1LJSZ3d3nS7jE54g3oVLVwqkzJacwABvk5FikKmq275xrmz0mSoJxHUsLsN7taJbwhrv9qoW7A== + dependencies: + fast-glob "3.3.3" + rollup-plugin-node-polyfills@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz" @@ -13584,6 +13851,40 @@ rollup@^4.20.0: "@rollup/rollup-win32-x64-msvc" "4.53.2" fsevents "~2.3.2" +rollup@^4.28.0: + version "4.60.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.1.tgz#b4aa2bcb3a5e1437b5fad40d43fe42d4bde7a42d" + integrity sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.60.1" + "@rollup/rollup-android-arm64" "4.60.1" + "@rollup/rollup-darwin-arm64" "4.60.1" + "@rollup/rollup-darwin-x64" "4.60.1" + "@rollup/rollup-freebsd-arm64" "4.60.1" + "@rollup/rollup-freebsd-x64" "4.60.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.1" + "@rollup/rollup-linux-arm-musleabihf" "4.60.1" + "@rollup/rollup-linux-arm64-gnu" "4.60.1" + "@rollup/rollup-linux-arm64-musl" "4.60.1" + "@rollup/rollup-linux-loong64-gnu" "4.60.1" + "@rollup/rollup-linux-loong64-musl" "4.60.1" + "@rollup/rollup-linux-ppc64-gnu" "4.60.1" + "@rollup/rollup-linux-ppc64-musl" "4.60.1" + "@rollup/rollup-linux-riscv64-gnu" "4.60.1" + "@rollup/rollup-linux-riscv64-musl" "4.60.1" + "@rollup/rollup-linux-s390x-gnu" "4.60.1" + "@rollup/rollup-linux-x64-gnu" "4.60.1" + "@rollup/rollup-linux-x64-musl" "4.60.1" + "@rollup/rollup-openbsd-x64" "4.60.1" + "@rollup/rollup-openharmony-arm64" "4.60.1" + "@rollup/rollup-win32-arm64-msvc" "4.60.1" + "@rollup/rollup-win32-ia32-msvc" "4.60.1" + "@rollup/rollup-win32-x64-gnu" "4.60.1" + "@rollup/rollup-win32-x64-msvc" "4.60.1" + fsevents "~2.3.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" @@ -14760,12 +15061,12 @@ util-deprecate@^1.0.1: uuid@2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.1.tgz#c2a30dedb3e535d72ccf82e343941a50ba8533ac" + resolved "https://registry.npmjs.org/uuid/-/uuid-2.0.1.tgz" integrity sha512-nWg9+Oa3qD2CQzHIP4qKUqwNfzKn8P0LtFhotaCTFchsV7ZfDhAybeip/HZVeMIpZi9JgY1E3nUlwaCmZT1sEg== uuid@8.3.2: version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== uuid@^10.0.0: @@ -14780,7 +15081,7 @@ uuid@^11.0.0: uuid@^13.0.0: version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" + resolved "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz" integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== uuidv4@^6.2.13: