diff --git a/package-lock.json b/package-lock.json index 457d9ab..30b41e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", - "jose": "^4.14.4" + "jose": "^4.14.4", + "js-yaml": "^4.1.0" }, "devDependencies": { "@types/jest": "^29.5.2", @@ -1695,8 +1696,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-union": { "version": "2.1.0", @@ -3657,7 +3657,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6127,8 +6126,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "array-union": { "version": "2.1.0", @@ -7587,7 +7585,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } diff --git a/package.json b/package.json index df462c6..cd3a066 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "ajv": "^8.12.0", - "jose": "^4.14.4" + "jose": "^4.14.4", + "js-yaml": "^4.1.0" } } diff --git a/src/controller.ts b/src/controller.ts new file mode 100644 index 0000000..27987be --- /dev/null +++ b/src/controller.ts @@ -0,0 +1,190 @@ +import * as jose from 'jose' + + +import joseApi from './jose' + +export type RequestGenerateKey = { + alg: string + crv?: string +} + +export const createPrivateKey = async ( + { crv, alg }: RequestGenerateKey, + extractable = true, +) => { + // https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF + if (alg === 'ECDH-ES+A256KW' && crv === undefined) { + crv = 'P-384' + } + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable, + crv, + }) + const publicKeyJwk = await jose.exportJWK(publicKey) + const privateKeyJwk = await jose.exportJWK(privateKey) + privateKeyJwk.alg = alg + privateKeyJwk.kid = await jose.calculateJwkThumbprintUri(publicKeyJwk) + return formatJwk(privateKeyJwk) +} + +const formatJwk = (jwk: any) => { + const { + kid, + x5u, + x5c, + x5t, + kty, + crv, + alg, + use, + key_ops, + x, + y, + d, + ...rest + } = structuredClone(jwk) + return JSON.parse( + JSON.stringify({ + kid, + kty, + crv, + alg, + use, + key_ops, + x, + y, + d, + x5u, + x5c, + x5t, + ...rest, + }), + ) +} + +export const publicKeyToUri = async (publicKeyJwk: any) => { + return jose.calculateJwkThumbprintUri(publicKeyJwk) +} + +export const publicFromPrivate = (privateKeyJwk: any) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, key_ops, ...publicKeyJwk } = privateKeyJwk + return formatJwk(publicKeyJwk) +} + +export const encryptToKey = async ({ publicKey, plaintext }: any) => { + const jwe = await new jose.FlattenedEncrypt(plaintext) + .setProtectedHeader({ alg: publicKey.alg, enc: 'A256GCM' }) + .encrypt(await jose.importJWK(publicKey)) + return jwe +} + +export const decryptWithKey = async ({ privateKey, ciphertext }: any) => { + return jose.flattenedDecrypt(ciphertext, await jose.importJWK(privateKey)) +} + + + +export const formatVerificationMethod = (vm: any) => { + const formatted = { + id: vm.id, + type: vm.type, + controller: vm.controller, + publicKeyJwk: vm.publicKeyJwk, + } + return JSON.parse(JSON.stringify(formatted)) +} + +export const createVerificationMethod = async (publicKeyJwk: any) => { + const holder = await jose.calculateJwkThumbprintUri(publicKeyJwk) + return { + id: holder, + type: 'JsonWebKey', + controller: holder, + publicKeyJwk: formatJwk(publicKeyJwk), + } +} + +export const dereferencePublicKey = async (didUrl: string) => + jose.importJWK( + JSON.parse( + new TextDecoder().decode( + jose.base64url.decode(didUrl.split(':')[2].split('#')[0]), + ), + ), + ) + +export const publicKeyToVerificationMethod = async (publicKeyJwk: any) => { + return '#' + publicKeyToUri(publicKeyJwk) +} + + +export const publicKeyToDid = (publicKeyJwk: any) => { + const id = `did:jwk:${jose.base64url.encode( + JSON.stringify(formatJwk(publicKeyJwk)), + )}` + return id +} + +const signatures = ['authentication', 'assertionMethod'] +const encryptions = ['keyAgreement'] +const both = [...signatures, ...encryptions] +const relationships: any = { + ES256: both, + ES384: both, + EdDSA: signatures, + X25519: encryptions, + ES256K: signatures, +} + +const did = { + document: { + create: async (publicKeyJwk: any) => { + const id = publicKeyToDid(publicKeyJwk) + const vm = await createVerificationMethod(publicKeyJwk) + const ddoc: any = { + '@context': [ + 'https://www.w3.org/ns/did/v1', + { '@vocab': 'https://www.iana.org/assignments/jose#' }, + ], + id, + verificationMethod: [ + formatVerificationMethod({ + ...vm, + id: '#0', + controller: id, + }), + ], + } + relationships[publicKeyJwk.alg].forEach((vmr: any) => { + ddoc[vmr] = ['#0'] + }) + return ddoc + }, + identifier: { + replace: (doc: any, source: any, target: any) => { + return JSON.parse( + JSON.stringify(doc, function replacer(key, value) { + if (value === source) { + return target + } + return value + }), + ) + }, + }, + }, +} + + + + +const key = { + ...joseApi, + createPrivateKey, + publicFromPrivate +} + +const controller = { did, key } + +export default controller \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 22cc69a..46c93f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import vc from './vc' +import controller from './controller' + +export * from './vc/types' export { vc } -const api = { vc } +const api = { controller, vc } export default api \ No newline at end of file diff --git a/src/jose/attached.ts b/src/jose/attached.ts new file mode 100644 index 0000000..a767014 --- /dev/null +++ b/src/jose/attached.ts @@ -0,0 +1,42 @@ + + +import detached, { RequestSigner, RequestVerifier, VerifiedFlattenedJws, RequestFlattenedJws } from './detached' + +export type AttachedSigner = { + sign: ({ protectedHeader, payload }: RequestFlattenedJws) => Promise +} + +export const signer = async ({ privateKey }: RequestSigner): Promise => { + const signer = await detached.signer({ privateKey }) + return { + sign: async ({ protectedHeader, payload }) => { + const sig = await signer.sign({ + protectedHeader, + payload + }) + return `${sig.protected}.${sig.payload}.${sig.signature}` + } + } +} + +export type AttachedVerifier = { + verify: (jws: string) => Promise +} + +export const verifier = async ({ publicKey }: RequestVerifier): Promise => { + const verifier = await detached.verifier({ publicKey }) + return { + verify: async (jws: string) => { + const [protectedHeader, payload, signature] = jws.split('.') + const result = await verifier.verify({ + protected: protectedHeader, payload, signature + }) + return result + } + } +} + + +const api = { signer, verifier } + +export default api \ No newline at end of file diff --git a/src/jose/detached.ts b/src/jose/detached.ts new file mode 100644 index 0000000..57b8925 --- /dev/null +++ b/src/jose/detached.ts @@ -0,0 +1,50 @@ +import * as jose from 'jose' + +import { getKey } from './getKey' + +// TODO Remote KMS. + +export type FlattenedJwsInput = { + protectedHeader: jose.ProtectedHeaderParameters + payload: Uint8Array +} + +export type RequestFlattenedJws = FlattenedJwsInput +export type VerifiedFlattenedJws = FlattenedJwsInput + +export type RequestSigner = { + privateKey: jose.KeyLike +} + +export const signer = async ({ privateKey }: RequestSigner) => { + const key = await getKey(privateKey) + return { + sign: async ({ protectedHeader, payload }: RequestFlattenedJws): Promise => { + const jws = await new jose.FlattenedSign(payload) + .setProtectedHeader(protectedHeader) + .sign(key) + return jws + }, + } +} + +export type RequestVerifier = { + publicKey: jose.KeyLike +} + +export const verifier = async ({ publicKey }: RequestVerifier) => { + const key = await getKey(publicKey) + return { + verify: async (jws: jose.FlattenedJWS): Promise => { + const { protectedHeader, payload } = await jose.flattenedVerify( + jws, + key, + ) + return { protectedHeader, payload } as VerifiedFlattenedJws + }, + } +} + +const api = { signer, verifier } + +export default api diff --git a/src/jose/getKey.ts b/src/jose/getKey.ts new file mode 100644 index 0000000..1165fee --- /dev/null +++ b/src/jose/getKey.ts @@ -0,0 +1,5 @@ +import { KeyLike, importJWK } from 'jose' + +export const getKey = async (data: any): Promise => { + return data.kty ? importJWK(data) : data; +}; \ No newline at end of file diff --git a/src/jose/index.ts b/src/jose/index.ts new file mode 100644 index 0000000..107a4e8 --- /dev/null +++ b/src/jose/index.ts @@ -0,0 +1,7 @@ +import attached from './attached' +import detached from './detached' +import { getKey } from './getKey' + +const api = { attached, detached, getKey } + +export default api \ No newline at end of file diff --git a/src/vc/attached.ts b/src/vc/attached.ts new file mode 100644 index 0000000..37cd358 --- /dev/null +++ b/src/vc/attached.ts @@ -0,0 +1,66 @@ +import { ProtectedHeaderParameters } from 'jose' +import { AttachedSigner } from '../jose/attached' +import { VerifiableCredentialClaimset } from './types' + +import attached from '../jose/attached' + +export type RequestAttachedIssuer = { + signer: AttachedSigner +} + +export type AttachedIssuer = { + signer: AttachedSigner +} + +export type VerifiedClaimset = { + protectedHeader: ProtectedHeaderParameters + claimset: VerifiableCredentialClaimset +} + +export type RequestAttachedVerifiableCredential = VerifiedClaimset + +export type AttachedVerifiableCredentialIssuer = { + issue: ({ protectedHeader, claimset }: RequestAttachedVerifiableCredential) => Promise +} + +const issuer = async ({ signer }: RequestAttachedIssuer): Promise => { + const encoder = new TextEncoder() + return { + issue: async ({ protectedHeader, claimset }) => { + const serialized = JSON.stringify(claimset) + const payload = encoder.encode(serialized) + return signer.sign({ + protectedHeader, payload + }) + } + } +} + + +export type RequestAttachedVerifier = { + issuer: (vc: string) => Promise +} + +export type VerifiableCredentialValidation = Record + +export type AttachedVerifiableCredentialVerifier = { + verify: (vc: string) => Promise +} + +const verifier = async ({ issuer }: RequestAttachedVerifier): Promise => { + const decoder = new TextDecoder() + return { + verify: async (vc) => { + const publicKey = await issuer(vc) + const verifier = await attached.verifier({ publicKey }) + const { protectedHeader, payload } = await verifier.verify(vc) + const decoded = decoder.decode(payload) + const deserialized = JSON.parse(decoded) + return { protectedHeader, claimset: deserialized } + } + } +} + +const api = { issuer, verifier } + +export default api \ No newline at end of file diff --git a/src/vc/credentialSchema.ts b/src/vc/credentialSchema.ts new file mode 100644 index 0000000..0bf034a --- /dev/null +++ b/src/vc/credentialSchema.ts @@ -0,0 +1,45 @@ +import { VerifiableCredentialClaimset } from "./types"; + +import Ajv from 'ajv' + +const ajv = new Ajv({ + strict: false, +}) + +export type JsonSchemaValidationErrors = any; +export type JsonSchema = any; +export type ResolveCredentialSchema = (id: string) => Promise + + +export type CredentialSchemaValidation = Record & { + valid: boolean +} + +const credentialSchema = async (claimset: VerifiableCredentialClaimset, resolve?: ResolveCredentialSchema) => { + let schemas: any = {} + let hasValidationError = false; + if (claimset.credentialSchema) { + if (!resolve) { + throw new Error("credentialSchema resolver required.") + } + const credentialSchemas = Array.isArray(claimset.credentialSchema) ? claimset.credentialSchema : [claimset.credentialSchema] + for (const cs of credentialSchemas) { + const schema = await resolve(cs.id) + const validate = ajv.compile(schema) + const valid = validate(claimset) + if (valid) { + schemas[cs.id] = schema + } else { + schemas[cs.id] = validate.errors + hasValidationError = true + } + } + } + return { valid: !hasValidationError, ...schemas } as CredentialSchemaValidation +} + +const credentialSchemaValidator = { + validate: credentialSchema +} + +export default credentialSchemaValidator \ No newline at end of file diff --git a/src/vc/index.ts b/src/vc/index.ts index e08b29a..069a6d4 100644 --- a/src/vc/index.ts +++ b/src/vc/index.ts @@ -1,3 +1,7 @@ -const vc = {} + +import attached from './attached' +import validator from './validator' + +const vc = { attached, validator } export default vc \ No newline at end of file diff --git a/src/vc/types.ts b/src/vc/types.ts new file mode 100644 index 0000000..e533e6e --- /dev/null +++ b/src/vc/types.ts @@ -0,0 +1,51 @@ + + +export type Issuer = string | Record & { + id: string +} + +export type Holder = string | Record & { + id: string +} + +export type CredentialSubject = Record & { + id?: string +} + +export type Type = string | string[] + +export type CredentialSchema = Record & { + id: string + type: string +} + +export type CredentialStatus = Record & { + id: string + type: string +} + +export type Proof = Record & { + id?: string + type?: string +} + +export type Context = string | string[] | Record[] + + +export type VerifiableCredentialRequiredClaims = { + '@context': Context + type: Type + issuer: Issuer, + credentialSubject: CredentialSubject | CredentialSubject[] +} + +export type VerifiableCredentialOptionalClaims = { + id?: string + validFrom?: string + validUntil?: string + credentialSchema?: CredentialSchema | CredentialSchema[] + credentialStatus?: CredentialStatus | CredentialStatus[] + proof?: Proof | Proof[] +} + +export type VerifiableCredentialClaimset = VerifiableCredentialRequiredClaims & VerifiableCredentialOptionalClaims & Record \ No newline at end of file diff --git a/src/vc/validator.ts b/src/vc/validator.ts new file mode 100644 index 0000000..4450b50 --- /dev/null +++ b/src/vc/validator.ts @@ -0,0 +1,37 @@ +import { base64url } from "jose" +import { VerifiedClaimset } from "./attached" +import credentialSchemaValidator, { CredentialSchemaValidation, ResolveCredentialSchema } from "./credentialSchema" + +export type RequestVerifiedCredentialValidator = { + issuer: (vc: string) => Promise + credentialSchema?: ResolveCredentialSchema +} + +export type CredentialValidation = { + issuer: any + credentialSchema?: CredentialSchemaValidation +} + +export type VerifiedCredentialValidator = { + validate: ({ protectedHeader, claimset }: VerifiedClaimset) => Promise +} + +const validator = async ({ issuer, credentialSchema }: RequestVerifiedCredentialValidator): Promise => { + return { + validate: async ({ protectedHeader, claimset }) => { + const publicKey = await issuer(`${base64url.encode(JSON.stringify(protectedHeader))}.${base64url.encode(JSON.stringify(claimset))}`) + let result = { + // TODO: consider issuer id. + issuer: publicKey + } as CredentialValidation + if (claimset.credentialSchema) { + result.credentialSchema = await credentialSchemaValidator.validate(claimset, credentialSchema) + } + return result + } + } +} + +const api = validator + +export default api \ No newline at end of file diff --git a/test/api.test.ts b/test/api.test.ts deleted file mode 100644 index b32592a..0000000 --- a/test/api.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import api from '../src' - -it('e2e', () => { - // -}) \ No newline at end of file diff --git a/test/mock.ts b/test/mock.ts new file mode 100644 index 0000000..0ae7061 --- /dev/null +++ b/test/mock.ts @@ -0,0 +1,81 @@ + + + +import yaml from 'js-yaml' + +const publicKey = { + kid: 'urn:ietf:params:oauth:jwk-thumbprint:sha-256:ydGzq9NKXcEdJ-kOIXoL1HgEOTwmnyk8h8DxgyWGpAE', + kty: 'EC', + crv: 'P-384', + alg: 'ES384', + x: '05UO-Dc-s7r-mX6KxHePF7zKWIM0iGrrnKQbEvdBuE804LmGNbIJUwL0uyoRkdK9', + y: 'HdIk9SXvulq3HaJG9-X_0AhwQi7HBhGnC3ty2Wpbolp4FlIrrUk7nrkGckgiVcAL', +} + +const privateKey = { + ...publicKey, + d: 'V_vSqbaQbws3edNLqNGMn_MwfdW9irsupfWZGd9gnW8EXsrL9s_6exIsmSDG9H7P' +} + + + +const claimset = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2", + ], + id: "https://contoso.example/credentials/35327255", + type: ["VerifiableCredential", "KYCExample"], + issuer: "did:web:contoso.example", + validFrom: "2019-05-25T03:10:16.992Z", + validUntil: "2027-05-25T03:10:16.992Z", + credentialSchema: { + id: "https://contoso.example/bafybeigdyr...lqabf3oclgtqy55fbzdi", + type: "JsonSchema", + }, + credentialSubject: { + id: "did:example:1231588", + type: "Person", + }, +}; + +const protectedHeader = { + alg: publicKey.alg, + kid: claimset.issuer + '#key-42' +} + +const credentialSchema = JSON.parse(JSON.stringify(yaml.load(` +$id: ${claimset.credentialSchema.id} +title: W3C Verifiable Credential +description: A JSON-LD Object of RDF type https://www.w3.org/2018/credentials#VerifiableCredential. +type: object +properties: + '@context': + type: array + readOnly: true + default: + - https://www.w3.org/ns/credentials/v2 + items: + - type: string + const: https://www.w3.org/ns/credentials/v2 + additionalItems: + type: string + enum: + - https://www.w3.org/ns/credentials/examples/v2 + `))) + + +const validator = { + issuer: async () => { + // this resolver must return application/jwk+json + return publicKey + }, + credentialSchema: async () => { + // this resolver MUST return application/schema+json + return credentialSchema + } +} + +const mock = { publicKey, privateKey, protectedHeader, claimset, credentialSchema, validator } + +export default mock \ No newline at end of file diff --git a/test/vc.validation.test.ts b/test/vc.validation.test.ts new file mode 100644 index 0000000..e05afb5 --- /dev/null +++ b/test/vc.validation.test.ts @@ -0,0 +1,63 @@ + +import api from '../src' +import mock from './mock' + +describe('validation', () => { + it('must be able to resolve issuer public key', async () => { + expect.assertions(1) + const validator = await api.vc.validator({ + issuer: async () => { + throw new Error('Untrusted issuer') + } + }) + try { + await validator.validate({ + protectedHeader: mock.protectedHeader, + claimset: mock.claimset + }) + } catch (e) { + expect((e as Error).message).toBe('Untrusted issuer') + } + }) + it('must be able to resolve schema when present', async () => { + expect.assertions(1) + const validator = await api.vc.validator({ + issuer: async () => { + return mock.publicKey + } + }) + try { + await validator.validate({ + protectedHeader: mock.protectedHeader, + claimset: mock.claimset + }) + } catch (e) { + expect((e as Error).message).toBe('credentialSchema resolver required.') + } + }) + it('credentialSchema', async () => { + expect.assertions(1) + const validator = await api.vc.validator(mock.validator) + const validation = await validator.validate({ + protectedHeader: mock.protectedHeader, + claimset: mock.claimset + }) + if (validation.credentialSchema) { + expect(validation.credentialSchema.valid).toBe(true) + } + }) + + it('credentialStatus', async () => { + expect.assertions(1) + const validator = await api.vc.validator(mock.validator) + const validation = await validator.validate({ + protectedHeader: mock.protectedHeader, + claimset: mock.claimset + }) + if (validation.credentialSchema) { + expect(validation.credentialSchema.valid).toBe(true) + } + }) + +}) + diff --git a/test/vc.verification.test.ts b/test/vc.verification.test.ts new file mode 100644 index 0000000..fa2d7b9 --- /dev/null +++ b/test/vc.verification.test.ts @@ -0,0 +1,38 @@ +import { decodeJwt, decodeProtectedHeader } from "jose" +import api, { VerifiableCredentialClaimset } from '../src' +import mock from './mock' + +it('e2e', async () => { + const privateKey = await api.controller.key.createPrivateKey({ alg: 'ES384' }) + const publicKey = api.controller.key.publicFromPrivate(privateKey) + const signer = await api.controller.key.attached.signer({ + privateKey + }) + const issuer = await api.vc.attached.issuer({ + signer + }) + const protectedHeader = { + alg: publicKey.alg, + kid: mock.claimset.issuer + '#key-42' + } + const vc = await issuer.issue({ + protectedHeader, + claimset: mock.claimset + }) + const verifier = await api.vc.attached.verifier({ + issuer: async (vc: string) => { + // the entire vc is a hint for the verifier to discover the issuer's public keys. + const protectedHeader = decodeProtectedHeader(vc) + const claimset = decodeJwt(vc) as VerifiableCredentialClaimset + const isIssuerKid = protectedHeader.kid?.startsWith(`${claimset.issuer}`) + if (isIssuerKid) { + // return application/jwk+json + return publicKey + } + throw new Error('Untrusted issuer.') + } + }) + const verified = await verifier.verify(vc) + expect(verified.claimset).toEqual(mock.claimset) + expect(verified.protectedHeader).toEqual(protectedHeader) +}) \ No newline at end of file