diff --git a/.gitignore b/.gitignore index 1766fcc4..3812bff3 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,5 @@ tsconfig.tsbuildinfo # development stuffs *scratch* + +.claude/settings.local.json diff --git a/bun.lock b/bun.lock index e24878ae..0bd96c46 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "example": { "name": "react-native-quick-crypto-example", - "version": "1.0.0-beta.23", + "version": "1.0.0", "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "@noble/ciphers": "^2.0.1", @@ -47,9 +47,10 @@ "react": "19.1.0", "react-native": "0.81.1", "react-native-bouncy-checkbox": "4.1.2", + "react-native-fast-encoder": "^0.3.1", "react-native-nitro-modules": "0.29.1", "react-native-quick-base64": "2.2.2", - "react-native-quick-crypto": "1.0.0-beta.23", + "react-native-quick-crypto": "workspace:*", "react-native-safe-area-context": "^5.2.2", "react-native-screens": "4.18.0", "react-native-vector-icons": "^10.3.0", @@ -79,13 +80,14 @@ "@types/react-test-renderer": "^19.1.0", "babel-jest": "29.7.0", "babel-plugin-module-resolver": "5.0.2", + "jose": "^6.1.3", "react-test-renderer": "19.1.0", "typescript": "^5.8.3", }, }, "packages/react-native-quick-crypto": { "name": "react-native-quick-crypto", - "version": "1.0.0-beta.23", + "version": "1.0.0", "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "events": "3.3.0", @@ -1362,6 +1364,8 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, ""], + "flatbuffers": ["flatbuffers@2.0.6", "", {}, "sha512-QTTZTXTbVfuOVQu2X6eLOw4vefUxnFJZxAKeN3rEPhjEzBtIbehimJLfVGHPM8iX0Na+9i76SBEg0skf0c0sCA=="], + "flatted": ["flatted@3.3.1", "", {}, ""], "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, ""], @@ -1680,6 +1684,8 @@ "joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, ""], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, ""], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, ""], @@ -2102,6 +2108,8 @@ "react-native-builder-bob": ["react-native-builder-bob@0.40.15", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-transform-flow-strip-types": "^7.26.5", "@babel/plugin-transform-strict-mode": "^7.24.7", "@babel/preset-env": "^7.25.2", "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "arktype": "^2.1.15", "babel-plugin-syntax-hermes-parser": "^0.28.0", "browserslist": "^4.20.4", "cross-spawn": "^7.0.3", "dedent": "^0.7.0", "del": "^6.1.1", "escape-string-regexp": "^4.0.0", "fs-extra": "^10.1.0", "glob": "^8.0.3", "is-git-dirty": "^2.0.1", "json5": "^2.2.1", "kleur": "^4.1.4", "prompts": "^2.4.2", "react-native-monorepo-config": "^0.1.8", "which": "^2.0.2", "yargs": "^17.5.1" }, "bin": { "bob": "bin/bob" } }, "sha512-p70LXlYOe53bZeEQchbK1hIhBGQsuB13bT81E7KkOe4dQABwMV6c585A4np0c3nwTTaqQMDYUat4x7J0oLnjxQ=="], + "react-native-fast-encoder": ["react-native-fast-encoder@0.3.1", "", { "dependencies": { "big-integer": "^1.6.51", "flatbuffers": "2.0.6" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-D5ZEbffxayZImtUUErbNod+7IvJITkxTCPZAQTFg6lrSjl/443Mk5CvT9nKpJC1w4cg2llWk6QP8nczij5ClcQ=="], + "react-native-monorepo-config": ["react-native-monorepo-config@0.1.10", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "fast-glob": "^3.3.3" } }, "sha512-v0rlaLZiCUg95Mpw6xNRQce5k9yio0qscKjNQaPtFYMNL75YugS2UPUItIPLIRbZubK+s2/LRzBjX+mdyUgh4g=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.29.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-91A/Lc4Zc1Bvzj1iMSnD6vA5Swqv8aVcwGcv8ddjoPd9mahNvVS2arFh3o7kAqRH4RIh3KcQ0NpYslu7AYn55Q=="], diff --git a/example/index.ts b/example/index.ts index fb144100..a713775b 100644 --- a/example/index.ts +++ b/example/index.ts @@ -2,6 +2,31 @@ import { install } from 'react-native-quick-crypto'; install(); +// TextEncoder/TextDecoder polyfill (required for jose) +import FastEncoder from 'react-native-fast-encoder'; +class TextEncoderPolyfill { + encode(input: string): Uint8Array { + const encoder = new FastEncoder(); + return encoder.encode(input); + } +} +class TextDecoderPolyfill { + private encoder: FastEncoder; + constructor(_encoding: string = 'utf-8') { + this.encoder = new FastEncoder(_encoding); + } + decode(input: Uint8Array): string { + return this.encoder.decode(input); + } +} +global.TextEncoder = TextEncoderPolyfill as unknown as typeof TextEncoder; +global.TextDecoder = TextDecoderPolyfill as unknown as typeof TextDecoder; + +// structuredClone polyfill (required for jose) +if (typeof global.structuredClone === 'undefined') { + global.structuredClone = (obj: T): T => JSON.parse(JSON.stringify(obj)); +} + // event-target-shim import 'event-target-polyfill'; diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 488c79ec..d106c53a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1809,6 +1809,34 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket + - react-native-fast-encoder (0.3.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-quick-base64 (2.2.2): - React-Core - react-native-safe-area-context (5.6.2): @@ -2537,6 +2565,7 @@ DEPENDENCIES: - React-logger (from `../../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-fast-encoder (from `../../node_modules/react-native-fast-encoder`) - react-native-quick-base64 (from `../../node_modules/react-native-quick-base64`) - react-native-safe-area-context (from `../../node_modules/react-native-safe-area-context`) - React-NativeModulesApple (from `../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -2665,6 +2694,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-fast-encoder: + :path: "../../node_modules/react-native-fast-encoder" react-native-quick-base64: :path: "../../node_modules/react-native-quick-base64" react-native-safe-area-context: @@ -2780,6 +2811,7 @@ SPEC CHECKSUMS: React-logger: 7aef4d74123e5e3d267e5af1fbf5135b5a0d8381 React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb + react-native-fast-encoder: f2728ab5e520601ba04df15716722941d941495e react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4 react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac diff --git a/example/package.json b/example/package.json index 460eaef2..9390ed83 100644 --- a/example/package.json +++ b/example/package.json @@ -36,9 +36,10 @@ "react": "19.1.0", "react-native": "0.81.1", "react-native-bouncy-checkbox": "4.1.2", + "react-native-fast-encoder": "^0.3.1", "react-native-nitro-modules": "0.29.1", "react-native-quick-base64": "2.2.2", - "react-native-quick-crypto": "1.0.0", + "react-native-quick-crypto": "workspace:*", "react-native-safe-area-context": "^5.2.2", "react-native-screens": "4.18.0", "react-native-vector-icons": "^10.3.0", @@ -68,6 +69,7 @@ "@types/react-test-renderer": "^19.1.0", "babel-jest": "29.7.0", "babel-plugin-module-resolver": "5.0.2", + "jose": "6.1.3", "react-test-renderer": "19.1.0", "typescript": "^5.8.3" }, diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index 56c3264b..b9a6ff00 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -11,6 +11,7 @@ import '../tests/cfrg/x25519_tests'; import '../tests/constants/constants_tests'; import '../tests/hash/hash_tests'; import '../tests/hmac/hmac_tests'; +import '../tests/jose/jose'; import '../tests/keys/sign_verify_streaming'; import '../tests/keys/public_cipher'; import '../tests/keys/create_keys'; diff --git a/example/src/tests/jose/jose.ts b/example/src/tests/jose/jose.ts new file mode 100644 index 00000000..04f108e3 --- /dev/null +++ b/example/src/tests/jose/jose.ts @@ -0,0 +1,522 @@ +import { expect } from 'chai'; +import { + exportJWK, + importJWK, + SignJWT, + jwtVerify, + CompactEncrypt, + compactDecrypt, + generateKeyPair as joseGenerateKeyPair, + generateSecret as joseGenerateSecret, +} from 'jose'; +import { subtle, CryptoKey } from 'react-native-quick-crypto'; +import type { CryptoKeyPair } from 'react-native-quick-crypto'; +import { test } from '../util'; + +const SUITE = 'jose'; + +// Helper to check Symbol.toStringTag +function getStringTag(obj: unknown): string | undefined { + if (obj === null || obj === undefined) return undefined; + return (obj as Record)[Symbol.toStringTag]; +} + +// ============================================================================= +// Symbol.toStringTag Tests +// ============================================================================= + +test(SUITE, 'CryptoKey has correct Symbol.toStringTag', async () => { + const key = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); + expect(getStringTag(key)).to.equal('CryptoKey'); +}); + +test( + SUITE, + 'KeyObject (via CryptoKey.keyObject) has correct Symbol.toStringTag', + async () => { + const key = (await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKey; + expect(getStringTag(key.keyObject)).to.equal('KeyObject'); + }, +); + +test(SUITE, 'RSA CryptoKey has correct Symbol.toStringTag', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + expect(getStringTag(keyPair.publicKey)).to.equal('CryptoKey'); + expect(getStringTag(keyPair.privateKey)).to.equal('CryptoKey'); + expect(getStringTag((keyPair.publicKey as CryptoKey).keyObject)).to.equal( + 'KeyObject', + ); + expect(getStringTag((keyPair.privateKey as CryptoKey).keyObject)).to.equal( + 'KeyObject', + ); +}); + +// ============================================================================= +// JWK Export/Import Tests +// ============================================================================= + +test(SUITE, 'exportJWK - RSA public key', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const jwk = await exportJWK(keyPair.publicKey as CryptoKey); + expect(jwk.kty).to.equal('RSA'); + expect(jwk.n).to.match(/^[A-Za-z0-9_-]+$/); + expect(jwk.e).to.equal('AQAB'); + expect(jwk.d).to.equal(undefined); +}); + +test(SUITE, 'exportJWK - RSA private key', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const jwk = await exportJWK(keyPair.privateKey as CryptoKey); + expect(jwk.kty).to.equal('RSA'); + expect(jwk.n).to.match(/^[A-Za-z0-9_-]+$/); + expect(jwk.e).to.equal('AQAB'); + expect(jwk.d).to.match(/^[A-Za-z0-9_-]+$/); + expect(jwk.p).to.match(/^[A-Za-z0-9_-]+$/); + expect(jwk.q).to.match(/^[A-Za-z0-9_-]+$/); +}); + +test(SUITE, 'exportJWK - EC P-256 key pair', async () => { + const keyPair = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const pubJwk = await exportJWK(keyPair.publicKey as CryptoKey); + expect(pubJwk.kty).to.equal('EC'); + expect(pubJwk.crv).to.equal('P-256'); + expect(pubJwk.x).to.match(/^[A-Za-z0-9_-]+$/); + expect(pubJwk.y).to.match(/^[A-Za-z0-9_-]+$/); + expect(pubJwk.d).to.equal(undefined); + + const privJwk = await exportJWK(keyPair.privateKey as CryptoKey); + expect(privJwk.kty).to.equal('EC'); + expect(privJwk.crv).to.equal('P-256'); + expect(privJwk.d).to.match(/^[A-Za-z0-9_-]+$/); +}); + +test(SUITE, 'exportJWK - HMAC secret key', async () => { + const key = (await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'], + )) as CryptoKey; + + const jwk = await exportJWK(key); + expect(jwk.kty).to.equal('oct'); + expect(jwk.k).to.match(/^[A-Za-z0-9_-]+$/); +}); + +test(SUITE, 'importJWK - RSA public key roundtrip', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const jwk = await exportJWK(keyPair.publicKey as CryptoKey); + const imported = await importJWK(jwk, 'RSA-OAEP-256'); + expect(getStringTag(imported)).to.equal('CryptoKey'); + expect((imported as CryptoKey).type).to.equal('public'); +}); + +test(SUITE, 'importJWK - EC P-256 public key roundtrip', async () => { + const keyPair = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const jwk = await exportJWK(keyPair.publicKey as CryptoKey); + const imported = await importJWK(jwk, 'ES256'); + expect(getStringTag(imported)).to.equal('CryptoKey'); + expect((imported as CryptoKey).type).to.equal('public'); +}); + +// ============================================================================= +// JWT Signing/Verification Tests (RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA) +// ============================================================================= + +test(SUITE, 'SignJWT/jwtVerify - RS256 (RSASSA-PKCS1-v1_5)', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const jwt = await new SignJWT({ + sub: 'test-user', + iat: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(keyPair.privateKey as CryptoKey); + + expect(jwt.split('.').length).to.equal(3); + + const { payload } = await jwtVerify(jwt, keyPair.publicKey as CryptoKey); + expect(payload.sub).to.equal('test-user'); +}); + +test(SUITE, 'SignJWT/jwtVerify - PS256 (RSA-PSS)', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const jwt = await new SignJWT({ sub: 'pss-user' }) + .setProtectedHeader({ alg: 'PS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(keyPair.privateKey as CryptoKey); + + expect(jwt.split('.').length).to.equal(3); + + const { payload } = await jwtVerify(jwt, keyPair.publicKey as CryptoKey); + expect(payload.sub).to.equal('pss-user'); +}); + +test(SUITE, 'SignJWT/jwtVerify - ES256 (ECDSA P-256)', async () => { + const keyPair = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const jwt = await new SignJWT({ sub: 'ec-user' }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(keyPair.privateKey as CryptoKey); + + expect(jwt.split('.').length).to.equal(3); + + const { payload } = await jwtVerify(jwt, keyPair.publicKey as CryptoKey); + expect(payload.sub).to.equal('ec-user'); +}); + +test(SUITE, 'SignJWT/jwtVerify - ES384 (ECDSA P-384)', async () => { + const keyPair = (await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const jwt = await new SignJWT({ sub: 'ec384-user' }) + .setProtectedHeader({ alg: 'ES384' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(keyPair.privateKey as CryptoKey); + + const { payload } = await jwtVerify(jwt, keyPair.publicKey as CryptoKey); + expect(payload.sub).to.equal('ec384-user'); +}); + +test(SUITE, 'SignJWT/jwtVerify - HS256 (HMAC)', async () => { + const key = (await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'], + )) as CryptoKey; + + const jwt = await new SignJWT({ sub: 'hmac-user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(key); + + expect(jwt.split('.').length).to.equal(3); + + const { payload } = await jwtVerify(jwt, key); + expect(payload.sub).to.equal('hmac-user'); +}); + +// ============================================================================= +// JWE Encryption/Decryption Tests (RSA-OAEP) +// ============================================================================= + +test(SUITE, 'CompactEncrypt/compactDecrypt - RSA-OAEP-256', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + )) as CryptoKeyPair; + + const plaintext = new TextEncoder().encode('Hello, Jose!'); + + const jwe = await new CompactEncrypt(plaintext) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .encrypt(keyPair.publicKey as CryptoKey); + + expect(jwe.split('.').length).to.equal(5); + + const { plaintext: decrypted } = await compactDecrypt( + jwe, + keyPair.privateKey as CryptoKey, + ); + expect(new TextDecoder().decode(decrypted)).to.equal('Hello, Jose!'); +}); + +test(SUITE, 'CompactEncrypt/compactDecrypt - RSA-OAEP (SHA-1)', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-1', + }, + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + )) as CryptoKeyPair; + + const plaintext = new TextEncoder().encode('Secret message'); + + const jwe = await new CompactEncrypt(plaintext) + .setProtectedHeader({ alg: 'RSA-OAEP', enc: 'A128GCM' }) + .encrypt(keyPair.publicKey as CryptoKey); + + const { plaintext: decrypted } = await compactDecrypt( + jwe, + keyPair.privateKey as CryptoKey, + ); + expect(new TextDecoder().decode(decrypted)).to.equal('Secret message'); +}); + +// ============================================================================= +// Algorithm Hash Property Normalization Tests +// ============================================================================= + +test( + SUITE, + 'RSA key algorithm.hash is normalized to { name: string }', + async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const pubKey = keyPair.publicKey as CryptoKey; + const privKey = keyPair.privateKey as CryptoKey; + + // Check that hash is normalized to object format + const pubAlgo = pubKey.algorithm; + const privAlgo = privKey.algorithm; + + expect(typeof pubAlgo.hash).to.equal('object'); + expect((pubAlgo.hash as { name: string }).name).to.equal('SHA-256'); + expect(typeof privAlgo.hash).to.equal('object'); + expect((privAlgo.hash as { name: string }).name).to.equal('SHA-256'); + }, +); + +test(SUITE, 'RSA imported key algorithm.hash is normalized', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + )) as CryptoKeyPair; + + const exported = await subtle.exportKey( + 'spki', + keyPair.publicKey as CryptoKey, + ); + + // Import with string hash format + const imported = await subtle.importKey( + 'spki', + exported, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['encrypt'], + ); + + // Verify hash is normalized + expect(typeof imported.algorithm.hash).to.equal('object'); + expect((imported.algorithm.hash as { name: string }).name).to.equal( + 'SHA-256', + ); +}); + +// ============================================================================= +// Cross-library Key Generation Tests +// ============================================================================= + +test(SUITE, 'jose generateKeyPair works with RNQC verification', async () => { + const { publicKey, privateKey } = await joseGenerateKeyPair('RS256', { + modulusLength: 2048, + extractable: true, + }); + + const jwt = await new SignJWT({ test: 'value' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .sign(privateKey); + + const { payload } = await jwtVerify(jwt, publicKey); + expect(payload.test).to.equal('value'); +}); + +test(SUITE, 'jose generateSecret works with RNQC', async () => { + const secret = await joseGenerateSecret('HS256', { extractable: true }); + + const jwt = await new SignJWT({ data: 'secret' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .sign(secret); + + const { payload } = await jwtVerify(jwt, secret); + expect(payload.data).to.equal('secret'); +}); + +// ============================================================================= +// Edge Cases and Error Handling +// ============================================================================= + +test(SUITE, 'JWT with all standard claims', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + + const now = Math.floor(Date.now() / 1000); + + const jwt = await new SignJWT({ + sub: 'user123', + name: 'Test User', + admin: true, + groups: ['users', 'admins'], + }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer('rnqc-test') + .setAudience('test-app') + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .setNotBefore(now - 60) + .setJti('unique-token-id') + .sign(keyPair.privateKey as CryptoKey); + + const { payload, protectedHeader } = await jwtVerify( + jwt, + keyPair.publicKey as CryptoKey, + { + issuer: 'rnqc-test', + audience: 'test-app', + }, + ); + + expect(protectedHeader.alg).to.equal('RS256'); + expect(protectedHeader.typ).to.equal('JWT'); + expect(payload.sub).to.equal('user123'); + expect(payload.iss).to.equal('rnqc-test'); + expect(payload.aud).to.equal('test-app'); + expect(payload.name).to.equal('Test User'); + expect(payload.admin).to.equal(true); + expect(payload.groups).to.have.members(['users', 'admins']); + expect(payload.jti).to.equal('unique-token-id'); +}); + +test(SUITE, 'JWE with A256GCM content encryption', async () => { + const keyPair = (await subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + )) as CryptoKeyPair; + + const sensitiveData = JSON.stringify({ + creditCard: '4111-1111-1111-1111', + expiry: '12/25', + }); + + const jwe = await new CompactEncrypt(new TextEncoder().encode(sensitiveData)) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .encrypt(keyPair.publicKey as CryptoKey); + + const { plaintext } = await compactDecrypt( + jwe, + keyPair.privateKey as CryptoKey, + ); + const decrypted = JSON.parse(new TextDecoder().decode(plaintext)); + + expect(decrypted.creditCard).to.equal('4111-1111-1111-1111'); + expect(decrypted.expiry).to.equal('12/25'); +}); diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index 7737609b..19eb6a0f 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -1490,7 +1490,7 @@ async function testImportSpki( }); expect(key.algorithm.modulusLength).to.equal(parseInt(size, 10)); expect(key.algorithm.publicExponent).to.deep.equal(new Uint8Array([1, 0, 1])); - expect(key.algorithm.hash).to.equal(hash); + expect((key.algorithm.hash as { name: string }).name).to.equal(hash); if (extractable) { const spki = await subtle.exportKey('spki', key); diff --git a/packages/react-native-quick-crypto/src/keys/classes.ts b/packages/react-native-quick-crypto/src/keys/classes.ts index 551c5727..d8414c9e 100644 --- a/packages/react-native-quick-crypto/src/keys/classes.ts +++ b/packages/react-native-quick-crypto/src/keys/classes.ts @@ -17,6 +17,10 @@ export class CryptoKey { keyUsages: KeyUsage[]; keyExtractable: boolean; + get [Symbol.toStringTag](): string { + return 'CryptoKey'; + } + constructor( keyObject: KeyObject, keyAlgorithm: SubtleAlgorithm, @@ -70,6 +74,11 @@ export class CryptoKey { export class KeyObject { handle: KeyObjectHandle; type: 'public' | 'secret' | 'private'; + + get [Symbol.toStringTag](): string { + return 'KeyObject'; + } + export(options: { format: 'pem' } & EncodingOptions): string | Buffer; export(options?: { format: 'der' } & EncodingOptions): Buffer; export(options?: { format: 'jwk' } & EncodingOptions): never; diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index b8d576d1..e9c38396 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -469,10 +469,11 @@ async function hmacGenerateKey( // Create secret key const keyObject = createSecretKey(keyBytes); - // Construct algorithm object + // Construct algorithm object with hash normalized to { name: string } format per WebCrypto spec + const webCryptoHashName = normalizeHashName(hash, HashContext.WebCrypto); const keyAlgorithm: SubtleAlgorithm = { name: 'HMAC', - hash: hashName, + hash: { name: webCryptoHashName }, length, }; @@ -570,10 +571,15 @@ function rsaImportKey( publicExponentBytes = new Uint8Array(bytes.length > 0 ? bytes : [0]); } + // Normalize hash to { name: string } format per WebCrypto spec + const hashName = normalizeHashName(algorithm.hash, HashContext.WebCrypto); + const normalizedHash = { name: hashName }; + const algorithmWithDetails = { ...algorithm, modulusLength: keyDetails?.modulusLength, publicExponent: publicExponentBytes, + hash: normalizedHash, }; return new CryptoKey(keyObject, algorithmWithDetails, keyUsages, extractable); @@ -636,12 +642,15 @@ async function hmacImportKey( throw new Error(`Unable to import HMAC key with format ${format}`); } - return new CryptoKey( - keyObject, - { ...algorithm, name: 'HMAC' }, - keyUsages, - extractable, - ); + // Normalize hash to { name: string } format per WebCrypto spec + const hashName = normalizeHashName(algorithm.hash, HashContext.WebCrypto); + const normalizedAlgorithm: SubtleAlgorithm = { + ...algorithm, + name: 'HMAC', + hash: { name: hashName }, + }; + + return new CryptoKey(keyObject, normalizedAlgorithm, keyUsages, extractable); } async function aesImportKey(