diff --git a/.gitignore b/.gitignore index 978ab7e..76dac55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # vscode settings -.vscode \ No newline at end of file +.vscode + +.DS_Store \ No newline at end of file diff --git a/test_client.sh b/test_client.sh old mode 100644 new mode 100755 diff --git a/vclogin/__tests__/evaluateLoginPolicy.test.js b/vclogin/__tests__/evaluateLoginPolicy.test.js index 44df67a..6439a8e 100644 --- a/vclogin/__tests__/evaluateLoginPolicy.test.js +++ b/vclogin/__tests__/evaluateLoginPolicy.test.js @@ -3,10 +3,7 @@ * SPDX-License-Identifier: MIT */ -import { - isTrustedPresentation, - exportedForTesting, -} from "@/lib/evaluateLoginPolicy"; +import { isTrustedPresentation } from "@/lib/extractClaims"; import vpEmployee from "@/testdata/presentations/VP_EmployeeCredential.json"; import vpEmail from "@/testdata/presentations/VP_EmailPass.json"; import vpTezos from "@/testdata/presentations/VP_TezosAssociatedAddress.json"; @@ -14,6 +11,8 @@ import policyAcceptAnything from "@/testdata/policies/acceptAnything.json"; import policyEmployeeFromAnyone from "@/testdata/policies/acceptEmployeeFromAnyone.json"; import policyEmailFromAltme from "@/testdata/policies/acceptEmailFromAltme.json"; import policyFromAltme from "@/testdata/policies/acceptFromAltme.json"; +import policyEmailFromAltmeConstr from "@/testdata/policies/acceptEmailFromAltmeConstr.json"; +import policyEmployeeFromAnyoneConstr from "@/testdata/policies/acceptEmployeeFromAnyoneConstr.json"; describe("evaluateLoginPolicy", () => { it("defaults to false if no policy is available", () => { @@ -54,34 +53,25 @@ describe("evaluateLoginPolicy", () => { trusted = isTrustedPresentation(vpEmployee, policyFromAltme); expect(trusted).toBe(false); }); -}); - -describe("utility function for VP policy validation", () => { - let hasUniquePath = exportedForTesting.hasUniquePath; - it("should return true when there is a unique path", () => { - const patternFits = [ - ["A", "B"], - ["C", "D"], - ["E", "F"], - ]; - const usedCreds = ["A", "C", "E"]; - expect(hasUniquePath(patternFits, usedCreds)).toBe(true); - }); - - it("should return false when there is no unique path", () => { - const patternFits = [ - ["A", "B"], - ["C", "D"], - ["E", "F"], - ]; - const usedCreds = ["A", "C", "E", "B"]; - expect(hasUniquePath(patternFits, usedCreds)).toBe(false); + it("accepts only VP with credential(s) with simple constraint", () => { + var trusted = isTrustedPresentation(vpEmail, policyEmailFromAltmeConstr); + expect(trusted).toBe(true); + trusted = isTrustedPresentation(vpEmployee, policyEmailFromAltmeConstr); + expect(trusted).toBe(false); + trusted = isTrustedPresentation(vpTezos, policyEmailFromAltmeConstr); + expect(trusted).toBe(false); }); - it("should return false when the patternFits array has only one subarray with no elements", () => { - const patternFits = [[]]; - const usedCreds = []; - expect(hasUniquePath(patternFits, usedCreds)).toBe(false); + it("accepts only VP with credential(s) with complicated constraint", () => { + var trusted = isTrustedPresentation( + vpEmployee, + policyEmployeeFromAnyoneConstr, + ); + expect(trusted).toBe(true); + trusted = isTrustedPresentation(vpEmail, policyEmployeeFromAnyoneConstr); + expect(trusted).toBe(false); + trusted = isTrustedPresentation(vpTezos, policyEmployeeFromAnyoneConstr); + expect(trusted).toBe(false); }); }); diff --git a/vclogin/__tests__/extractClaims.test.js b/vclogin/__tests__/extractClaims.test.js index 00cc008..92f0525 100644 --- a/vclogin/__tests__/extractClaims.test.js +++ b/vclogin/__tests__/extractClaims.test.js @@ -8,6 +8,7 @@ import vpEmployee from "@/testdata/presentations/VP_EmployeeCredential.json"; import vpEmail from "@/testdata/presentations/VP_EmailPass.json"; import policyAcceptAnything from "@/testdata/policies/acceptAnything.json"; import policyEmailFromAltme from "@/testdata/policies/acceptEmailFromAltme.json"; +import policyEmailFromAltmeConstr from "@/testdata/policies/acceptEmailFromAltmeConstr.json"; import policyEmployeeFromAnyone from "@/testdata/policies/acceptEmployeeFromAnyone.json"; describe("extractClaims", () => { @@ -47,6 +48,17 @@ describe("extractClaims", () => { expect(claims).toStrictEqual(expected); }); + it("all designated claims from an EmailPass Credential are mapped (constrained)", () => { + var claims = extractClaims(vpEmail, policyEmailFromAltmeConstr); + var expected = { + tokenId: { + email: "felix.hoops@tum.de", + }, + tokenAccess: {}, + }; + expect(claims).toStrictEqual(expected); + }); + it("all designated claims from an EmployeeCredential are extracted", () => { var claims = extractClaims(vpEmployee, policyEmployeeFromAnyone); var expected = { diff --git a/vclogin/__tests__/testdata/policies/acceptAnything.json b/vclogin/__tests__/testdata/policies/acceptAnything.json index d4c7855..15d0d37 100644 --- a/vclogin/__tests__/testdata/policies/acceptAnything.json +++ b/vclogin/__tests__/testdata/policies/acceptAnything.json @@ -1,6 +1,6 @@ [ { - "credentialID": "credential1", + "credentialId": "credential1", "patterns": [ { "issuer": "*", diff --git a/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json b/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json index c096912..e409d18 100644 --- a/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json +++ b/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json @@ -1,6 +1,6 @@ [ { - "credentialID": "one", + "credentialId": "one", "patterns": [ { "issuer": "did:web:app.altme.io:issuer", diff --git a/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json b/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json new file mode 100644 index 0000000..7e69486 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json @@ -0,0 +1,21 @@ +[ + { + "credentialId": "one", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.email", + "token": "id_token" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$.credentialSubject.id" + } + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json index fe9a4af..b9d87dd 100644 --- a/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json +++ b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json @@ -1,6 +1,6 @@ [ { - "credentialID": "one", + "credentialId": "one", "patterns": [ { "issuer": "*", diff --git a/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyoneConstr.json b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyoneConstr.json new file mode 100644 index 0000000..f809654 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyoneConstr.json @@ -0,0 +1,53 @@ +[ + { + "credentialId": "one", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$.credentialSubject.hasLegallyBindingName", + "newPath": "$.companyName" + }, + { + "claimPath": "$.credentialSubject.name", + "token": "id_token" + }, + { + "claimPath": "$.credentialSubject.email", + "token": "id_token" + } + ], + "constraint": { + "op": "and", + "a": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$.credentialSubject.id" + }, + "b": { + "op": "and", + "a": { + "op": "endsWith", + "a": "$.credentialSubject.email", + "b": "@test.com" + }, + "b": { + "op": "or", + "a": { + "op": "matches", + "a": "$.credentialSubject.title", + "b": "C[EOT]O" + }, + "b": { + "op": "equals", + "a": "$.credentialSubject.hasJurisdiction", + "b": "GER" + } + } + } + } + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptFromAltme.json b/vclogin/__tests__/testdata/policies/acceptFromAltme.json index affcff5..f94c5b1 100644 --- a/vclogin/__tests__/testdata/policies/acceptFromAltme.json +++ b/vclogin/__tests__/testdata/policies/acceptFromAltme.json @@ -1,6 +1,6 @@ [ { - "credentialID": "one", + "credentialId": "one", "patterns": [ { "issuer": "did:web:app.altme.io:issuer", diff --git a/vclogin/lib/evaluateLoginPolicy.ts b/vclogin/lib/evaluateLoginPolicy.ts deleted file mode 100644 index dc35b93..0000000 --- a/vclogin/lib/evaluateLoginPolicy.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright 2024 Software Engineering for Business Information Systems (sebis) . - * SPDX-License-Identifier: MIT - */ - -import { CredentialPattern, LoginPolicy } from "@/types/LoginPolicy"; -import jp from "jsonpath"; -import { getConfiguredLoginPolicy } from "@/config/loginPolicy"; - -export const isTrustedPresentation = (VP: any, policy?: LoginPolicy) => { - var configuredPolicy = getConfiguredLoginPolicy(); - if (!policy && configuredPolicy === undefined) return false; - - var usedPolicy = policy ? policy : configuredPolicy!; - - var creds = VP.verifiableCredential; - var credArr: Array; - if (!Array.isArray(creds)) { - credArr = [creds]; - } else { - credArr = creds; - } - - // collect all credentials that fit an expected credential - var patternFits = []; - for (let expectation of usedPolicy) { - let fittingCreds = []; - for (let cred of credArr) { - if (isCredentialFittingPatternList(cred, expectation.patterns)) { - fittingCreds.push(cred); - } - } - patternFits.push(fittingCreds); - } - - return hasUniquePath(patternFits, []); -}; - -const hasUniquePath = (patternFits: any[][], usedCreds: any[]): boolean => { - if (patternFits.length === 1) return patternFits[0].length > 0; - - for (let cred of patternFits[0]) { - if (!usedCreds.includes(cred)) { - usedCreds.push(cred); - let newPatternFits = patternFits.slice(1); - if (hasUniquePath(newPatternFits, usedCreds)) { - return true; - } - usedCreds.pop(); - } - } - return false; -}; - -const isCredentialFittingPatternList = ( - cred: any, - patterns: CredentialPattern[], -): boolean => { - for (let pattern of patterns) { - if (isCredentialFittingPattern(cred, pattern)) { - return true; - } - } - return false; -}; - -const isCredentialFittingPattern = ( - cred: any, - pattern: CredentialPattern, -): boolean => { - if (cred.issuer !== pattern.issuer && pattern.issuer !== "*") { - return false; - } - - for (const claim of pattern.claims) { - if ( - (!Object.hasOwn(claim, "required") || claim.required) && - jp.paths(cred, claim.claimPath).length === 0 - ) { - return false; - } - } - - return true; -}; - -export const exportedForTesting = { - hasUniquePath, -}; diff --git a/vclogin/lib/extractClaims.ts b/vclogin/lib/extractClaims.ts index e2f3eca..d07b497 100644 --- a/vclogin/lib/extractClaims.ts +++ b/vclogin/lib/extractClaims.ts @@ -3,10 +3,28 @@ * SPDX-License-Identifier: MIT */ -import { LoginPolicy, ClaimEntry } from "@/types/LoginPolicy"; +import { + LoginPolicy, + ClaimEntry, + CredentialPattern, + VcConstraint, +} from "@/types/LoginPolicy"; import jp from "jsonpath"; import { getConfiguredLoginPolicy } from "@/config/loginPolicy"; +export const isTrustedPresentation = (VP: any, policy?: LoginPolicy) => { + var configuredPolicy = getConfiguredLoginPolicy(); + if (!policy && configuredPolicy === undefined) return false; + + var usedPolicy = policy ? policy : configuredPolicy!; + + const creds = Array.isArray(VP.verifiableCredential) + ? VP.verifiableCredential + : [VP.verifiableCredential]; + + return getConstraintFit(creds, usedPolicy, VP).length > 0; +}; + export const extractClaims = (VP: any, policy?: LoginPolicy) => { var configuredPolicy = getConfiguredLoginPolicy(); if (!policy && configuredPolicy === undefined) return false; @@ -16,6 +34,7 @@ export const extractClaims = (VP: any, policy?: LoginPolicy) => { const creds = Array.isArray(VP.verifiableCredential) ? VP.verifiableCredential : [VP.verifiableCredential]; + const vcClaims = creds.map((vc: any) => extractClaimsFromVC(vc, usedPolicy)); const claims = vcClaims.reduce( (acc: any, vc: any) => Object.assign(acc, vc), @@ -24,6 +43,217 @@ export const extractClaims = (VP: any, policy?: LoginPolicy) => { return claims; }; +const getConstraintFit = ( + creds: any[], + policy: LoginPolicy, + VP: any, +): any[] => { + const patternFits = getPatternClaimFits(creds, policy); + const uniqueFits = getAllUniqueDraws(patternFits); + if (uniqueFits.length === 0) { + return []; + } + for (let fit of uniqueFits) { + if (isValidConstraintFit(fit, policy, VP)) { + return fit; + } + } + return []; +}; + +const getPatternClaimFits = (creds: any[], policy: LoginPolicy): any[][] => { + // collect all credentials that fit an expected credential claim-wise + var patternFits = []; + for (let expectation of policy) { + let fittingCreds = []; + for (let cred of creds) { + if (isCredentialFittingPatternList(cred, expectation.patterns)) { + fittingCreds.push(cred); + } + } + patternFits.push(fittingCreds); + } + + return patternFits; +}; + +const isCredentialFittingPatternList = ( + cred: any, + + patterns: CredentialPattern[], +): boolean => { + for (let pattern of patterns) { + if (isCredentialFittingPattern(cred, pattern)) { + return true; + } + } + + return false; +}; + +const isCredentialFittingPattern = ( + cred: any, + + pattern: CredentialPattern, +): boolean => { + if (cred.issuer !== pattern.issuer && pattern.issuer !== "*") { + return false; + } + + for (const claim of pattern.claims) { + if ( + (!Object.hasOwn(claim, "required") || claim.required) && + jp.paths(cred, claim.claimPath).length === 0 + ) { + return false; + } + } + + return true; +}; + +const getAllUniqueDraws = (patternFits: any[][]): any[][] => { + const draws = getAllUniqueDrawsHelper(patternFits, []); + return draws.filter((draw) => draw.length == patternFits.length); +}; + +const getAllUniqueDrawsHelper = ( + patternFits: any[][], + usedIds: any[], +): any[][] => { + if (patternFits.length === 0) { + return []; + } + + let uniqueDraws: any[][] = []; + for (let cred of patternFits[0]) { + if (!usedIds.includes(cred.id)) { + uniqueDraws.push([ + cred, + ...getAllUniqueDrawsHelper(patternFits.slice(1), [...usedIds, cred.id]), + ]); + } + } + return uniqueDraws; +}; + +const isValidConstraintFit = ( + credFit: any[], + policy: LoginPolicy, + VP: any, +): boolean => { + const credDict: any = {}; + for (let i = 0; i < policy.length; i++) { + credDict[policy[i].credentialId] = credFit[i]; + } + + for (let i = 0; i < policy.length; i++) { + const cred = credFit[i]; + const expectation = policy[i]; + var oneFittingPattern = false; + for (let pattern of expectation.patterns) { + if (isCredentialFittingPattern(cred, pattern)) { + if (pattern.constraint) { + const res = evaluateConstraint( + pattern.constraint, + cred, + credDict, + VP, + ); + if (res) { + oneFittingPattern = true; + break; + } + } + } + } + if (!oneFittingPattern) { + return true; + } + } + return false; +}; + +const evaluateConstraint = ( + constraint: VcConstraint, + cred: any, + credDict: any, + VP: any, +): boolean => { + var a = "", + b = ""; + switch (constraint.op) { + case "equals": + case "equalsDID": + case "startsWith": + case "endsWith": + case "matches": + a = resolveValue(constraint.a as string, cred, credDict, VP); + b = resolveValue(constraint.b as string, cred, credDict, VP); + } + + switch (constraint.op) { + case "equals": + return a === b; + case "equalsDID": + return ( + a.split("#").slice(0, -1).join("#") === + b.split("#").slice(0, -1).join("#") + ); + case "startsWith": + return a.startsWith(b); + case "endsWith": + return a.endsWith(b); + case "matches": + return a.match(b) !== null; + case "and": + return ( + evaluateConstraint(constraint.a as VcConstraint, cred, credDict, VP) && + evaluateConstraint(constraint.b as VcConstraint, cred, credDict, VP) + ); + case "or": + return ( + evaluateConstraint(constraint.a as VcConstraint, cred, credDict, VP) || + evaluateConstraint(constraint.b as VcConstraint, cred, credDict, VP) + ); + case "not": + return !evaluateConstraint( + constraint.a as VcConstraint, + cred, + credDict, + VP, + ); + } + throw Error("Unknown constraint operator: " + constraint.op); +}; + +const resolveValue = ( + expression: string, + cred: any, + credDict: any, + VP: any, +): string => { + if (expression.startsWith("$")) { + var nodes: any; + if (expression.startsWith("$.")) { + nodes = jp.nodes(cred, expression); + } else if (expression.startsWith("$VP.")) { + nodes = jp.nodes(VP, "$" + expression.slice(3)); + } else { + nodes = jp.nodes( + credDict[expression.slice(1).split(".")[0]], + expression.slice(1).split(".").slice(1).join("."), + ); + } + if (nodes.length > 1 || nodes.length <= 0) { + throw Error("JSON Paths in constraints must be single-valued"); + } + return nodes[0].value; + } + + return expression; +}; + const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { for (let expectation of policy) { for (let pattern of expectation.patterns) { diff --git a/vclogin/package.json b/vclogin/package.json index d131b24..2d00987 100644 --- a/vclogin/package.json +++ b/vclogin/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "jest" + "test": "jest --coverage" }, "dependencies": { "@material-tailwind/react": "^2.0.3", diff --git a/vclogin/pages/api/presentCredential.ts b/vclogin/pages/api/presentCredential.ts index 5abae1e..2c34bd1 100644 --- a/vclogin/pages/api/presentCredential.ts +++ b/vclogin/pages/api/presentCredential.ts @@ -7,8 +7,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { verifyAuthenticationPresentation } from "@/lib/verifyPresentation"; import { hydraAdmin } from "@/config/ory"; import { Redis } from "ioredis"; -import { isTrustedPresentation } from "@/lib/evaluateLoginPolicy"; -import { extractClaims } from "@/lib/extractClaims"; +import { isTrustedPresentation, extractClaims } from "@/lib/extractClaims"; import * as jose from "jose"; import { keyToDID, keyToVerificationMethod } from "@spruceid/didkit-wasm-node"; import { generatePresentationDefinition } from "@/lib/generatePresentationDefinition"; diff --git a/vclogin/types/LoginPolicy.ts b/vclogin/types/LoginPolicy.ts index 89ce736..381fcea 100644 --- a/vclogin/types/LoginPolicy.ts +++ b/vclogin/types/LoginPolicy.ts @@ -3,6 +3,12 @@ * SPDX-License-Identifier: MIT */ +export type VcConstraint = { + op: string; + a: VcConstraint | string; + b: VcConstraint | string; +}; + export type ClaimEntry = { claimPath: string; newPath?: string; @@ -12,9 +18,12 @@ export type ClaimEntry = { export type CredentialPattern = { issuer: string; claims: ClaimEntry[]; + constraint?: VcConstraint; }; + export type ExpectedCredential = { - credentialID: string; + credentialId: string; patterns: CredentialPattern[]; }; + export type LoginPolicy = ExpectedCredential[];