diff --git a/example/src/tests/subtle/import_export.ts b/example/src/tests/subtle/import_export.ts index a24d485b..7fd679a9 100644 --- a/example/src/tests/subtle/import_export.ts +++ b/example/src/tests/subtle/import_export.ts @@ -383,6 +383,108 @@ test(SUITE, 'EC import raw / export spki (osp)', async () => { ); }); +// #1005 D.1 — SPKI/PKCS#8 import must reject named-curve mismatch. +test(SUITE, 'EC SPKI import rejects named-curve mismatch (#1005)', async () => { + const p256Spki = base64ToArrayBuffer( + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENlFpbMBNfCY6Lhj9A/clefyxJVIXGJ0y6CcZ/cbbyyebvN6T0aNPvpQyFdUwRtYvFHlYbqIZOM8AoqdPcnSMIA==', + ); + await assertThrowsAsync( + () => + subtle.importKey( + 'spki', + p256Spki, + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['verify'], + ), + 'DataError', + ); +}); + +test( + SUITE, + 'EC PKCS#8 import rejects named-curve mismatch (#1005)', + async () => { + const p256Pkcs8 = base64ToArrayBuffer( + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgDxBsPQPIgMuMyQbxzbb9toew6Ev6e9O6ZhpxLNgmAEqhRANCAARfSYxhH+6V5lIg+M3O0iQBLf+53kuE2luIgWnp81/Ya1Gybj8tl4tJVu1GEwcTyt8hoA7vRACmCHnI5B1+bNpS', + ); + await assertThrowsAsync( + () => + subtle.importKey( + 'pkcs8', + p256Pkcs8, + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['sign'], + ), + 'DataError', + ); + }, +); + +// #1005 D.3 — SPKI export must always emit uncompressed point form, even when +// the key was imported in compressed form. +test(SUITE, 'EC SPKI export forces uncompressed point (#1005)', async () => { + // Reuse the P-256 raw point used above. + const raw = new Uint8Array( + base64ToArrayBuffer( + 'BDZRaWzATXwmOi4Y/QP3JXn8sSVSFxidMugnGf3G28snm7zek9GjT76UMhXVMEbWLxR5WG6iGTjPAKKnT3J0jCA=', + ), + ); + const x = raw.slice(1, 33); + const y = raw.slice(33); + const compressedPrefix = (y[31]! & 1) === 1 ? 0x03 : 0x02; + + // Hand-rolled compressed P-256 SPKI: 26-byte ASN.1 wrapper + 33-byte point. + const compressedSpki = new Uint8Array([ + 0x30, + 0x39, + 0x30, + 0x13, + 0x06, + 0x07, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x02, + 0x01, + 0x06, + 0x08, + 0x2a, + 0x86, + 0x48, + 0xce, + 0x3d, + 0x03, + 0x01, + 0x07, + 0x03, + 0x22, + 0x00, + compressedPrefix, + ...x, + ]); + expect(compressedSpki.length).to.equal(59); + + const key = await subtle.importKey( + 'spki', + compressedSpki.buffer.slice( + compressedSpki.byteOffset, + compressedSpki.byteOffset + compressedSpki.byteLength, + ), + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'], + ); + + const exported = (await subtle.exportKey('spki', key)) as ArrayBuffer; + expect(exported.byteLength).to.equal(91); + // Uncompressed-point prefix lives at offset 26 in P-256 SPKI. + expect(new Uint8Array(exported)[26]).to.equal(0x04); +}); + // // TODO: enable when generateKey() is implemented // // from Node.js https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-export-import.js#L217-L273 // test(SUITE, 'EC import / export key pairs (node)', async () => { diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp index 98095418..86eb18e1 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp @@ -1016,4 +1016,16 @@ double HybridKeyObjectHandle::getSymmetricKeySize() { return static_cast(data_.GetSymmetricKeySize()); } +bool HybridKeyObjectHandle::checkEcKeyData() { + const auto& pkey = data_.GetAsymmetricKey(); + if (!pkey || EVP_PKEY_id(pkey.get()) != EVP_PKEY_EC) { + return false; + } + auto ctx = pkey.newCtx(); + if (!ctx) { + return false; + } + return data_.GetKeyType() == KeyType::PRIVATE ? ctx.privateCheck() : ctx.publicCheck(); +} + } // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp index b2eafc6d..03c08e80 100644 --- a/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp +++ b/packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp @@ -41,6 +41,8 @@ class HybridKeyObjectHandle : public HybridKeyObjectHandleSpec { double getSymmetricKeySize() override; + bool checkEcKeyData() override; + const KeyObjectData& getKeyObjectData() const { return data_; } diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp index ae963c5f..e64c2e6f 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp @@ -24,6 +24,7 @@ namespace margelo::nitro::crypto { prototype.registerHybridMethod("keyDetail", &HybridKeyObjectHandleSpec::keyDetail); prototype.registerHybridMethod("keyEquals", &HybridKeyObjectHandleSpec::keyEquals); prototype.registerHybridMethod("getSymmetricKeySize", &HybridKeyObjectHandleSpec::getSymmetricKeySize); + prototype.registerHybridMethod("checkEcKeyData", &HybridKeyObjectHandleSpec::checkEcKeyData); }); } diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.hpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.hpp index 783a85c9..5bcb74c9 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.hpp +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.hpp @@ -85,6 +85,7 @@ namespace margelo::nitro::crypto { virtual KeyDetail keyDetail() = 0; virtual bool keyEquals(const std::shared_ptr& other) = 0; virtual double getSymmetricKeySize() = 0; + virtual bool checkEcKeyData() = 0; protected: // Hybrid Setup diff --git a/packages/react-native-quick-crypto/src/ec.ts b/packages/react-native-quick-crypto/src/ec.ts index 45ff3ace..9bd7cf51 100644 --- a/packages/react-native-quick-crypto/src/ec.ts +++ b/packages/react-native-quick-crypto/src/ec.ts @@ -230,6 +230,18 @@ export function ecImportKey( KFormatType.DER, format === 'spki' ? KeyEncoding.SPKI : KeyEncoding.PKCS8, ); + + // Validate the imported curve matches the requested algorithm.namedCurve. + const expectedAlias = + kNamedCurveAliases[namedCurve as keyof typeof kNamedCurveAliases]; + if (keyObject.handle.keyDetail().namedCurve !== expectedAlias) { + throw lazyDOMException('Named curve mismatch', 'DataError'); + } + } + + // Verify the public/private point lies on the named curve. + if (!keyObject.handle.checkEcKeyData()) { + throw lazyDOMException('Invalid keyData', 'DataError'); } return new CryptoKey(keyObject, algorithm, keyUsages, extractable); diff --git a/packages/react-native-quick-crypto/src/specs/keyObjectHandle.nitro.ts b/packages/react-native-quick-crypto/src/specs/keyObjectHandle.nitro.ts index 233bd19f..8bf018a0 100644 --- a/packages/react-native-quick-crypto/src/specs/keyObjectHandle.nitro.ts +++ b/packages/react-native-quick-crypto/src/specs/keyObjectHandle.nitro.ts @@ -36,4 +36,5 @@ export interface KeyObjectHandle keyDetail(): KeyDetail; keyEquals(other: KeyObjectHandle): boolean; getSymmetricKeySize(): number; + checkEcKeyData(): boolean; } diff --git a/packages/react-native-quick-crypto/src/subtle.ts b/packages/react-native-quick-crypto/src/subtle.ts index e180b753..83d4bd64 100644 --- a/packages/react-native-quick-crypto/src/subtle.ts +++ b/packages/react-native-quick-crypto/src/subtle.ts @@ -18,7 +18,8 @@ import type { RsaOaepParams, ChaCha20Poly1305Params, } from './utils'; -import { KFormatType, KeyEncoding, KeyType } from './utils'; +import { KFormatType, KeyEncoding, KeyType, kNamedCurveAliases } from './utils'; +import { Buffer } from '@craftzdog/react-native-buffer'; import { CryptoKey, KeyObject, @@ -168,15 +169,60 @@ function aliasKeyFormat(format: ImportFormat): ImportFormat { return format; } -// Placeholder implementations for missing functions +const kUncompressedSpkiLength: Record = { + 'P-256': 91, + 'P-384': 120, + 'P-521': 158, +}; + function ecExportKey(key: CryptoKey, format: KWebCryptoKeyFormat): ArrayBuffer { const keyObject = key.keyObject; if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatRaw) { return bufferLikeToArrayBuffer(keyObject.handle.exportKey()); } else if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatSPKI) { - const exported = keyObject.export({ format: 'der', type: 'spki' }); - return bufferLikeToArrayBuffer(exported); + const exported = bufferLikeToArrayBuffer( + keyObject.export({ format: 'der', type: 'spki' }), + ); + + // WebCrypto requires uncompressed point format for SPKI exports. + // If the key was imported in compressed form, re-export as uncompressed + // by reconstructing the point from the JWK x,y coordinates and + // round-tripping through initECRaw. + const namedCurve = key.algorithm.namedCurve; + const expected = + namedCurve === undefined + ? undefined + : kUncompressedSpkiLength[namedCurve]; + if (expected !== undefined && exported.byteLength !== expected) { + const jwk = keyObject.handle.exportJwk({}, false); + if (!jwk.x || !jwk.y) { + throw lazyDOMException( + 'Failed to re-export EC public key as uncompressed SPKI', + 'OperationError', + ); + } + const x = Buffer.from(jwk.x, 'base64url'); + const y = Buffer.from(jwk.y, 'base64url'); + const raw = new Uint8Array(1 + x.length + y.length); + raw[0] = 0x04; + raw.set(x, 1); + raw.set(y, 1 + x.length); + const tmp = + NitroModules.createHybridObject('KeyObjectHandle'); + const curveAlias = + kNamedCurveAliases[namedCurve as keyof typeof kNamedCurveAliases]; + if (!tmp.initECRaw(curveAlias, raw.buffer as ArrayBuffer)) { + throw lazyDOMException( + 'Failed to re-export EC public key as uncompressed SPKI', + 'OperationError', + ); + } + return bufferLikeToArrayBuffer( + tmp.exportKey(KFormatType.DER, KeyEncoding.SPKI), + ); + } + return exported; } else if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatPKCS8) { const exported = keyObject.export({ format: 'der', type: 'pkcs8' }); return bufferLikeToArrayBuffer(exported);