diff --git a/package-lock.json b/package-lock.json index 154ffd081..1d01e1dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "dependencies": { "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.2.0", + "axios": "^1.7.7", "cross-fetch": "^4.0.0", "crypto-js": "^4.1.1", "decimal.js": "^10.4.1", - "ethers": "^5.7.2" + "ethers": "^5.7.2", + "jose": "^5.9.6" }, "devDependencies": { "@truffle/hdwallet-provider": "^2.0.14", @@ -4499,6 +4501,17 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -8667,6 +8680,26 @@ "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -8696,6 +8729,20 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz", @@ -10798,6 +10845,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -14037,8 +14093,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/prr": { "version": "1.0.1", @@ -21018,6 +21073,16 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, + "axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -24274,6 +24339,11 @@ "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", "dev": true }, + "follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -24297,6 +24367,16 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, + "form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "form-data-encoder": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.1.tgz", @@ -25811,6 +25891,11 @@ } } }, + "jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" + }, "js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -28230,8 +28315,7 @@ "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "prr": { "version": "1.0.1", diff --git a/package.json b/package.json index 283e17ba3..85b301ca8 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,12 @@ "dependencies": { "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.2.0", + "axios": "^1.7.7", "cross-fetch": "^4.0.0", "crypto-js": "^4.1.1", "decimal.js": "^10.4.1", - "ethers": "^5.7.2" + "ethers": "^5.7.2", + "jose": "^5.9.6" }, "devDependencies": { "@truffle/hdwallet-provider": "^2.0.14", diff --git a/src/@types/IssuerSignature.ts b/src/@types/IssuerSignature.ts new file mode 100644 index 000000000..36b50a046 --- /dev/null +++ b/src/@types/IssuerSignature.ts @@ -0,0 +1,51 @@ +/** + * Represents a JSON Web Token (JWT) used in cryptographic operations. + */ +export interface JWT { + kty: string // Key type (e.g., 'EC' for Elliptic Curve) + d: string // Private key (base64url encoded) + crv: string // Cryptographic curve (e.g., 'secp256k1') + kid: string // Key ID + x: string // X-coordinate of the public key (base64url encoded) +} + +/** + * Represents a key used by an issuer to sign credentials. + */ +export interface IssuerKey { + type: string // Type of the key (e.g., 'JWK') + jwk: JWT // The JSON Web Token associated with the issuer's key +} + +/** + * Represents the result of signing a credential. + */ +export interface SignedCredential { + jws: string // JSON Web Signature (JWS) of the credential + header: Record // Protected header used in the JWS + issuer: string // DID or public key of the issuer +} + +/** + * Represents the common properties of a JSON Web Key (JWK). + */ +interface BaseJWK { + kty: string // Key type (e.g., 'EC' for Elliptic Curve) + crv: string // Cryptographic curve (e.g., 'secp256k1') + x: string // X-coordinate of the public key (base64url encoded) + y: string // Y-coordinate of the public key (base64url encoded) + alg: string // Algorithm used (e.g., 'ES256K') + use: string // Intended use of the key (e.g., 'sig' for signing) +} + +/** + * Represents a JSON Web Key (JWK) for private signing operations. + */ +export interface IssuerKeyJWK extends BaseJWK { + d: string // Private key (base64url encoded) +} + +/** + * Represents a JSON Web Key (JWK) for public verification operations. + */ +export interface IssuerPublicKeyJWK extends BaseJWK {} diff --git a/src/utils/SignDDO.ts b/src/utils/SignDDO.ts new file mode 100644 index 000000000..e0247093e --- /dev/null +++ b/src/utils/SignDDO.ts @@ -0,0 +1,90 @@ +import { importJWK, JWTPayload, jwtVerify, SignJWT } from 'jose' +import axios from 'axios' +import { + IssuerKey, + IssuerKeyJWK, + IssuerPublicKeyJWK, + SignedCredential +} from '../@types/IssuerSignature' + +/** + * Signs a verifiable credential using Walt.id's issuer API. + * @param {any} verifiableCredential - The verifiable credential to sign. + * @param {string} waltIdIssuerApi - URL of the Walt.id issuer API. + * @param {string} issuerDid - DID of the issuer. + * @param {IssuerKey} issuerKey - Issuer's key for signing. + * @returns {Promise} - The signed credential's JWS, header, and issuer information. + * @throws {Error} If the signing process fails. + */ +export async function signCredentialWithWaltId( + verifiableCredential: any, + waltIdIssuerApi: string, + issuerDid: string, + issuerKey: IssuerKey +): Promise { + try { + const response = await axios.post(waltIdIssuerApi, { + credentialData: verifiableCredential, + issuerDid, + issuerKey, + subjectDid: verifiableCredential.credentialSubject.id + }) + const jws = response.data + const header = { alg: issuerKey.jwk.kty } + return { jws, header, issuer: issuerDid } + } catch (error) { + console.error('Error signing credential with WaltId:', error) + throw error + } +} + +/** + * Signs a verifiable credential locally using a private key. + * @param {any} verifiableCredential - The verifiable credential to sign. + * @param {IssuerKeyJWK} issuerKeyJWK - the JWK from private key. + * @param {string} publicKeyHex - the public key + * @returns {Promise} - The signed credential's JWS, header, and issuer information. + * @throws {Error} If the signing process fails. + */ +export async function signCredential( + verifiableCredential: any, + issuerKeyJWK: IssuerKeyJWK, + publicKeyHex: string +): Promise { + try { + const key = await importJWK(issuerKeyJWK, issuerKeyJWK.alg) + + const jws = await new SignJWT(verifiableCredential as unknown as JWTPayload) + .setProtectedHeader({ alg: issuerKeyJWK.alg }) + .setIssuedAt() + .setIssuer(publicKeyHex) + .sign(key) + const header = { alg: issuerKeyJWK.alg } + + return { jws, header, issuer: publicKeyHex } + } catch (error) { + console.error('Error signing credential:', error) + throw error + } +} + +/** + * Verifies a verifiable credential's JWS using the issuer's public key. + * @param {string} jws - The JSON Web Signature (JWS) to verify. + * @param {IssuerPublicKeyJWK} issuerPublicKeyJWK - The public key JWK of the issuer. + * @returns {Promise} - The verified payload of the credential. + * @throws {Error} If the verification fails. + */ +export async function verifyCredential( + jws: string, + issuerPublicKeyJWK: IssuerPublicKeyJWK +): Promise { + const key = await importJWK(issuerPublicKeyJWK, 'ES256K') + try { + const { payload } = await jwtVerify(jws, key) + return payload + } catch (error) { + console.error('Verification failed:', error) + throw error + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 55da7cf7e..8b6c8adec 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,3 +11,4 @@ export * from './TokenUtils' export * from './ProviderErrors' export * from './OrderUtils' export * from './Assets' +export * from './SignDDO' diff --git a/test/unit/SignDDO.test.ts b/test/unit/SignDDO.test.ts new file mode 100644 index 000000000..6248939c5 --- /dev/null +++ b/test/unit/SignDDO.test.ts @@ -0,0 +1,133 @@ +import { assert } from 'chai' +import { ethers } from 'ethers' +import { base64url } from 'jose' +import { signCredential, verifyCredential } from '../../src/utils' +import { IssuerKeyJWK, IssuerPublicKeyJWK } from '../../src/@types/IssuerSignature' + +const mockVerifiableCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential'], + credentialSubject: { id: 'did:example:123' }, + issuer: 'did:example:issuer', + issuanceDate: '2023-01-01T00:00:00Z' +} + +describe('Credential Signing and Verification Functions', () => { + describe('Sign and verify credential', () => { + it('should verify the signed credential with the correct public key', async () => { + const privateKey = + '0xc494c6e5def4bab63ac29eed19a134c130355f74f019bc74b8f4389df2837a57' + const wallet = new ethers.Wallet(privateKey) + const privateKeyBuffer = Buffer.from(privateKey.substring(2), 'hex') + const publicKeyHex = wallet._signingKey().publicKey + const publicKeyBuffer = Buffer.from(publicKeyHex.substring(2), 'hex') + const xBuffer = publicKeyBuffer.slice(1, 33) + const yBuffer = publicKeyBuffer.slice(33, 65) + const d = base64url.encode(privateKeyBuffer as any as Uint8Array) + const x = base64url.encode(xBuffer as any as Uint8Array) + const y = base64url.encode(yBuffer as any as Uint8Array) + + const privateJwk: IssuerKeyJWK = { + kty: 'EC', + crv: 'secp256k1', + d, + x, + y, + alg: 'ES256K', + use: 'sig' + } + + const { jws } = await signCredential( + mockVerifiableCredential, + privateJwk, + publicKeyHex + ) + + const publicJwk = { + kty: 'EC', + crv: 'secp256k1', + x, + y, + alg: 'ES256K', + use: 'sig' + } + + const payload = await verifyCredential(jws, publicJwk) + assert.deepEqual( + { + type: payload.type, + credentialSubject: payload.credentialSubject, + issuer: payload.issuer, + issuanceDate: payload.issuanceDate + }, + { + type: mockVerifiableCredential.type, + credentialSubject: mockVerifiableCredential.credentialSubject, + issuer: mockVerifiableCredential.issuer, + issuanceDate: mockVerifiableCredential.issuanceDate + }, + 'Payload should match the original credential' + ) + }) + + it('should throw an error if verification fails due to an invalid signature', async () => { + const privateKey = + '0xc494c6e5def4bab63ac29eed19a134c130355f74f019bc74b8f4389df2837a57' + const invalidPublicKey = + '0x0491d20394c7c2b191c6db3a3a9e7eac21d9c6741dcf66010e0a743530d8c1b05656fb9b555ebc4162df5d1cf3e372a4e0230205932c27fcd998bdbe26399236f9' + + const wallet = new ethers.Wallet(privateKey) + const { publicKey } = wallet._signingKey() + const privateKeyBuffer = Buffer.from(privateKey.substring(2), 'hex') + const publicKeyHex = wallet._signingKey().publicKey + const publicKeyBuffer = Buffer.from(publicKeyHex.substring(2), 'hex') + const xBuffer = publicKeyBuffer.slice(1, 33) + const yBuffer = publicKeyBuffer.slice(33, 65) + const d = base64url.encode(privateKeyBuffer as any as Uint8Array) + const x = base64url.encode(xBuffer as any as Uint8Array) + const y = base64url.encode(yBuffer as any as Uint8Array) + + const privateJwk: IssuerKeyJWK = { + kty: 'EC', + crv: 'secp256k1', + d, + x, + y, + alg: 'ES256K', + use: 'sig' + } + + const { jws } = await signCredential( + mockVerifiableCredential, + privateJwk, + publicKey + ) + + const invalidPublicKeyBuffer = Buffer.from(invalidPublicKey.substring(2), 'hex') + const invalidXBuffer = invalidPublicKeyBuffer.slice(1, 33) + const invalidYBuffer = invalidPublicKeyBuffer.slice(33, 65) + + const invalidX = base64url.encode(invalidXBuffer as any as Uint8Array) + const invalidY = base64url.encode(invalidYBuffer as any as Uint8Array) + const publicJwk: IssuerPublicKeyJWK = { + kty: 'EC', + crv: 'secp256k1', + x: invalidX, + y: invalidY, + alg: 'ES256K', + use: 'sig' + } + + try { + await verifyCredential(jws, publicJwk) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.include( + error.message, + 'Invalid JWK EC key', + 'Error should indicate failed verification' + ) + } + }) + }) +})