diff --git a/package-lock.json b/package-lock.json index 4f8fc32..673ba34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "jose": "^6.0.10", "jsonwebtoken": "^9.0.2", "uuid": "^9.0.1" }, "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", @@ -1377,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", @@ -3891,6 +3911,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..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", @@ -34,6 +35,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "jose": "^6.0.10", "jsonwebtoken": "^9.0.2", "uuid": "^9.0.1" } diff --git a/src/types.ts b/src/types.ts index 1741d8a..b60847a 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,27 @@ export interface Analysis { extra?: Record; } +/** + * 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; +} + export interface VconData { uuid?: string; vcon?: string; @@ -87,8 +108,24 @@ export interface VconData { attachments?: Attachment[]; analysis?: Analysis[]; tags?: Record; + + /** + * Original signature property - kept for backward compatibility + */ signature?: { alg: string; signature: string; }; -} \ No newline at end of file + + /** + * 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 da5a3d0..638043b 100644 --- a/src/vcon.ts +++ b/src/vcon.ts @@ -3,9 +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 crypto from 'crypto'; export class Vcon { - private data: VconData; + data: VconData; constructor(vconDict: Partial = {}) { this.data = { @@ -82,10 +83,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 +130,191 @@ 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). + * + * 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); + * ``` + */ + 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(); + + // Convert private key to PEM format if it's a KeyObject + const privateKey = typeof privateKeyInput === 'string' + ? privateKeyInput + : privateKeyInput.export({ type: 'pkcs8', format: 'pem' }).toString(); + + // Create a header for JWS + const header = { + alg: 'RS256', + typ: 'JWS' + }; + + // Create base64url encoded versions + const headerBase64 = this.base64UrlEncode(JSON.stringify(header)); + const payloadBase64 = this.base64UrlEncode(payload); + + // Create the signature input + const signatureInput = `${headerBase64}.${payloadBase64}`; + + // 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: 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 (!keysToKeep.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(); + * vcon.sign(privateKey); + * const isValid = vcon.verify(publicKey); + * console.log(isValid); // Prints true + * ``` + */ + 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"); + } + + try { + console.log("Verifying vCon signature"); + + // Extract components + const { protected: protectedHeader, signature } = this.data.signatures[0]; + const payload = this.data.payload; + + // Convert public key to appropriate format + const publicKey = typeof publicKeyInput === 'string' + ? publicKeyInput + : publicKeyInput.export({ type: 'spki', format: 'pem' }).toString(); + + // Create signature input + const signatureInput = `${protectedHeader}.${payload}`; + + // Convert base64url signature to base64 + const signatureBase64 = signature + .replace(/-/g, '+') + .replace(/_/g, '/'); + + // Verify the signature + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(signatureInput); + const isValid = verifier.verify(publicKey, signatureBase64, 'base64'); + + console.log("Signature verification result:", isValid); + return isValid; + } 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(); + * vcon.sign(privateKey); + * const isValid = 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 +366,4 @@ export class Vcon { get meta(): Record | undefined { return this.data.meta; } -} \ No newline at end of file +} \ No newline at end of file