From 6a041b7b66bdc0665cbaf79e14158c5aea6cc1aa Mon Sep 17 00:00:00 2001 From: benjigifford Date: Wed, 16 Apr 2025 11:06:47 -0400 Subject: [PATCH 1/3] Adding signing and validation --- src/types.ts | 17 +++++- src/vcon.ts | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 1741d8a..47fe2a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type Encoding = 'base64' | 'base64url' | 'none'; +export type Encoding = 'base64' | 'base64url' | 'json' | 'none'; export interface Attachment { type: string; @@ -72,6 +72,13 @@ export interface Analysis { extra?: Record; } +// New interface for JWS signature components +export interface Signature { + protected: string; + signature: string; + header?: Record; +} + export interface VconData { uuid?: string; vcon?: string; @@ -87,8 +94,14 @@ export interface VconData { attachments?: Attachment[]; analysis?: Analysis[]; tags?: Record; + + // Original signature property signature?: { alg: string; signature: string; }; -} \ No newline at end of file + + // New JWS signature properties + signatures?: Signature[]; + payload?: string; +} \ No newline at end of file diff --git a/src/vcon.ts b/src/vcon.ts index da5a3d0..266b245 100644 --- a/src/vcon.ts +++ b/src/vcon.ts @@ -3,6 +3,8 @@ import { VconData, Attachment, Party, Dialog, Analysis, Encoding } from './types import { Attachment as AttachmentClass } from './attachment'; import { Party as PartyClass } from './party'; import { Dialog as DialogClass } from './dialog'; +import * as jose from 'jose'; +import * as crypto from 'crypto'; export class Vcon { private data: VconData; @@ -82,10 +84,13 @@ export class Vcon { dialog: params.dialog, vendor: params.vendor, body: params.body, - encoding: params.encoding || 'none', - extra: params.extra || {} + encoding: params.encoding || 'none' }; + if (params.extra) { + analysis.extra = params.extra; + } + if (!this.data.analysis) { this.data.analysis = []; } @@ -126,6 +131,164 @@ export class Vcon { return { ...this.data }; } + /** + * Sign the vCon using JWS (JSON Web Signature). + * + * This method signs the vCon using the provided private key, adding the signature + * information to the vCon. The signature can later be verified using the + * corresponding public key. + * + * @param privateKey - The RSA private key in PEM format or as a crypto.KeyObject + * @throws Error - If there is an error during the signing process + * + * @example + * ```typescript + * import * as crypto from 'crypto'; + * const { privateKey } = crypto.generateKeyPairSync('rsa', { + * modulusLength: 2048, + * publicKeyEncoding: { type: 'spki', format: 'pem' }, + * privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + * }); + * const vcon = Vcon.buildNew(); + * vcon.sign(privateKey); + * ``` + */ + async sign(privateKey: string | crypto.KeyObject): Promise { + try { + console.log("Signing vCon with JWS"); + + // Convert the vCon to a JSON string for signing + const payload = this.toJson(); + + // Create the protected header + const protectedHeader = { alg: 'RS256', typ: 'JWS' }; + + // Convert private key to key object if it's a string + let keyObject: crypto.KeyObject; + if (typeof privateKey === 'string') { + keyObject = crypto.createPrivateKey(privateKey); + } else { + keyObject = privateKey; + } + + // Create a JWS signer + const privateJwk = await jose.exportJWK(keyObject); + + // Sign the payload + const jws = await new jose.CompactSign( + new TextEncoder().encode(payload) + ) + .setProtectedHeader(protectedHeader) + .sign(keyObject); + + // Split the compact JWS into its components + const [header, payloadB64, signature] = jws.split('.'); + + // Update the vCon with the signature information + this.data.signatures = [{ protected: header, signature }]; + this.data.payload = payloadB64; + + // Remove the original vCon properties that are now in the payload + // to match the signed vCon format + Object.keys(this.data).forEach(key => { + if (!['signatures', 'payload'].includes(key)) { + delete this.data[key as keyof VconData]; + } + }); + + console.log("Successfully signed vCon"); + } catch (error) { + console.error("Failed to sign vCon:", error); + throw error; + } + } + + /** + * Verify the JWS signature of the vCon. + * + * This method verifies the vCon's signature using the provided public key. + * The vCon must have been previously signed using the corresponding private key. + * + * @param publicKey - The RSA public key in PEM format or as a crypto.KeyObject + * @returns true if the signature is valid, false otherwise + * @throws Error - If the vCon is not signed + * + * @example + * ```typescript + * import * as crypto from 'crypto'; + * const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + * modulusLength: 2048, + * publicKeyEncoding: { type: 'spki', format: 'pem' }, + * privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + * }); + * const vcon = Vcon.buildNew(); + * await vcon.sign(privateKey); + * const isValid = await vcon.verify(publicKey); + * console.log(isValid); // Prints true + * ``` + */ + async verify(publicKey: string | crypto.KeyObject): Promise { + if (!this.data.signatures || !this.data.payload) { + console.error("Cannot verify: vCon is not signed"); + throw new Error("vCon is not signed"); + } + + try { + console.log("Verifying vCon signature"); + + // Reconstruct the compact JWS + const { protected: protectedHeader, signature } = this.data.signatures[0]; + const compactJws = `${protectedHeader}.${this.data.payload}.${signature}`; + + // Convert public key to key object if it's a string + let keyObject: crypto.KeyObject; + if (typeof publicKey === 'string') { + keyObject = crypto.createPublicKey(publicKey); + } else { + keyObject = publicKey; + } + + // Verify the signature + await jose.compactVerify(compactJws, keyObject); + + console.log("Successfully verified vCon signature"); + return true; + } catch (error) { + console.warn("Invalid signature detected:", error); + return false; + } + } + + /** + * Generate a new RSA key pair for signing vCons. + * + * This method generates a new RSA key pair that can be used for signing + * and verifying vCons. + * + * @returns A tuple containing the private key and public key as PEM strings + * + * @example + * ```typescript + * const [privateKey, publicKey] = Vcon.generateKeyPair(); + * const vcon = Vcon.buildNew(); + * await vcon.sign(privateKey); + * const isValid = await vcon.verify(publicKey); + * console.log(isValid); // Prints true + * ``` + */ + static generateKeyPair(): [string, string] { + console.log("Generating new RSA key pair"); + + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }); + + console.log("Successfully generated RSA key pair"); + return [privateKey, publicKey]; + } + get parties(): Party[] { return this.data.parties || []; } @@ -177,4 +340,4 @@ export class Vcon { get meta(): Record | undefined { return this.data.meta; } -} \ No newline at end of file +} \ No newline at end of file From 7dcb575844dda15bd55cf880a0146cd14001c642 Mon Sep 17 00:00:00 2001 From: benjigifford Date: Wed, 16 Apr 2025 16:30:14 -0400 Subject: [PATCH 2/3] Install jose (the devil's library) --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4f8fc32..6b22255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "jose": "^6.0.10", "jsonwebtoken": "^9.0.2", "uuid": "^9.0.1" }, @@ -3891,6 +3892,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz", + "integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index a450b75..f1556f1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "jose": "^6.0.10", "jsonwebtoken": "^9.0.2", "uuid": "^9.0.1" } From a8adacea0a05d3c12fc1255206db927a7307deac Mon Sep 17 00:00:00 2001 From: benjigifford Date: Wed, 16 Apr 2025 17:42:00 -0400 Subject: [PATCH 3/3] Removed jose --- package-lock.json | 19 ++++++++ package.json | 1 + src/types.ts | 30 +++++++++++-- src/vcon.ts | 110 ++++++++++++++++++++++++++++------------------ 4 files changed, 115 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b22255..673ba34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.11.24", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.1.0", @@ -1378,6 +1379,24 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", diff --git a/package.json b/package.json index f1556f1..b2f50c0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.11.24", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.1.0", diff --git a/src/types.ts b/src/types.ts index 47fe2a3..b60847a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,10 +72,24 @@ export interface Analysis { extra?: Record; } -// New interface for JWS signature components +/** + * Interface for JWS signature components according to the vCon specification. + * Used in the signatures array of a signed vCon. + */ export interface Signature { + /** + * The protected header in base64url encoding + */ protected: string; + + /** + * The JWS signature in base64url encoding + */ signature: string; + + /** + * Optional unprotected header + */ header?: Record; } @@ -95,13 +109,23 @@ export interface VconData { analysis?: Analysis[]; tags?: Record; - // Original signature property + /** + * Original signature property - kept for backward compatibility + */ signature?: { alg: string; signature: string; }; - // New JWS signature properties + /** + * JWS signature array according to the JWS JSON Serialization + * Added when a vCon is signed using the sign() method + */ signatures?: Signature[]; + + /** + * Base64url encoded payload containing the original vCon data + * Added when a vCon is signed using the sign() method + */ payload?: string; } \ No newline at end of file diff --git a/src/vcon.ts b/src/vcon.ts index 266b245..638043b 100644 --- a/src/vcon.ts +++ b/src/vcon.ts @@ -3,11 +3,10 @@ import { VconData, Attachment, Party, Dialog, Analysis, Encoding } from './types import { Attachment as AttachmentClass } from './attachment'; import { Party as PartyClass } from './party'; import { Dialog as DialogClass } from './dialog'; -import * as jose from 'jose'; import * as crypto from 'crypto'; export class Vcon { - private data: VconData; + data: VconData; constructor(vconDict: Partial = {}) { this.data = { @@ -131,6 +130,20 @@ export class Vcon { return { ...this.data }; } + /** + * Helper method to encode a string in base64url format + * + * @param input - The string to encode + * @returns The base64url encoded string + */ + private base64UrlEncode(input: string): string { + return Buffer.from(input) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + /** * Sign the vCon using JWS (JSON Web Signature). * @@ -153,45 +166,51 @@ export class Vcon { * vcon.sign(privateKey); * ``` */ - async sign(privateKey: string | crypto.KeyObject): Promise { + sign(privateKeyInput: string | crypto.KeyObject): void { try { console.log("Signing vCon with JWS"); // Convert the vCon to a JSON string for signing const payload = this.toJson(); - // Create the protected header - const protectedHeader = { alg: 'RS256', typ: 'JWS' }; + // Convert private key to PEM format if it's a KeyObject + const privateKey = typeof privateKeyInput === 'string' + ? privateKeyInput + : privateKeyInput.export({ type: 'pkcs8', format: 'pem' }).toString(); - // Convert private key to key object if it's a string - let keyObject: crypto.KeyObject; - if (typeof privateKey === 'string') { - keyObject = crypto.createPrivateKey(privateKey); - } else { - keyObject = privateKey; - } + // Create a header for JWS + const header = { + alg: 'RS256', + typ: 'JWS' + }; - // Create a JWS signer - const privateJwk = await jose.exportJWK(keyObject); + // Create base64url encoded versions + const headerBase64 = this.base64UrlEncode(JSON.stringify(header)); + const payloadBase64 = this.base64UrlEncode(payload); - // Sign the payload - const jws = await new jose.CompactSign( - new TextEncoder().encode(payload) - ) - .setProtectedHeader(protectedHeader) - .sign(keyObject); + // Create the signature input + const signatureInput = `${headerBase64}.${payloadBase64}`; - // Split the compact JWS into its components - const [header, payloadB64, signature] = jws.split('.'); + // Create signature + const signer = crypto.createSign('RSA-SHA256'); + signer.update(signatureInput); + const signature = signer.sign(privateKey, 'base64'); + + // Convert to base64url format + const signatureBase64Url = signature + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); // Update the vCon with the signature information - this.data.signatures = [{ protected: header, signature }]; - this.data.payload = payloadB64; + this.data.signatures = [{ protected: headerBase64, signature: signatureBase64Url }]; + this.data.payload = payloadBase64; // Remove the original vCon properties that are now in the payload // to match the signed vCon format + const keysToKeep = ['signatures', 'payload']; Object.keys(this.data).forEach(key => { - if (!['signatures', 'payload'].includes(key)) { + if (!keysToKeep.includes(key)) { delete this.data[key as keyof VconData]; } }); @@ -222,12 +241,12 @@ export class Vcon { * privateKeyEncoding: { type: 'pkcs8', format: 'pem' } * }); * const vcon = Vcon.buildNew(); - * await vcon.sign(privateKey); - * const isValid = await vcon.verify(publicKey); + * vcon.sign(privateKey); + * const isValid = vcon.verify(publicKey); * console.log(isValid); // Prints true * ``` */ - async verify(publicKey: string | crypto.KeyObject): Promise { + verify(publicKeyInput: string | crypto.KeyObject): boolean { if (!this.data.signatures || !this.data.payload) { console.error("Cannot verify: vCon is not signed"); throw new Error("vCon is not signed"); @@ -236,23 +255,30 @@ export class Vcon { try { console.log("Verifying vCon signature"); - // Reconstruct the compact JWS + // Extract components const { protected: protectedHeader, signature } = this.data.signatures[0]; - const compactJws = `${protectedHeader}.${this.data.payload}.${signature}`; + const payload = this.data.payload; + + // Convert public key to appropriate format + const publicKey = typeof publicKeyInput === 'string' + ? publicKeyInput + : publicKeyInput.export({ type: 'spki', format: 'pem' }).toString(); - // Convert public key to key object if it's a string - let keyObject: crypto.KeyObject; - if (typeof publicKey === 'string') { - keyObject = crypto.createPublicKey(publicKey); - } else { - keyObject = publicKey; - } + // Create signature input + const signatureInput = `${protectedHeader}.${payload}`; + + // Convert base64url signature to base64 + const signatureBase64 = signature + .replace(/-/g, '+') + .replace(/_/g, '/'); // Verify the signature - await jose.compactVerify(compactJws, keyObject); + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(signatureInput); + const isValid = verifier.verify(publicKey, signatureBase64, 'base64'); - console.log("Successfully verified vCon signature"); - return true; + console.log("Signature verification result:", isValid); + return isValid; } catch (error) { console.warn("Invalid signature detected:", error); return false; @@ -271,8 +297,8 @@ export class Vcon { * ```typescript * const [privateKey, publicKey] = Vcon.generateKeyPair(); * const vcon = Vcon.buildNew(); - * await vcon.sign(privateKey); - * const isValid = await vcon.verify(publicKey); + * vcon.sign(privateKey); + * const isValid = vcon.verify(publicKey); * console.log(isValid); // Prints true * ``` */