diff --git a/packages/crypto/src/jose.ts b/packages/crypto/src/jose.ts index e80a949f5..32b6e6289 100644 --- a/packages/crypto/src/jose.ts +++ b/packages/crypto/src/jose.ts @@ -1,5 +1,5 @@ import { sha256 } from '@noble/hashes/sha256'; -import { Convert, Multicodec, MulticodecCode, removeUndefinedProperties } from '@web5/common'; +import { Convert, Multicodec, MulticodecCode, MulticodecDefinition, removeUndefinedProperties } from '@web5/common'; import type { Web5Crypto } from './types/web5-crypto.js'; @@ -507,7 +507,7 @@ export class Jose { public static async joseToMulticodec(options: { key: JsonWebKey - }): Promise<{ code?: MulticodecCode, name?: string }> { + }): Promise> { const jsonWebKey = options.key; const params: string[] = []; @@ -610,7 +610,7 @@ export class Jose { * const thumbprint = jwkThumbprint(jwk); * console.log(`JWK thumbprint: ${thumbprint}`); * - * @see {@link https://datatracker.ietf.org/doc/html/rfc7638|RFC 7638} for + * @see {@link https://datatracker.ietf.org/doc/html/rfc7638 | RFC7638} for * the specification of JWK thumbprint computation. */ public static async jwkThumbprint(options: { @@ -863,7 +863,7 @@ export class Jose { const jose = multicodecToJoseMapping[lookupKey]; if (jose === undefined) { - throw new Error(`Unsupported Multicodec to JOSE conversion: '${options.name ?? options.code}'`); + throw new Error(`Unsupported Multicodec to JOSE conversion: '${options.name}'`); } return { ...jose }; diff --git a/packages/crypto/tests/fixtures/test-vectors/jose.ts b/packages/crypto/tests/fixtures/test-vectors/jose.ts index 0aa31497e..7aeeca461 100644 --- a/packages/crypto/tests/fixtures/test-vectors/jose.ts +++ b/packages/crypto/tests/fixtures/test-vectors/jose.ts @@ -312,6 +312,66 @@ export const joseToWebCryptoTestVectors = [ }, ]; +export const joseToMulticodecTestVectors = [ + { + output : { code: 237, name: 'ed25519-pub' }, + input : { + alg : 'EdDSA', + crv : 'Ed25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks', + }, + }, + { + output : { code: 4864, name: 'ed25519-priv' }, + input : { + d : '', + alg : 'EdDSA', + crv : 'Ed25519', + kty : 'OKP', + x : 'c5UR1q2r1lOT_ygDhSkU3paf5Bmukg-jX-1t4kIKJvA', + }, + }, + { + output : { code: 231, name: 'secp256k1-pub' }, + input : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + }, + }, + { + output : { code: 4865, name: 'secp256k1-priv' }, + input : { + d : '', + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + }, + }, + { + output : { code: 236, name: 'x25519-pub' }, + input : { + crv : 'X25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks', + }, + }, + { + output : { code: 4866, name: 'x25519-priv' }, + input : { + d : '', + crv : 'X25519', + kty : 'OKP', + x : 'MBZd77wAy5932AEP7MHXOevv_MLzzD9OP_fZAOlnIWM', + }, + }, +]; + export const keyToJwkTestVectorsKeyMaterial = '72e63e7c4bbf575b386fc1db1b3cbff5539a36dc6250fccb9fa28e013773d24b'; export const keyToJwkMulticodecTestVectors = [ { @@ -372,6 +432,7 @@ export const keyToJwkMulticodecTestVectors = [ } } ]; + export const keyToJwkWebCryptoTestVectors = [ { input : { namedCurve: 'Ed25519', name: 'EdDSA' }, @@ -382,6 +443,16 @@ export const keyToJwkWebCryptoTestVectors = [ x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' } }, + { + input : { namedCurve: 'secp256k1', name: 'ECDSA' }, + output : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + } + }, { input : { namedCurve: 'X25519', name: 'ECDH' }, output : { @@ -425,4 +496,256 @@ export const keyToJwkWebCryptoTestVectors = [ k : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' } } -]; \ No newline at end of file +]; + +export const keyToJwkWebCryptoWithNullKTYTestVectors = [ + { + input : { namedCurve: 'Ed25519', name: 'EdDSA' }, + output : { + alg : 'EdDSA', + crv : 'Ed25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' + } + }, + { + input : { namedCurve: 'secp256k1', name: 'ECDSA' }, + output : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + } + }, + { + input : { namedCurve: 'X25519', name: 'ECDH' }, + output : { + crv : 'X25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' + } + }, + { + input : { namedCurve: 'secp256k1', name: 'ECDSA' }, + output : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA' + } + }, + { + input : { namedCurve: 'secp256k1', name: 'ECDH' }, + output : { + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA' + } + }, + { + input : { name: 'AES-CBC', length: 128 }, + output : { + alg : 'A128CBC', + kty : null, + } + }, + { + input : { name: 'HMAC', hash: { name: 'SHA-256' } }, + output : { + alg : 'HS256', + kty : null, + } + } +]; + +export const jwkToKeyTestVectors = [ + { + output: { + keyMaterial : keyToJwkTestVectorsKeyMaterial, + keyType : 'public', + }, + input: { + alg : 'EdDSA', + crv : 'Ed25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' + } + }, + { + output: { + keyMaterial : '04fd38a116fe6ddb88635ac45c75905e1096bae61401e5a88e6261ba98cbb5459051f88e19c92126e87d7f7f988bb83e8d320b60feaf11639217576bc2304779b0', + keyType : 'public', + }, + input: { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA' + } + }, + { + output: { + keyMaterial : keyToJwkTestVectorsKeyMaterial, + keyType : 'private', + }, + input: { + alg : 'A128CBC', + kty : 'oct', + k : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' + } + }, + { + output: { + keyMaterial : keyToJwkTestVectorsKeyMaterial, + keyType : 'private', + }, + input: { + alg : 'HS256', + kty : 'oct', + k : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks' + } + }, + { + output: { + keyMaterial : '', + keyType : 'private', + }, + input: { + d : '', + alg : 'EdDSA', + crv : 'Ed25519', + kty : 'OKP', + x : 'c5UR1q2r1lOT_ygDhSkU3paf5Bmukg-jX-1t4kIKJvA', + }, + } +]; + +export const jwkToThumbprintTestVectors = [ + { + output : 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs', + input : { + kty : 'RSA', + n : '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', + e : 'AQAB', + alg : 'RS256', + kid : '2011-04-29', + }, + }, + { + output : 'legaImFEtXYAJYZ8_ZGbZnx-bhc_9nN53pxGpOum3Io', + input : { + alg : 'A128CBC', + kty : 'oct', + k : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks', + }, + }, + { + output : 'dwzDb6KNsqS3QMTqH0jfBHcoHJzYZBc5scB5n5VLe1E', + input : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + }, + }, + { + output : 'KCfBQ0EA2cWr1Kbt-mnlj8LQ9C2AJfcuEm8mtgOe7wQ', + input : { + crv : 'X25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks', + }, + }, + { + output : 'TQdUBtR3MvnNE-7p5sotzCGgZNyQC7EgsiKQz1Erzc4', + input : { + d : '', + crv : 'X25519', + kty : 'OKP', + x : 'MBZd77wAy5932AEP7MHXOevv_MLzzD9OP_fZAOlnIWM', + }, + }, +]; + +export const jwkToCryptoKeyTestVectors = [ + { + cryptoKey: { + algorithm : { name: 'AES-CTR', length: 256 }, + extractable : true, + type : 'private', + usages : ['encrypt', 'decrypt'], + }, + jsonWebKey: { + 'alg' : 'A256CTR', + 'ext' : 'true', + 'key_ops' : ['encrypt', 'decrypt'], + 'k' : 'UQtIAS-rmWB-vgNgG4lPrnTS2tNvwDPKl9rs0L9ICnU', // Base64url + 'kty' : 'oct', + } + }, + { + cryptoKey: { + algorithm : { name: 'AES-GCM', length: 256 }, + extractable : false, + type : 'private', + usages : ['encrypt', 'decrypt'], + }, + jsonWebKey: { + 'alg' : 'A256GCM', + 'ext' : 'false', + 'key_ops' : ['encrypt', 'decrypt'], + 'k' : '-pGdALDtxmxz78wjJQc__4FzvTCVYXTNULM4H0OKVqw', // Base64url + 'kty' : 'oct', + } + }, + { + cryptoKey: { + algorithm : { name: 'HMAC', hash: { name: 'SHA-256' } }, + extractable : true, + type : 'private', + usages : ['sign', 'verify'], + }, + jsonWebKey: { + 'alg' : 'HS256', + 'ext' : 'true', + 'key_ops' : ['sign', 'verify'], + 'k' : '3HOae-P_wVKvabxF37Atgc_jE8fLB0xkMUSpwVWI2HRouvoC2iCrf8j3SYkWsYRFm4Sv8nc2vpzI9g5Jyg0Bxw', // Base64url + 'kty' : 'oct', + } + }, +]; + +export const jwkToMultibaseIdTestVectors = [ + { + output : 'zQ3sheTFzDvGpXAc9AXtwGF3MW1CusKovnwM4pSsUamqKCyLB', + input : { + alg : 'ES256K', + crv : 'secp256k1', + kty : 'EC', + x : '_TihFv5t24hjWsRcdZBeEJa65hQB5aiOYmG6mMu1RZA', + y : 'UfiOGckhJuh9f3-Yi7g-jTILYP6vEWOSF1drwjBHebA', + }, + }, + { + output : 'z6LSjQhGhqqYgrFsNFoZL9wzuKpS1xQ7YNE6fnLgSyW2hUt2', + input : { + crv : 'X25519', + kty : 'OKP', + x : 'cuY-fEu_V1s4b8HbGzy_9VOaNtxiUPzLn6KOATdz0ks', + }, + }, + { + output : 'zAuT', + input : { + d : '', + crv : 'X25519', + kty : 'OKP', + x : 'MBZd77wAy5932AEP7MHXOevv_MLzzD9OP_fZAOlnIWM', + }, + }, +]; diff --git a/packages/crypto/tests/jose.spec.ts b/packages/crypto/tests/jose.spec.ts index a0d0aa2c4..46df2c703 100644 --- a/packages/crypto/tests/jose.spec.ts +++ b/packages/crypto/tests/jose.spec.ts @@ -1,5 +1,5 @@ import chai, { expect } from 'chai'; -import { Convert } from '@web5/common'; +import { Convert, MulticodecCode, MulticodecDefinition } from '@web5/common'; import chaiAsPromised from 'chai-as-promised'; import type { JsonWebKey } from '../src/jose.js'; @@ -12,7 +12,13 @@ import { joseToWebCryptoTestVectors, keyToJwkWebCryptoTestVectors, keyToJwkMulticodecTestVectors, - keyToJwkTestVectorsKeyMaterial + keyToJwkTestVectorsKeyMaterial, + joseToMulticodecTestVectors, + jwkToThumbprintTestVectors, + jwkToCryptoKeyTestVectors, + jwkToKeyTestVectors, + jwkToMultibaseIdTestVectors, + keyToJwkWebCryptoWithNullKTYTestVectors, } from './fixtures/test-vectors/jose.js'; chai.use(chaiAsPromised); @@ -145,20 +151,116 @@ describe('Jose', () => { }); }); + describe('joseToMulticodec()', () => { + it('converts JOSE to Multicodec', async () => { + let multicoded: MulticodecDefinition; + for (const vector of joseToMulticodecTestVectors) { + multicoded = await Jose.joseToMulticodec({ + key: vector.input as JsonWebKey, + }); + expect(multicoded).to.deep.equal(vector.output); + } + }); + + it('throws an error if unsupported JOSE has been passed', async () => { + await expect( + // @ts-expect-error because parameters are intentionally omitted to trigger an error. + Jose.joseToMulticodec({key: { crv: '123'}}) + ).to.eventually.be.rejectedWith(Error, `Unsupported JOSE to Multicodec conversion: '123:public'`); + }); + }); + describe('jwkThumbprint()', () => { - it('passes RFC 7638 test vector', async () => { - // @see {@link https://datatracker.ietf.org/doc/html/rfc7638#section-3.1 | Example JWK Thumbprint Computation} - const jwk: JsonWebKey = { - 'kty' : 'RSA', - 'n' : '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', - 'e' : 'AQAB', - 'alg' : 'RS256', - 'kid' : '2011-04-29' - }; - - const jwkThumbprint = await Jose.jwkThumbprint({ key: jwk }); - expect(jwkThumbprint).to.equal('NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'); + it('passes all test vectors', async () => { + let jwkThumbprint: string; + + for (const vector of jwkToThumbprintTestVectors) { + jwkThumbprint = await Jose.jwkThumbprint({ key: vector.input as JsonWebKey}); + expect(jwkThumbprint).to.equal(vector.output); + } + }); + + it('throws an error if unsupported key type has been passed', async () => { + await expect( + // @ts-expect-error because parameters are intentionally omitted to trigger an error. + Jose.jwkThumbprint({key: { crv: 'X25519', kty: 'unsupported' }}) + ).to.eventually.be.rejectedWith(Error, `Unsupported key type: unsupported`); + }); + }); + + describe('jwkToCryptoKey()', () => { + it('passes all test vectors', async () => { + let cryptoKey: Web5Crypto.CryptoKey; + + for (const vector of jwkToCryptoKeyTestVectors) { + cryptoKey = await Jose.jwkToCryptoKey({ key: vector.jsonWebKey as JsonWebKey}); + expect(cryptoKey).to.deep.equal(vector.cryptoKey); + } + }); + + it('throws an error when ext parameter is missing', async () => { + await expect( + Jose.jwkToCryptoKey({key: { + 'alg' : 'A256CTR', + 'key_ops' : ['encrypt', 'decrypt'], + 'k' : 'UQtIAS-rmWB-vgNgG4lPrnTS2tNvwDPKl9rs0L9ICnU', + 'kty' : 'oct', + }}) + ).to.eventually.be.rejectedWith(Error, `Conversion from JWK to CryptoKey failed. Required parameter missing: 'ext'`); + }); + + it('throws an error when key_ops parameter is missing', async () => { + await expect( + Jose.jwkToCryptoKey({key: { + 'alg' : 'A256CTR', + 'ext' : 'true', + 'k' : 'UQtIAS-rmWB-vgNgG4lPrnTS2tNvwDPKl9rs0L9ICnU', + 'kty' : 'oct', + }}) + ).to.eventually.be.rejectedWith(Error, `Conversion from JWK to CryptoKey failed. Required parameter missing: 'key_ops'`); + }); + }); + + describe('jwkToKey()', () => { + it('converts JWK into Jose parameters', async () => { + let jwk: { keyMaterial: Uint8Array; keyType: Web5Crypto.KeyType }; + + for (const vector of jwkToKeyTestVectors) { + jwk = await Jose.jwkToKey({ key: vector.input as JsonWebKey}); + const hexKeyMaterial = Convert.uint8Array(jwk.keyMaterial).toHex(); + + expect({...jwk, keyMaterial: hexKeyMaterial}).to.deep.equal(vector.output); + } + }); + + it('throws an error if unsupported JOSE has been passed', async () => { + await expect( + // @ts-expect-error because parameters are intentionally omitted to trigger an error. + Jose.jwkToKey({ key: { alg: 'HS256', kty: 'oct' }}) + ).to.eventually.be.rejectedWith(Error, `Jose: Unknown JSON Web Key format.`); + }); + }); + + describe('jwkToMultibaseId()', () => { + it('passes all test vectors', async () => { + let multibaseId: string; + + for (const vector of jwkToMultibaseIdTestVectors) { + multibaseId = await Jose.jwkToMultibaseId({ key: vector.input as JsonWebKey}); + expect(multibaseId).to.equal(vector.output); + } }); + + // it('throws an error when ext parameter is missing', async () => { + // await expect( + // Jose.jwkToCryptoKey({key: { + // 'alg' : 'A256CTR', + // 'key_ops' : ['encrypt', 'decrypt'], + // 'k' : 'UQtIAS-rmWB-vgNgG4lPrnTS2tNvwDPKl9rs0L9ICnU', + // 'kty' : 'oct', + // }}) + // ).to.eventually.be.rejectedWith(Error, `Conversion from JWK to CryptoKey failed. Required parameter missing: 'ext'`); + // }); }); describe('keyToJwk()', () => { @@ -185,6 +287,35 @@ describe('Jose', () => { } }); + it('coverts when kty equals to null', async () => { + let jwkParams: Partial; + const keyMaterial = Convert.hex(keyToJwkTestVectorsKeyMaterial).toUint8Array(); + + for (const vector of keyToJwkWebCryptoWithNullKTYTestVectors) { + jwkParams = Jose.webCryptoToJose(vector.input); + // @ts-expect-error because parameters are intentionally omitted to trigger an error. + const jwk = await Jose.keyToJwk({ keyMaterial, keyType: 'public', ...jwkParams, kty: null }); + expect(jwk).to.deep.equal(vector.output); + } + }); + + it('throws an error for wrong arguments', async () => { + await expect( + Jose.multicodecToJose({ name: 'intentionally-wrong-name', code: 12345 }) + ).to.eventually.be.rejectedWith(Error, `Either 'name' or 'code' must be defined, but not both.`); + }); + + it('handles undefined name', async () => { + const jwkParams = await Jose.multicodecToJose({ name: undefined, code: 0xed }); + expect(jwkParams).to.deep.equal({ alg: 'EdDSA', crv: 'Ed25519', kty: 'OKP', x: '' }); + }); + + it('throws an error for unsupported multicodec conversion', async () => { + await expect( + Jose.multicodecToJose({ name: 'intentionally-wrong-name' }) + ).to.eventually.be.rejectedWith(Error, `Unsupported Multicodec to JOSE conversion: 'intentionally-wrong-name'`); + }); + it('throws an error for unsupported conversion', async () => { let jwkParams: Partial; const testVectors = [ @@ -233,4 +364,29 @@ describe('Jose', () => { ).to.throw(Error, `Unsupported WebCrypto to JOSE conversion: 'non-existent:SHA-1'`); }); }); -}); \ No newline at end of file + + describe('cryptoKeyToJwkPair()', () => { + it('converts CryptoKeys to JWK Pair', async () => { + for (const vector of cryptoKeyPairToJsonWebKeyTestVectors) { + const privateKey = { + ...vector.cryptoKey.privateKey, + material: Convert.hex( + vector.cryptoKey.privateKey.material + ).toUint8Array(), + } as Web5Crypto.CryptoKey; + const publicKey = { + ...vector.cryptoKey.publicKey, + material: Convert.hex( + vector.cryptoKey.publicKey.material + ).toUint8Array(), + } as Web5Crypto.CryptoKey; + + const jwkKeyPair = await Jose.cryptoKeyToJwkPair({ + keyPair: { publicKey, privateKey }, + }); + + expect(jwkKeyPair).to.deep.equal(vector.jsonWebKey); + } + }); + }); +});