diff --git a/.github/actions/post-maestro-screenshot/action.yml b/.github/actions/post-maestro-screenshot/action.yml index ad3dec4f..303495ba 100644 --- a/.github/actions/post-maestro-screenshot/action.yml +++ b/.github/actions/post-maestro-screenshot/action.yml @@ -14,7 +14,7 @@ inputs: default: 'unknown' imgbb-api-key: description: 'ImgBB API key for image hosting' - required: true + required: false runs: using: 'composite' @@ -69,7 +69,7 @@ runs: fi - name: Upload screenshot to ImgBB - if: steps.check-screenshot.outputs.exists == 'true' && github.event_name == 'pull_request' + if: steps.check-screenshot.outputs.exists == 'true' && github.event_name == 'pull_request' && inputs.imgbb-api-key != '' id: upload-screenshot uses: McCzarny/upload-image@v2.0.0 with: @@ -82,6 +82,7 @@ runs: uses: peter-evans/find-comment@v3 id: find-comment if: github.event_name == 'pull_request' + continue-on-error: true with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' @@ -89,6 +90,7 @@ runs: - name: Create or update PR comment (with screenshot) if: github.event_name == 'pull_request' && steps.check-screenshot.outputs.exists == 'true' && steps.upload-screenshot.outputs.url + continue-on-error: true uses: peter-evans/create-or-update-comment@v4 with: token: ${{ inputs.github-token }} @@ -113,6 +115,7 @@ runs: - name: Create or update PR comment (no screenshot) if: github.event_name == 'pull_request' && steps.check-screenshot.outputs.exists != 'true' + continue-on-error: true uses: peter-evans/create-or-update-comment@v4 with: token: ${{ inputs.github-token }} @@ -135,6 +138,7 @@ runs: - name: Create or update PR comment (upload failed) if: github.event_name == 'pull_request' && steps.check-screenshot.outputs.exists == 'true' && !steps.upload-screenshot.outputs.url + continue-on-error: true uses: peter-evans/create-or-update-comment@v4 with: token: ${{ inputs.github-token }} diff --git a/.gitignore b/.gitignore index 3812bff3..0690dd7c 100644 --- a/.gitignore +++ b/.gitignore @@ -187,4 +187,6 @@ tsconfig.tsbuildinfo # development stuffs *scratch* +# agents .claude/settings.local.json +.agent/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 91e14e4f..c6ed3648 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,6 @@ +# Add bun to PATH +export PATH="$HOME/.bun/bin:$PATH" + # Run linting and formatting on staged files bun lint-staged diff --git a/bun.lock b/bun.lock index 7ee442d5..1fea30d3 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "example": { "name": "react-native-quick-crypto-example", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "@noble/ciphers": "^2.0.1", @@ -47,7 +47,7 @@ "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-fast-encoder": "0.3.1", "react-native-nitro-modules": "0.29.1", "react-native-quick-base64": "2.2.2", "react-native-quick-crypto": "workspace:*", @@ -87,7 +87,7 @@ }, "packages/react-native-quick-crypto": { "name": "react-native-quick-crypto", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@craftzdog/react-native-buffer": "6.1.0", "events": "3.3.0", @@ -1308,7 +1308,7 @@ "expo-asset": ["expo-asset@12.0.10", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg=="], - "expo-build-properties": ["expo-build-properties@1.0.9", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ=="], + "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], "expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="], @@ -1340,7 +1340,7 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, ""], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], "fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, ""], diff --git a/docs/implementation-coverage.md b/docs/implementation-coverage.md index 73ace9e2..b9452cd1 100644 --- a/docs/implementation-coverage.md +++ b/docs/implementation-coverage.md @@ -1,6 +1,8 @@ # Implementation Coverage - NodeJS This document attempts to describe the implementation status of Crypto APIs/Interfaces from Node.js in the `react-native-quick-crypto` library. +> Note: This is the status for version 1.x and higher. For version `0.x` see [this document](https://github.com/margelo/react-native-quick-crypto/blob/0.x/docs/implementation-coverage.md) and the [0.x branch](https://github.com/margelo/react-native-quick-crypto/tree/0.x). + * ` ` - not implemented in Node * ❌ - implemented in Node, not RNQC * ✅ - implemented in Node and RNQC @@ -13,6 +15,7 @@ This document attempts to describe the implementation status of Crypto APIs/Inte These algorithms provide quantum-resistant cryptography. + # `Crypto` * ❌ Class: `Certificate` @@ -260,9 +263,9 @@ These algorithms provide quantum-resistant cryptography. * ❌ `subtle.getPublicKey(key, keyUsages)` * 🚧 `subtle.importKey(format, keyData, algorithm, extractable, keyUsages)` * ✅ `subtle.sign(algorithm, key, data)` - * ❌ `subtle.unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgo, unwrappedKeyAlgo, extractable, keyUsages)` + * ✅ `subtle.unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgo, unwrappedKeyAlgo, extractable, keyUsages)` * ✅ `subtle.verify(algorithm, key, signature, data)` - * ❌ `subtle.wrapKey(format, key, wrappingKey, wrapAlgo)` + * ✅ `subtle.wrapKey(format, key, wrappingKey, wrapAlgo)` ## `subtle.decrypt` | Algorithm | Status | @@ -271,13 +274,14 @@ These algorithms provide quantum-resistant cryptography. | `AES-CTR` | ✅ | | `AES-CBC` | ✅ | | `AES-GCM` | ✅ | +| `ChaCha20-Poly1305` | ✅ | ## `subtle.deriveBits` | Algorithm | Status | | --------- | :----: | | `ECDH` | ❌ | -| `X25519` | ❌ | -| `X448` | ❌ | +| `X25519` | ✅ | +| `X448` | ✅ | | `HKDF` | ❌ | | `PBKDF2` | ✅ | @@ -286,9 +290,9 @@ These algorithms provide quantum-resistant cryptography. | --------- | :----: | | `ECDH` | ❌ | | `HKDF` | ❌ | -| `PBKDF2` | ❌ | -| `X25519` | ❌ | -| `X448` | ❌ | +| `PBKDF2` | ✅ | +| `X25519` | ✅ | +| `X448` | ✅ | ## `subtle.digest` | Algorithm | Status | @@ -310,7 +314,7 @@ These algorithms provide quantum-resistant cryptography. | `AES-CBC` | ✅ | | `AES-GCM` | ✅ | | `AES-OCB` | ❌ | -| `ChaCha20-Poly1305` | ❌ | +| `ChaCha20-Poly1305` | ✅ | | `RSA-OAEP` | ✅ | ## `subtle.exportKey` @@ -398,8 +402,8 @@ These algorithms provide quantum-resistant cryptography. | `RSA-OAEP` | ✅ | ✅ | ✅ | | | | | | `RSA-PSS` | ✅ | ✅ | ✅ | | | | | | `RSASSA-PKCS1-v1_5` | ✅ | ✅ | ✅ | | | | | -| `X25519` | ❌ | ❌ | ❌ | ❌ | | ❌ | | -| `X448` | ❌ | ❌ | ❌ | ❌ | | ❌ | | +| `X25519` | ✅ | ✅ | ✅ | ✅ | | ✅ | | +| `X448` | ✅ | ✅ | ✅ | ✅ | | ✅ | | ## `subtle.sign` | Algorithm | Status | @@ -421,10 +425,10 @@ These algorithms provide quantum-resistant cryptography. | ------------------- | :----: | | `AES-CBC` | ❌ | | `AES-CTR` | ❌ | -| `AES-GCM` | ❌ | -| `AES-KW` | ❌ | +| `AES-GCM` | ✅ | +| `AES-KW` | ✅ | | `AES-OCB` | ❌ | -| `ChaCha20-Poly1305` | ❌ | +| `ChaCha20-Poly1305` | ✅ | | `RSA-OAEP` | ❌ | ### unwrapped key algorithms @@ -473,8 +477,8 @@ These algorithms provide quantum-resistant cryptography. | ------------------- | :----: | | `AES-CBC` | ❌ | | `AES-CTR` | ❌ | -| `AES-GCM` | ❌ | -| `AES-KW` | ❌ | +| `AES-GCM` | ✅ | +| `AES-KW` | ✅ | | `AES-OCB` | ❌ | -| `ChaCha20-Poly1305` | ❌ | +| `ChaCha20-Poly1305` | ✅ | | `RSA-OAEP` | ❌ | diff --git a/example/package.json b/example/package.json index dc416319..4913bf05 100644 --- a/example/package.json +++ b/example/package.json @@ -36,10 +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-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.1", + "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", diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index b7b4e347..9db850f1 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -19,13 +19,16 @@ import '../tests/keys/public_cipher'; import '../tests/keys/sign_verify_streaming'; import '../tests/pbkdf2/pbkdf2_tests'; import '../tests/random/random_tests'; +import '../tests/subtle/x25519_x448'; import '../tests/subtle/deriveBits'; +import '../tests/subtle/derive_key'; import '../tests/subtle/digest'; import '../tests/subtle/encrypt_decrypt'; import '../tests/subtle/generateKey'; import '../tests/subtle/import_export'; import '../tests/subtle/jwk_rfc7517_tests'; import '../tests/subtle/sign_verify'; +import '../tests/subtle/wrap_unwrap'; export const useTestsList = (): [ TestSuites, diff --git a/example/src/tests/subtle/derive_key.ts b/example/src/tests/subtle/derive_key.ts new file mode 100644 index 00000000..b91d0157 --- /dev/null +++ b/example/src/tests/subtle/derive_key.ts @@ -0,0 +1,100 @@ +import { test } from '../util'; +import { expect } from 'chai'; +import { subtle, getRandomValues } from 'react-native-quick-crypto'; +import { CryptoKey } from 'react-native-quick-crypto'; +import type { CryptoKeyPair } from 'react-native-quick-crypto'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const subtleAny = subtle as any; + +const SUITE = 'subtle.deriveKey'; + +// Test 1: PBKDF2 deriveKey +test(SUITE, 'PBKDF2 deriveKey to AES-GCM', async () => { + const password = new TextEncoder().encode('my-password'); + const salt = getRandomValues(new Uint8Array(16)); + + const baseKey = await subtle.importKey( + 'raw', + password, + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + + const derivedKey = await subtleAny.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + baseKey as CryptoKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + + // Verify key can encrypt/decrypt + const plaintext = new Uint8Array([1, 2, 3, 4]); + const iv = getRandomValues(new Uint8Array(12)); + + const ciphertext = await subtle.encrypt( + { name: 'AES-GCM', iv }, + derivedKey as CryptoKey, + plaintext, + ); + + const decrypted = await subtle.decrypt( + { name: 'AES-GCM', iv }, + derivedKey as CryptoKey, + ciphertext, + ); + + expect(Buffer.from(decrypted).toString('hex')).to.equal( + Buffer.from(plaintext).toString('hex'), + ); +}); + +// Test 2: X25519 deriveKey +test(SUITE, 'X25519 deriveKey to AES-GCM', async () => { + const aliceKeyPair = await subtle.generateKey({ name: 'X25519' }, false, [ + 'deriveKey', + 'deriveBits', + ]); + + const bobKeyPair = await subtle.generateKey({ name: 'X25519' }, false, [ + 'deriveKey', + 'deriveBits', + ]); + + const aliceDerivedKey = await subtleAny.deriveKey( + { + name: 'X25519', + public: (bobKeyPair as CryptoKeyPair).publicKey, + }, + (aliceKeyPair as CryptoKeyPair).privateKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + + const bobDerivedKey = await subtleAny.deriveKey( + { + name: 'X25519', + public: (aliceKeyPair as CryptoKeyPair).publicKey, + }, + (bobKeyPair as CryptoKeyPair).privateKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + + // Both should derive the same key + const aliceRaw = await subtle.exportKey('raw', aliceDerivedKey as CryptoKey); + const bobRaw = await subtle.exportKey('raw', bobDerivedKey as CryptoKey); + + expect(Buffer.from(aliceRaw as ArrayBuffer).toString('hex')).to.equal( + Buffer.from(bobRaw as ArrayBuffer).toString('hex'), + ); +}); diff --git a/example/src/tests/subtle/encrypt_decrypt.ts b/example/src/tests/subtle/encrypt_decrypt.ts index 4e9764be..5f0fc0a7 100644 --- a/example/src/tests/subtle/encrypt_decrypt.ts +++ b/example/src/tests/subtle/encrypt_decrypt.ts @@ -529,6 +529,349 @@ test( }, ); +// Test ChaCha20-Poly1305 +test(SUITE, 'ChaCha20-Poly1305', async () => { + const buf = getRandomValues(new Uint8Array(50)); + const iv = getRandomValues(new Uint8Array(12)); // 96-bit nonce + + const key = await subtle.generateKey( + { + name: 'ChaCha20-Poly1305', + length: 256, + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + buf, + ); + + const plaintext = await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ); + + expect(Buffer.from(plaintext).toString('hex')).to.equal( + Buffer.from(buf as Uint8Array).toString('hex'), + ); +}); + +// Test ChaCha20-Poly1305 with AAD +test(SUITE, 'ChaCha20-Poly1305 with AAD', async () => { + const plaintext = getRandomValues(new Uint8Array(32)); + const iv = getRandomValues(new Uint8Array(12)); + const aad = getRandomValues(new Uint8Array(16)); + + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv, additionalData: aad } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + const decrypted = await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv, additionalData: aad } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ); + + expect(Buffer.from(decrypted).toString('hex')).to.equal( + Buffer.from(plaintext as Uint8Array).toString('hex'), + ); +}); + +// RFC 8439 test vector for ChaCha20-Poly1305 +test(SUITE, 'ChaCha20-Poly1305 RF C 8439 vector', async () => { + const keyHex = + '808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f'; + const nonceHex = '070000004041424344454647'; + const plaintextHex = + '4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e'; + const aadHex = '50515253c0c1c2c3c4c5c6c7'; + const expectedCiphertextHex = + 'd31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4def08e4b7a9de576d26586cec64b6116'; + const expectedTagHex = '1ae10b594f09e26a7e902ecbd0600691'; + + const key = await subtle.importKey( + 'raw', + Buffer.from(keyHex, 'hex'), + { name: 'ChaCha20-Poly1305' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const ciphertext = await subtle.encrypt( + { + name: 'ChaCha20-Poly1305', + iv: Buffer.from(nonceHex, 'hex'), + additionalData: Buffer.from(aadHex, 'hex'), + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + Buffer.from(plaintextHex, 'hex'), + ); + + // Ciphertext includes tag at the end (last 16 bytes) + const ctWithTag = Buffer.from(ciphertext); + const ct = ctWithTag.subarray(0, -16); + const tag = ctWithTag.subarray(-16); + + expect(ct.toString('hex')).to.equal(expectedCiphertextHex); + expect(tag.toString('hex')).to.equal(expectedTagHex); +}); + +// ChaCha20-Poly1305 comprehensive tests (similar to AES) +test(SUITE, 'ChaCha20-Poly1305 wrong key usage encrypt', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['decrypt'], // Only decrypt, not encrypt + ); + + await assertThrowsAsync( + async () => + await subtle.encrypt( + { + name: 'ChaCha20-Poly1305', + iv: getRandomValues(new Uint8Array(12)), + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + getRandomValues(new Uint8Array(32)), + ), + 'The requested operation is not valid for the provided key', + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 wrong key usage decrypt', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt'], // Only encrypt, not decrypt + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = getRandomValues(new Uint8Array(32)); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + await assertThrowsAsync( + async () => + await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ), + 'The requested operation is not valid for the provided key', + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 invalid IV length', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + // Test with wrong IV lengths + const invalidIVs = [ + getRandomValues(new Uint8Array(8)), // Too short + getRandomValues(new Uint8Array(16)), // Too long + getRandomValues(new Uint8Array(24)), // Way too long + ]; + + for (const iv of invalidIVs) { + await assertThrowsAsync( + async () => + await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + getRandomValues(new Uint8Array(32)), + ), + 'ChaCha20-Poly1305 IV must be exactly 12 bytes', + ); + } +}); + +test(SUITE, 'ChaCha20-Poly1305 empty plaintext', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = new Uint8Array(0); // Empty + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + // Should still include auth tag (16 bytes) + expect(ciphertext.byteLength).to.equal(16); + + const decrypted = await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ); + + expect(decrypted.byteLength).to.equal(0); +}); + +test(SUITE, 'ChaCha20-Poly1305 large plaintext', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = getRandomValues(new Uint8Array(1024 * 64)); // 64KB + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + // Ciphertext = plaintext + 16-byte tag + expect(ciphertext.byteLength).to.equal(plaintext.byteLength + 16); + + const decrypted = await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ); + + expect(Buffer.from(decrypted).toString('hex')).to.equal( + Buffer.from(plaintext as Uint8Array).toString('hex'), + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 key import/export raw', async () => { + const keyMaterial = getRandomValues(new Uint8Array(32)); // 256 bits + + const key = await subtle.importKey( + 'raw', + keyMaterial, + { name: 'ChaCha20-Poly1305' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const exported = await subtle.exportKey('raw', key as CryptoKey); + + expect(Buffer.from(exported as ArrayBuffer).toString('hex')).to.equal( + Buffer.from(keyMaterial as Uint8Array).toString('hex'), + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 tampered ciphertext', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = getRandomValues(new Uint8Array(32)); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + // Tamper with the ciphertext + const tamperedCiphertext = new Uint8Array(ciphertext); + tamperedCiphertext[0]! ^= 1; // Flip a bit + + await assertThrowsAsync( + async () => + await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + tamperedCiphertext, + ), + 'Failed to finalize', + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 tampered tag', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = getRandomValues(new Uint8Array(32)); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + // Tamper with the auth tag (last 16 bytes) + const tamperedCiphertext = new Uint8Array(ciphertext); + tamperedCiphertext[tamperedCiphertext.length - 1]! ^= 1; // Flip a bit in tag + + await assertThrowsAsync( + async () => + await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + tamperedCiphertext, + ), + 'Failed to finalize', + ); +}); + +test(SUITE, 'ChaCha20-Poly1305 wrong AAD', async () => { + const key = await subtle.generateKey( + { name: 'ChaCha20-Poly1305', length: 256 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + true, + ['encrypt', 'decrypt'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + const plaintext = getRandomValues(new Uint8Array(32)); + const aad1 = getRandomValues(new Uint8Array(16)); + const aad2 = getRandomValues(new Uint8Array(16)); + + const ciphertext = await subtle.encrypt( + { name: 'ChaCha20-Poly1305', iv, additionalData: aad1 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + plaintext, + ); + + // Try to decrypt with different AAD + await assertThrowsAsync( + async () => + await subtle.decrypt( + { name: 'ChaCha20-Poly1305', iv, additionalData: aad2 } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + key as CryptoKey, + ciphertext, + ), + 'Failed to finalize', + ); +}); + // from https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-encrypt-decrypt-aes.js async function testAESEncrypt({ keyBuffer, diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index 0c5fb678..fe5006d5 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -1483,11 +1483,6 @@ async function testImportSpki( expect(key.extractable).to.equal(extractable); expect(key.usages).to.deep.equal(publicUsages); expect(key.algorithm.name).to.equal(name); - console.log('[RSA TEST DEBUG]', { - modulusLength: key.algorithm.modulusLength, - expected: parseInt(size, 10), - algorithm: JSON.stringify(key.algorithm), - }); 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 as { name: string }).name).to.equal(hash); diff --git a/example/src/tests/subtle/wrap_unwrap.ts b/example/src/tests/subtle/wrap_unwrap.ts new file mode 100644 index 00000000..73e3bcca --- /dev/null +++ b/example/src/tests/subtle/wrap_unwrap.ts @@ -0,0 +1,151 @@ +import { test } from '../util'; +import { expect } from 'chai'; +import { subtle, getRandomValues } from 'react-native-quick-crypto'; +import { CryptoKey } from 'react-native-quick-crypto'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const subtleAny = subtle as any; + +const SUITE = 'subtle.wrapKey/unwrapKey'; + +// Test 1: Wrap/unwrap AES key with AES-KW +test(SUITE, 'wrap/unwrap AES-256 with AES-KW', async () => { + const keyToWrap = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + + const wrappingKey = await subtle.generateKey( + { name: 'AES-KW', length: 256 }, + true, + ['wrapKey', 'unwrapKey'], + ); + + const wrapped = await subtleAny.wrapKey( + 'raw', + keyToWrap as CryptoKey, + wrappingKey as CryptoKey, + { name: 'AES-KW' }, + ); + + const unwrapped = await subtleAny.unwrapKey( + 'raw', + wrapped, + wrappingKey as CryptoKey, + { name: 'AES-KW' }, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + + // Verify keys are functionally identical + const plaintext = getRandomValues(new Uint8Array(32)); + const iv = getRandomValues(new Uint8Array(12)); + + const ct1 = await subtle.encrypt( + { name: 'AES-GCM', iv }, + keyToWrap as CryptoKey, + plaintext, + ); + + const pt2 = await subtle.decrypt( + { name: 'AES-GCM', iv }, + unwrapped as CryptoKey, + ct1, + ); + + expect(Buffer.from(pt2).toString('hex')).to.equal( + Buffer.from(plaintext).toString('hex'), + ); +}); + +// Test 2: Wrap with AES-GCM +test(SUITE, 'wrap/unwrap with AES-GCM', async () => { + const keyToWrap = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + true, + ['sign', 'verify'], + ); + + const wrappingKey = await subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['wrapKey', 'unwrapKey'], + ); + + const iv = getRandomValues(new Uint8Array(12)); + + const wrapped = await subtleAny.wrapKey( + 'raw', + keyToWrap as CryptoKey, + wrappingKey as CryptoKey, + { name: 'AES-GCM', iv }, + ); + + const unwrapped = await subtleAny.unwrapKey( + 'raw', + wrapped, + wrappingKey as CryptoKey, + { name: 'AES-GCM', iv }, + { name: 'HMAC', hash: 'SHA-256' }, + true, + ['sign', 'verify'], + ); + + // Verify functionality + const data = new Uint8Array([1, 2, 3, 4]); + const sig1 = await subtle.sign( + { name: 'HMAC' }, + keyToWrap as CryptoKey, + data, + ); + const sig2 = await subtle.sign( + { name: 'HMAC' }, + unwrapped as CryptoKey, + data, + ); + + expect(Buffer.from(sig1).toString('hex')).to.equal( + Buffer.from(sig2).toString('hex'), + ); +}); + +// Test 3: Wrap/unwrap JWK format +test(SUITE, 'wrap/unwrap JWK format', async () => { + const keyToWrap = await subtle.generateKey( + { name: 'AES-CBC', length: 128 }, + true, + ['encrypt', 'decrypt'], + ); + + const wrappingKey = await subtle.generateKey( + { name: 'AES-KW', length: 256 }, + true, + ['wrapKey', 'unwrapKey'], + ); + + const wrapped = await subtleAny.wrapKey( + 'jwk', + keyToWrap as CryptoKey, + wrappingKey as CryptoKey, + { name: 'AES-KW' }, + ); + + const unwrapped = await subtleAny.unwrapKey( + 'jwk', + wrapped, + wrappingKey as CryptoKey, + { name: 'AES-KW' }, + { name: 'AES-CBC' }, + true, + ['encrypt', 'decrypt'], + ); + + const exported1 = await subtle.exportKey('raw', keyToWrap as CryptoKey); + const exported2 = await subtle.exportKey('raw', unwrapped as CryptoKey); + + expect(Buffer.from(exported1 as ArrayBuffer).toString('hex')).to.equal( + Buffer.from(exported2 as ArrayBuffer).toString('hex'), + ); +}); diff --git a/example/src/tests/subtle/x25519_x448.ts b/example/src/tests/subtle/x25519_x448.ts new file mode 100644 index 00000000..dca7fde2 --- /dev/null +++ b/example/src/tests/subtle/x25519_x448.ts @@ -0,0 +1,155 @@ +import { expect } from 'chai'; +import { + subtle, + type CryptoKeyPair, + type CryptoKey, +} from 'react-native-quick-crypto'; +import { test } from '../util'; + +const SUITE = 'subtle.cfrg'; + +test( + SUITE, + 'X25519 - generateKey, exportKey, importKey, deriveBits', + async () => { + const format = 'raw'; + const algorithm = { name: 'X25519' } as const; + + // 1. Generate Keys + const aliceKeys = (await subtle.generateKey(algorithm, true, [ + 'deriveKey', + 'deriveBits', + ])) as CryptoKeyPair; + + const bobKeys = (await subtle.generateKey(algorithm, true, [ + 'deriveKey', + 'deriveBits', + ])) as CryptoKeyPair; + + expect((aliceKeys.publicKey as CryptoKey).algorithm.name).to.equal( + 'X25519', + ); + expect((aliceKeys.privateKey as CryptoKey).algorithm.name).to.equal( + 'X25519', + ); + + // 2. Export Keys + const alicePubRaw = await subtle.exportKey( + format, + aliceKeys.publicKey as CryptoKey, + ); + const bobPubRaw = await subtle.exportKey( + format, + bobKeys.publicKey as CryptoKey, + ); + + // 3. Import Keys + const alicePubImported = await subtle.importKey( + format, + alicePubRaw, + algorithm, + true, + [], + ); + + const bobPubImported = await subtle.importKey( + format, + bobPubRaw, + algorithm, + true, + [], + ); + + // 4. Derive Bits + const bitsLength = 256; + const aliceShared = await subtle.deriveBits( + { name: 'X25519', public: bobPubImported } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + aliceKeys.privateKey as CryptoKey, + bitsLength, + ); + + const bobShared = await subtle.deriveBits( + { name: 'X25519', public: alicePubImported } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + bobKeys.privateKey as CryptoKey, + bitsLength, + ); + + // Verify shared secrets match + const aliceSharedView = new Uint8Array(aliceShared); + const bobSharedView = new Uint8Array(bobShared); + + expect(aliceSharedView.length).to.equal(bitsLength / 8); + expect(aliceSharedView).to.deep.equal(bobSharedView); + }, +); + +test( + SUITE, + 'X448 - generateKey, exportKey, importKey, deriveBits', + async () => { + const format = 'spki'; // Use SPKI for X448 public key export test + const algorithm = { name: 'X448' } as const; + + // 1. Generate Keys + const aliceKeys = (await subtle.generateKey(algorithm, true, [ + 'deriveKey', + 'deriveBits', + ])) as CryptoKeyPair; + + const bobKeys = (await subtle.generateKey(algorithm, true, [ + 'deriveKey', + 'deriveBits', + ])) as CryptoKeyPair; + + expect((aliceKeys.publicKey as CryptoKey).algorithm.name).to.equal('X448'); + expect((aliceKeys.privateKey as CryptoKey).algorithm.name).to.equal('X448'); + + // 2. Export Keys + const alicePubSpki = await subtle.exportKey( + format, + aliceKeys.publicKey as CryptoKey, + ); + const bobPubSpki = await subtle.exportKey( + format, + bobKeys.publicKey as CryptoKey, + ); + + // 3. Import Keys + const alicePubImported = await subtle.importKey( + format, + alicePubSpki, + algorithm, + true, + [], + ); + + const bobPubImported = await subtle.importKey( + format, + bobPubSpki, + algorithm, + true, + [], + ); + + // 4. Derive Bits + const bitsLength = 448; // X448 produces 56 bytes + const aliceShared = await subtle.deriveBits( + { name: 'X448', public: bobPubImported } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + aliceKeys.privateKey as CryptoKey, + bitsLength, + ); + + const bobShared = await subtle.deriveBits( + { name: 'X448', public: alicePubImported } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + bobKeys.privateKey as CryptoKey, + bitsLength, + ); + + // Verify shared secrets match + const aliceSharedView = new Uint8Array(aliceShared); + const bobSharedView = new Uint8Array(bobShared); + + expect(aliceSharedView.length).to.equal(56); + expect(aliceSharedView).to.deep.equal(bobSharedView); + }, +); diff --git a/packages/react-native-quick-crypto/cpp/cipher/HybridCipher.cpp b/packages/react-native-quick-crypto/cpp/cipher/HybridCipher.cpp index bc194834..72d7dd18 100644 --- a/packages/react-native-quick-crypto/cpp/cipher/HybridCipher.cpp +++ b/packages/react-native-quick-crypto/cpp/cipher/HybridCipher.cpp @@ -86,6 +86,14 @@ void HybridCipher::init(const std::shared_ptr cipher_key, const std ctx = nullptr; throw std::runtime_error("HybridCipher: Failed to set key/IV: " + std::string(err_buf)); } + + // For AES-KW (wrap ciphers), set the WRAP_ALLOW flag and disable padding + std::string cipher_name(cipher_type); + if (cipher_name.find("-wrap") != std::string::npos) { + // This flag is required for AES-KW in OpenSSL 3.x + EVP_CIPHER_CTX_set_flags(ctx, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW); + EVP_CIPHER_CTX_set_padding(ctx, 0); + } } std::shared_ptr HybridCipher::update(const std::shared_ptr& data) { @@ -100,7 +108,15 @@ std::shared_ptr HybridCipher::update(const std::shared_ptrdata(), in_len); + int ret = EVP_CipherUpdate(ctx, out, &out_len, native_data->data(), in_len); + + if (!ret) { + unsigned long err = ERR_get_error(); + char err_buf[256]; + ERR_error_string_n(err, err_buf, sizeof(err_buf)); + delete[] out; + throw std::runtime_error("Cipher update failed: " + std::string(err_buf)); + } // Create and return a new buffer of exact size needed return std::make_shared(out, out_len, [=]() { delete[] out; }); diff --git a/packages/react-native-quick-crypto/cpp/ed25519/HybridEdKeyPair.cpp b/packages/react-native-quick-crypto/cpp/ed25519/HybridEdKeyPair.cpp index a38ea677..97cfa186 100644 --- a/packages/react-native-quick-crypto/cpp/ed25519/HybridEdKeyPair.cpp +++ b/packages/react-native-quick-crypto/cpp/ed25519/HybridEdKeyPair.cpp @@ -14,14 +14,20 @@ std::shared_ptr HybridEdKeyPair::diffieHellman(const std::shared_pt using EVP_PKEY_ptr = std::unique_ptr; using EVP_PKEY_CTX_ptr = std::unique_ptr; + // Determine key type from curve name + int keyType = EVP_PKEY_X25519; + if (this->curve == "x448" || this->curve == "X448") { + keyType = EVP_PKEY_X448; + } + // 1. Create EVP_PKEY for private key (our key) - EVP_PKEY_ptr pkey_priv(EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, privateKey->data(), privateKey->size()), EVP_PKEY_free); + EVP_PKEY_ptr pkey_priv(EVP_PKEY_new_raw_private_key(keyType, NULL, privateKey->data(), privateKey->size()), EVP_PKEY_free); if (!pkey_priv) { throw std::runtime_error("Failed to create private key: " + getOpenSSLError()); } // 2. Create EVP_PKEY for public key (peer's key) - EVP_PKEY_ptr pkey_pub(EVP_PKEY_new_raw_public_key(EVP_PKEY_X25519, NULL, publicKey->data(), publicKey->size()), EVP_PKEY_free); + EVP_PKEY_ptr pkey_pub(EVP_PKEY_new_raw_public_key(keyType, NULL, publicKey->data(), publicKey->size()), EVP_PKEY_free); if (!pkey_pub) { throw std::runtime_error("Failed to create public key: " + getOpenSSLError()); } diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index a28c2729..33b47d0f 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -153,4 +153,4 @@ "nitro-codegen", "react-native-nitro-modules" ] -} +} \ No newline at end of file diff --git a/packages/react-native-quick-crypto/src/ed.ts b/packages/react-native-quick-crypto/src/ed.ts index 2ebd4ad0..bee1c9a7 100644 --- a/packages/react-native-quick-crypto/src/ed.ts +++ b/packages/react-native-quick-crypto/src/ed.ts @@ -361,3 +361,105 @@ export async function ed_generateKeyPairWebCrypto( return { publicKey, privateKey }; } + +export async function x_generateKeyPairWebCrypto( + type: 'x25519' | 'x448', + extractable: boolean, + keyUsages: KeyUsage[], +): Promise { + if (hasAnyNotIn(keyUsages, ['deriveKey', 'deriveBits'])) { + throw lazyDOMException(`Unsupported key usage for ${type}`, 'SyntaxError'); + } + + const publicUsages = getUsagesUnion(keyUsages); + const privateUsages = getUsagesUnion(keyUsages, 'deriveKey', 'deriveBits'); + + if (privateUsages.length === 0) { + throw lazyDOMException('Usages cannot be empty', 'SyntaxError'); + } + + // Request DER-encoded SPKI for public key, PKCS8 for private key + const config = { + publicFormat: KFormatType.DER, + publicType: KeyEncoding.SPKI, + privateFormat: KFormatType.DER, + privateType: KeyEncoding.PKCS8, + }; + const ed = new Ed(type, config); + await ed.generateKeyPair(); + + const algorithmName = type === 'x25519' ? 'X25519' : 'X448'; + + const publicKeyData = ed.getPublicKey(); + const privateKeyData = ed.getPrivateKey(); + + const pub = KeyObject.createKeyObject( + 'public', + publicKeyData, + KFormatType.DER, + KeyEncoding.SPKI, + ) as PublicKeyObject; + const publicKey = new CryptoKey( + pub, + { name: algorithmName } as SubtleAlgorithm, + publicUsages, + true, + ); + + const priv = KeyObject.createKeyObject( + 'private', + privateKeyData, + KFormatType.DER, + KeyEncoding.PKCS8, + ) as PrivateKeyObjectClass; + const privateKey = new CryptoKey( + priv, + { name: algorithmName } as SubtleAlgorithm, + privateUsages, + extractable, + ); + + return { publicKey, privateKey }; +} + +export function xDeriveBits( + algorithm: SubtleAlgorithm, + baseKey: CryptoKey, + length: number | null, +): ArrayBuffer { + const publicParams = algorithm as SubtleAlgorithm & { public?: CryptoKey }; + const publicKey = publicParams.public; + + if (!publicKey) { + throw new Error('Public key is required for X25519/X448 derivation'); + } + + if (baseKey.algorithm.name !== publicKey.algorithm.name) { + throw new Error('Keys must be of the same algorithm'); + } + + const type = baseKey.algorithm.name.toLowerCase() as 'x25519' | 'x448'; + const ed = new Ed(type, {}); + + // Export raw keys + const privateKeyBytes = baseKey.keyObject.handle.exportKey(); + const publicKeyBytes = publicKey.keyObject.handle.exportKey(); + + const privateKeyTyped = new Uint8Array(privateKeyBytes); + const publicKeyTyped = new Uint8Array(publicKeyBytes); + + const secret = ed.getSharedSecret(privateKeyTyped, publicKeyTyped); + + // If length is null, return the full secret + if (length === null) { + return secret; + } + + // If length is specified, truncate + const byteLength = Math.ceil(length / 8); + if (secret.byteLength >= byteLength) { + return secret.slice(0, byteLength); + } + + throw new Error('Derived key is shorter than requested length'); +} diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index 540dc270..53d5b8ea 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -15,6 +15,7 @@ import type { AesCbcParams, AesGcmParams, RsaOaepParams, + ChaCha20Poly1305Params, } from './utils'; import { KFormatType, KeyEncoding } from './utils'; import { @@ -41,7 +42,12 @@ import { rsa_generateKeyPair } from './rsa'; import { getRandomValues } from './random'; import { createHmac } from './hmac'; import { createSign, createVerify } from './keys/signVerify'; -import { ed_generateKeyPairWebCrypto, Ed } from './ed'; +import { + ed_generateKeyPairWebCrypto, + x_generateKeyPairWebCrypto, + xDeriveBits, + Ed, +} from './ed'; import { mldsa_generateKeyPairWebCrypto, type MlDsaVariant } from './mldsa'; // import { pbkdf2DeriveBits } from './pbkdf2'; // import { aesCipher, aesGenerateKey, aesImportKey, getAlgorithmName } from './aes'; @@ -369,6 +375,156 @@ async function aesGcmCipher( } } +async function aesKwCipher( + mode: CipherOrWrapMode, + key: CryptoKey, + data: ArrayBuffer, +): Promise { + const isWrap = mode === CipherOrWrapMode.kWebCryptoCipherEncrypt; + + // AES-KW requires input to be a multiple of 8 bytes (64 bits) + if (data.byteLength % 8 !== 0) { + throw lazyDOMException( + `AES-KW input length must be a multiple of 8 bytes, got ${data.byteLength}`, + 'OperationError', + ); + } + + // AES-KW requires at least 16 bytes of input (128 bits) + if (isWrap && data.byteLength < 16) { + throw lazyDOMException( + `AES-KW input must be at least 16 bytes, got ${data.byteLength}`, + 'OperationError', + ); + } + + // Get cipher type based on key length + const keyLength = (key.algorithm as { length: number }).length; + // Use aes*-wrap for both operations (matching Node.js) + const cipherType = `aes${keyLength}-wrap`; + + // Export key material + const exportedKey = key.keyObject.export(); + const cipherKey = bufferLikeToArrayBuffer(exportedKey); + + // AES-KW uses a default IV as specified in RFC 3394 + const defaultWrapIV = new Uint8Array([ + 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, + ]); + + const factory = + NitroModules.createHybridObject('CipherFactory'); + + const cipher = factory.createCipher({ + isCipher: isWrap, + cipherType, + cipherKey, + iv: defaultWrapIV.buffer, // RFC 3394 default IV for AES-KW + }); + + // Process data + const updated = cipher.update(data); + const final = cipher.final(); + + // Concatenate results + const result = new Uint8Array(updated.byteLength + final.byteLength); + result.set(new Uint8Array(updated), 0); + result.set(new Uint8Array(final), updated.byteLength); + + return result.buffer; +} + +async function chaCha20Poly1305Cipher( + mode: CipherOrWrapMode, + key: CryptoKey, + data: ArrayBuffer, + algorithm: ChaCha20Poly1305Params, +): Promise { + const { iv, additionalData, tagLength = 128 } = algorithm; + + // Validate IV (must be 12 bytes for ChaCha20-Poly1305) + const ivBuffer = bufferLikeToArrayBuffer(iv); + if (!ivBuffer || ivBuffer.byteLength !== 12) { + throw lazyDOMException( + 'ChaCha20-Poly1305 IV must be exactly 12 bytes', + 'OperationError', + ); + } + + // Validate tag length (only 128-bit supported) + if (tagLength !== 128) { + throw lazyDOMException( + 'ChaCha20-Poly1305 only supports 128-bit auth tags', + 'NotSupportedError', + ); + } + + const tagByteLength = 16; // 128 bits = 16 bytes + + // Create cipher using existing ChaCha20-Poly1305 implementation + const factory = + NitroModules.createHybridObject('CipherFactory'); + const cipher = factory.createCipher({ + isCipher: mode === CipherOrWrapMode.kWebCryptoCipherEncrypt, + cipherType: 'chacha20-poly1305', + cipherKey: bufferLikeToArrayBuffer(key.keyObject.export()), + iv: ivBuffer, + authTagLen: tagByteLength, + }); + + let processData: ArrayBuffer; + let authTag: ArrayBuffer | undefined; + + if (mode === CipherOrWrapMode.kWebCryptoCipherDecrypt) { + // For decryption, extract auth tag from end of data + const dataView = new Uint8Array(data); + + if (dataView.byteLength < tagByteLength) { + throw lazyDOMException( + 'The provided data is too small.', + 'OperationError', + ); + } + + // Split data and tag + const ciphertextLength = dataView.byteLength - tagByteLength; + processData = dataView.slice(0, ciphertextLength).buffer; + authTag = dataView.slice(ciphertextLength).buffer; + + // Set auth tag for verification + cipher.setAuthTag(authTag); + } else { + processData = data; + } + + // Set additional authenticated data if provided + if (additionalData) { + cipher.setAAD(bufferLikeToArrayBuffer(additionalData)); + } + + // Process data + const updated = cipher.update(processData); + const final = cipher.final(); + + if (mode === CipherOrWrapMode.kWebCryptoCipherEncrypt) { + // For encryption, append auth tag to result + const tag = cipher.getAuthTag(); + const result = new Uint8Array( + updated.byteLength + final.byteLength + tag.byteLength, + ); + result.set(new Uint8Array(updated), 0); + result.set(new Uint8Array(final), updated.byteLength); + result.set(new Uint8Array(tag), updated.byteLength + final.byteLength); + return result.buffer; + } else { + // For decryption, just concatenate plaintext + const result = new Uint8Array(updated.byteLength + final.byteLength); + result.set(new Uint8Array(updated), 0); + result.set(new Uint8Array(final), updated.byteLength); + return result.buffer; + } +} + async function aesGenerateKey( algorithm: AesKeyGenParams, extractable: boolean, @@ -737,7 +893,12 @@ function edImportKey( const { name } = algorithm; // Validate usages - if (hasAnyNotIn(keyUsages, ['sign', 'verify'])) { + const isX = name === 'X25519' || name === 'X448'; + const allowedUsages: KeyUsage[] = isX + ? ['deriveKey', 'deriveBits'] + : ['sign', 'verify']; + + if (hasAnyNotIn(keyUsages, allowedUsages)) { throw lazyDOMException( `Unsupported key usage for ${name} key`, 'SyntaxError', @@ -853,8 +1014,12 @@ const exportKeySpki = async ( case 'Ed25519': // Fall through case 'Ed448': + // Fall through + case 'X25519': + // Fall through + case 'X448': if (key.type === 'public') { - // Export Ed key in SPKI DER format + // Export Ed/X key in SPKI DER format return bufferLikeToArrayBuffer( key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.SPKI), ); @@ -902,8 +1067,12 @@ const exportKeyPkcs8 = async ( case 'Ed25519': // Fall through case 'Ed448': + // Fall through + case 'X25519': + // Fall through + case 'X448': if (key.type === 'private') { - // Export Ed key in PKCS8 DER format + // Export Ed/X key in PKCS8 DER format return bufferLikeToArrayBuffer( key.keyObject.handle.exportKey(KFormatType.DER, KeyEncoding.PKCS8), ); @@ -937,6 +1106,19 @@ const exportKeyRaw = (key: CryptoKey): ArrayBuffer | unknown => { return ecExportKey(key, KWebCryptoKeyFormat.kWebCryptoKeyFormatRaw); } break; + case 'Ed25519': + // Fall through + case 'Ed448': + // Fall through + case 'X25519': + // Fall through + case 'X448': + if (key.type === 'public') { + // Export raw public key + const exported = key.keyObject.handle.exportKey(); + return bufferLikeToArrayBuffer(exported); + } + break; case 'AES-CTR': // Fall through case 'AES-CBC': @@ -945,6 +1127,8 @@ const exportKeyRaw = (key: CryptoKey): ArrayBuffer | unknown => { // Fall through case 'AES-KW': // Fall through + case 'ChaCha20-Poly1305': + // Fall through case 'HMAC': { const exported = key.keyObject.export(); // Convert Buffer to ArrayBuffer @@ -994,6 +1178,8 @@ const exportKeyJWK = (key: CryptoKey): ArrayBuffer | unknown => { case 'AES-GCM': // Fall through case 'AES-KW': + // Fall through + case 'ChaCha20-Poly1305': if (key.algorithm.length === undefined) { throw lazyDOMException( `Algorithm ${key.algorithm.name} missing required length property`, @@ -1290,6 +1476,15 @@ const cipherOrWrap = async ( // Fall through case 'AES-GCM': return aesCipher(mode, key, data, algorithm); + case 'AES-KW': + return aesKwCipher(mode, key, data); + case 'ChaCha20-Poly1305': + return chaCha20Poly1305Cipher( + mode, + key, + data, + algorithm as ChaCha20Poly1305Params, + ); } }; @@ -1325,20 +1520,79 @@ export class Subtle { baseKey: CryptoKey, length: number, ): Promise { - if (!baseKey.keyUsages.includes('deriveBits')) { - throw new Error('baseKey does not have deriveBits usage'); + // Allow either deriveBits OR deriveKey usage (WebCrypto spec allows both) + if ( + !baseKey.keyUsages.includes('deriveBits') && + !baseKey.keyUsages.includes('deriveKey') + ) { + throw new Error('baseKey does not have deriveBits or deriveKey usage'); } if (baseKey.algorithm.name !== algorithm.name) throw new Error('Key algorithm mismatch'); switch (algorithm.name) { case 'PBKDF2': return pbkdf2DeriveBits(algorithm, baseKey, length); + case 'X25519': + // Fall through + case 'X448': + return xDeriveBits(algorithm, baseKey, length); } throw new Error( `'subtle.deriveBits()' for ${algorithm.name} is not implemented.`, ); } + async deriveKey( + algorithm: SubtleAlgorithm, + baseKey: CryptoKey, + derivedKeyAlgorithm: SubtleAlgorithm, + extractable: boolean, + keyUsages: KeyUsage[], + ): Promise { + // Validate baseKey usage + if ( + !baseKey.usages.includes('deriveKey') && + !baseKey.usages.includes('deriveBits') + ) { + throw lazyDOMException( + 'baseKey does not have deriveKey or deriveBits usage', + 'InvalidAccessError', + ); + } + + // Calculate required key length + const length = getKeyLength(derivedKeyAlgorithm); + + // Step 1: Derive bits + let derivedBits: ArrayBuffer; + if (baseKey.algorithm.name !== algorithm.name) + throw new Error('Key algorithm mismatch'); + + switch (algorithm.name) { + case 'PBKDF2': + derivedBits = await pbkdf2DeriveBits(algorithm, baseKey, length); + break; + case 'X25519': + // Fall through + case 'X448': + derivedBits = await xDeriveBits(algorithm, baseKey, length); + break; + default: + throw new Error( + `'subtle.deriveKey()' for ${algorithm.name} is not implemented.`, + ); + } + + // Step 2: Import as key + return this.importKey( + 'raw', + derivedBits, + derivedKeyAlgorithm, + extractable, + keyUsages, + ); + } + async encrypt( algorithm: EncryptDecryptParams, key: CryptoKey, @@ -1372,6 +1626,115 @@ export class Subtle { } } + async wrapKey( + format: ImportFormat, + key: CryptoKey, + wrappingKey: CryptoKey, + wrapAlgorithm: EncryptDecryptParams, + ): Promise { + // Validate wrappingKey usage + if (!wrappingKey.usages.includes('wrapKey')) { + throw lazyDOMException( + 'wrappingKey does not have wrapKey usage', + 'InvalidAccessError', + ); + } + + // Step 1: Export the key + const exported = await this.exportKey(format, key); + + // Step 2: Convert to ArrayBuffer if JWK + let keyData: ArrayBuffer; + if (format === 'jwk') { + const jwkString = JSON.stringify(exported); + const buffer = SBuffer.from(jwkString, 'utf8'); + + // For AES-KW, pad to multiple of 8 bytes (accounting for null terminator) + if (wrapAlgorithm.name === 'AES-KW') { + const length = buffer.length; + // Add 1 for null terminator, then pad to multiple of 8 + const paddedLength = Math.ceil((length + 1) / 8) * 8; + const paddedBuffer = SBuffer.alloc(paddedLength); + buffer.copy(paddedBuffer); + // Null terminator for JSON string (remaining bytes are already zeros from alloc) + paddedBuffer.writeUInt8(0, length); + keyData = bufferLikeToArrayBuffer(paddedBuffer); + } else { + keyData = bufferLikeToArrayBuffer(buffer); + } + } else { + keyData = exported as ArrayBuffer; + } + + // Step 3: Encrypt the exported key + return cipherOrWrap( + CipherOrWrapMode.kWebCryptoCipherEncrypt, + wrapAlgorithm, + wrappingKey, + keyData, + 'wrapKey', + ); + } + + async unwrapKey( + format: ImportFormat, + wrappedKey: BufferLike, + unwrappingKey: CryptoKey, + unwrapAlgorithm: EncryptDecryptParams, + unwrappedKeyAlgorithm: SubtleAlgorithm | AnyAlgorithm, + extractable: boolean, + keyUsages: KeyUsage[], + ): Promise { + // Validate unwrappingKey usage + if (!unwrappingKey.usages.includes('unwrapKey')) { + throw lazyDOMException( + 'unwrappingKey does not have unwrapKey usage', + 'InvalidAccessError', + ); + } + + // Step 1: Decrypt the wrapped key + const decrypted = await cipherOrWrap( + CipherOrWrapMode.kWebCryptoCipherDecrypt, + unwrapAlgorithm, + unwrappingKey, + bufferLikeToArrayBuffer(wrappedKey), + 'unwrapKey', + ); + + // Step 2: Convert to appropriate format + let keyData: BufferLike | JWK; + if (format === 'jwk') { + const buffer = SBuffer.from(decrypted); + // For AES-KW, the data may be padded - find the null terminator + let jwkString: string; + if (unwrapAlgorithm.name === 'AES-KW') { + // Find the null terminator (if present) to get the original string + const nullIndex = buffer.indexOf(0); + if (nullIndex !== -1) { + jwkString = buffer.toString('utf8', 0, nullIndex); + } else { + // No null terminator, try to parse the whole buffer + jwkString = buffer.toString('utf8').trim(); + } + } else { + jwkString = buffer.toString('utf8'); + } + keyData = JSON.parse(jwkString) as JWK; + } else { + keyData = decrypted; + } + + // Step 3: Import the key + return this.importKey( + format, + keyData, + unwrappedKeyAlgorithm, + extractable, + keyUsages, + ); + } + async generateKey( algorithm: SubtleAlgorithm, extractable: boolean, @@ -1411,6 +1774,26 @@ export class Subtle { keyUsages, ); break; + case 'ChaCha20-Poly1305': { + const length = (algorithm as AesKeyGenParams).length ?? 256; + + if (length !== 256) { + throw lazyDOMException( + 'ChaCha20-Poly1305 only supports 256-bit keys', + 'NotSupportedError', + ); + } + + result = await aesGenerateKey( + { + name: 'ChaCha20-Poly1305', + length: 256, + } as unknown as AesKeyGenParams, + extractable, + keyUsages, + ); + break; + } case 'HMAC': result = await hmacGenerateKey(algorithm, extractable, keyUsages); break; @@ -1436,6 +1819,16 @@ export class Subtle { ); checkCryptoKeyPairUsages(result as CryptoKeyPair); break; + case 'X25519': + // Fall through + case 'X448': + result = await x_generateKeyPairWebCrypto( + algorithm.name.toLowerCase() as 'x25519' | 'x448', + extractable, + keyUsages, + ); + checkCryptoKeyPairUsages(result as CryptoKeyPair); + break; default: throw new Error( `'subtle.generateKey()' is not implemented for ${algorithm.name}. @@ -1496,6 +1889,8 @@ export class Subtle { case 'AES-GCM': // Fall through case 'AES-KW': + // Fall through + case 'ChaCha20-Poly1305': result = await aesImportKey( normalizedAlgorithm, format, @@ -1513,6 +1908,10 @@ export class Subtle { keyUsages, ); break; + case 'X25519': + // Fall through + case 'X448': + // Fall through case 'Ed25519': // Fall through case 'Ed448': @@ -1560,7 +1959,43 @@ export class Subtle { key: CryptoKey, data: BufferLike, ): Promise { - return signVerify(algorithm, key, data) as ArrayBuffer; + const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'sign'); + + if (normalizedAlgorithm.name === 'HMAC') { + // Validate key usage + if (!key.usages.includes('sign')) { + throw lazyDOMException( + 'Key does not have sign usage', + 'InvalidAccessError', + ); + } + + // Get hash algorithm from key or algorithm params + // Hash can be either a string or an object with name property + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alg = normalizedAlgorithm as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keyAlg = key.algorithm as any; + let hashAlgorithm = 'SHA-256'; + + if (typeof alg.hash === 'string') { + hashAlgorithm = alg.hash; + } else if (alg.hash?.name) { + hashAlgorithm = alg.hash.name; + } else if (typeof keyAlg.hash === 'string') { + hashAlgorithm = keyAlg.hash; + } else if (keyAlg.hash?.name) { + hashAlgorithm = keyAlg.hash.name; + } + + // Create HMAC and sign + const keyData = key.keyObject.export(); + const hmac = createHmac(hashAlgorithm, keyData); + hmac.update(bufferLikeToArrayBuffer(data)); + return bufferLikeToArrayBuffer(hmac.digest()); + } + + return signVerify(normalizedAlgorithm, key, data) as ArrayBuffer; } async verify( @@ -1568,9 +2003,84 @@ export class Subtle { key: CryptoKey, signature: BufferLike, data: BufferLike, - ): Promise { - return signVerify(algorithm, key, data, signature) as ArrayBuffer; + ): Promise { + const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'verify'); + + if (normalizedAlgorithm.name === 'HMAC') { + // Validate key usage + if (!key.usages.includes('verify')) { + throw lazyDOMException( + 'Key does not have verify usage', + 'InvalidAccessError', + ); + } + + // Get hash algorithm + // Hash can be either a string or an object with name property + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alg = normalizedAlgorithm as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const keyAlg = key.algorithm as any; + let hashAlgorithm = 'SHA-256'; + + if (typeof alg.hash === 'string') { + hashAlgorithm = alg.hash; + } else if (alg.hash?.name) { + hashAlgorithm = alg.hash.name; + } else if (typeof keyAlg.hash === 'string') { + hashAlgorithm = keyAlg.hash; + } else if (keyAlg.hash?.name) { + hashAlgorithm = keyAlg.hash.name; + } + + // Create HMAC and compute expected signature + const keyData = key.keyObject.export(); + const hmac = createHmac(hashAlgorithm, keyData); + const dataBuffer = bufferLikeToArrayBuffer(data); + hmac.update(dataBuffer); + const expectedDigest = hmac.digest(); + const expected = new Uint8Array(bufferLikeToArrayBuffer(expectedDigest)); + + // Constant-time comparison + const signatureArray = new Uint8Array(bufferLikeToArrayBuffer(signature)); + if (expected.length !== signatureArray.length) { + return false; + } + + // Manual constant-time comparison + let result = 0; + for (let i = 0; i < expected.length; i++) { + result |= expected[i]! ^ signatureArray[i]!; + } + return result === 0; + } + + return signVerify(normalizedAlgorithm, key, data, signature) as boolean; } } export const subtle = new Subtle(); + +function getKeyLength(algorithm: SubtleAlgorithm): number { + const name = algorithm.name; + + switch (name) { + case 'AES-CTR': + case 'AES-CBC': + case 'AES-GCM': + case 'AES-KW': + case 'ChaCha20-Poly1305': + return (algorithm as AesKeyGenParams).length || 256; + + case 'HMAC': { + const hmacAlg = algorithm as { length?: number }; + return hmacAlg.length || 256; + } + + default: + throw lazyDOMException( + `Cannot determine key length for ${name}`, + 'NotSupportedError', + ); + } +} diff --git a/packages/react-native-quick-crypto/src/utils/types.ts b/packages/react-native-quick-crypto/src/utils/types.ts index 164a68d6..0c14c036 100644 --- a/packages/react-native-quick-crypto/src/utils/types.ts +++ b/packages/react-native-quick-crypto/src/utils/types.ts @@ -106,7 +106,9 @@ export type EncryptDecryptAlgorithm = | 'RSA-OAEP' | 'AES-CTR' | 'AES-CBC' - | 'AES-GCM'; + | 'AES-GCM' + | 'AES-KW' + | 'ChaCha20-Poly1305'; export type RsaOaepParams = { name: 'RSA-OAEP'; @@ -131,6 +133,13 @@ export type AesGcmParams = { additionalData?: BufferLike; }; +export type ChaCha20Poly1305Params = { + name: 'ChaCha20-Poly1305'; + iv: BufferLike; + tagLength?: 128; + additionalData?: BufferLike; +}; + export type AesKwParams = { name: 'AES-KW'; wrappingKey?: BufferLike; @@ -149,7 +158,9 @@ export type EncryptDecryptParams = | AesCbcParams | AesCtrParams | AesGcmParams - | RsaOaepParams; + | AesKwParams + | RsaOaepParams + | ChaCha20Poly1305Params; export type AnyAlgorithm = | DigestAlgorithm @@ -446,7 +457,9 @@ export type Operation = | 'generateKey' | 'importKey' | 'exportKey' - | 'deriveBits'; + | 'deriveBits' + | 'wrapKey' + | 'unwrapKey'; export interface KeyPairOptions { namedCurve: string;