Skip to content

Commit 9155523

Browse files
authored
fix: EC named-curve match, on-curve check, uncompressed SPKI export (#1027)
1 parent dcf78c5 commit 9155523

8 files changed

Lines changed: 181 additions & 4 deletions

File tree

example/src/tests/subtle/import_export.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,108 @@ test(SUITE, 'EC import raw / export spki (osp)', async () => {
383383
);
384384
});
385385

386+
// #1005 D.1 — SPKI/PKCS#8 import must reject named-curve mismatch.
387+
test(SUITE, 'EC SPKI import rejects named-curve mismatch (#1005)', async () => {
388+
const p256Spki = base64ToArrayBuffer(
389+
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENlFpbMBNfCY6Lhj9A/clefyxJVIXGJ0y6CcZ/cbbyyebvN6T0aNPvpQyFdUwRtYvFHlYbqIZOM8AoqdPcnSMIA==',
390+
);
391+
await assertThrowsAsync(
392+
() =>
393+
subtle.importKey(
394+
'spki',
395+
p256Spki,
396+
{ name: 'ECDSA', namedCurve: 'P-384' },
397+
true,
398+
['verify'],
399+
),
400+
'DataError',
401+
);
402+
});
403+
404+
test(
405+
SUITE,
406+
'EC PKCS#8 import rejects named-curve mismatch (#1005)',
407+
async () => {
408+
const p256Pkcs8 = base64ToArrayBuffer(
409+
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgDxBsPQPIgMuMyQbxzbb9toew6Ev6e9O6ZhpxLNgmAEqhRANCAARfSYxhH+6V5lIg+M3O0iQBLf+53kuE2luIgWnp81/Ya1Gybj8tl4tJVu1GEwcTyt8hoA7vRACmCHnI5B1+bNpS',
410+
);
411+
await assertThrowsAsync(
412+
() =>
413+
subtle.importKey(
414+
'pkcs8',
415+
p256Pkcs8,
416+
{ name: 'ECDSA', namedCurve: 'P-384' },
417+
true,
418+
['sign'],
419+
),
420+
'DataError',
421+
);
422+
},
423+
);
424+
425+
// #1005 D.3 — SPKI export must always emit uncompressed point form, even when
426+
// the key was imported in compressed form.
427+
test(SUITE, 'EC SPKI export forces uncompressed point (#1005)', async () => {
428+
// Reuse the P-256 raw point used above.
429+
const raw = new Uint8Array(
430+
base64ToArrayBuffer(
431+
'BDZRaWzATXwmOi4Y/QP3JXn8sSVSFxidMugnGf3G28snm7zek9GjT76UMhXVMEbWLxR5WG6iGTjPAKKnT3J0jCA=',
432+
),
433+
);
434+
const x = raw.slice(1, 33);
435+
const y = raw.slice(33);
436+
const compressedPrefix = (y[31]! & 1) === 1 ? 0x03 : 0x02;
437+
438+
// Hand-rolled compressed P-256 SPKI: 26-byte ASN.1 wrapper + 33-byte point.
439+
const compressedSpki = new Uint8Array([
440+
0x30,
441+
0x39,
442+
0x30,
443+
0x13,
444+
0x06,
445+
0x07,
446+
0x2a,
447+
0x86,
448+
0x48,
449+
0xce,
450+
0x3d,
451+
0x02,
452+
0x01,
453+
0x06,
454+
0x08,
455+
0x2a,
456+
0x86,
457+
0x48,
458+
0xce,
459+
0x3d,
460+
0x03,
461+
0x01,
462+
0x07,
463+
0x03,
464+
0x22,
465+
0x00,
466+
compressedPrefix,
467+
...x,
468+
]);
469+
expect(compressedSpki.length).to.equal(59);
470+
471+
const key = await subtle.importKey(
472+
'spki',
473+
compressedSpki.buffer.slice(
474+
compressedSpki.byteOffset,
475+
compressedSpki.byteOffset + compressedSpki.byteLength,
476+
),
477+
{ name: 'ECDSA', namedCurve: 'P-256' },
478+
true,
479+
['verify'],
480+
);
481+
482+
const exported = (await subtle.exportKey('spki', key)) as ArrayBuffer;
483+
expect(exported.byteLength).to.equal(91);
484+
// Uncompressed-point prefix lives at offset 26 in P-256 SPKI.
485+
expect(new Uint8Array(exported)[26]).to.equal(0x04);
486+
});
487+
386488
// // TODO: enable when generateKey() is implemented
387489
// // from Node.js https://github.com/nodejs/node/blob/main/test/parallel/test-webcrypto-export-import.js#L217-L273
388490
// test(SUITE, 'EC import / export key pairs (node)', async () => {

packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,4 +1016,16 @@ double HybridKeyObjectHandle::getSymmetricKeySize() {
10161016
return static_cast<double>(data_.GetSymmetricKeySize());
10171017
}
10181018

1019+
bool HybridKeyObjectHandle::checkEcKeyData() {
1020+
const auto& pkey = data_.GetAsymmetricKey();
1021+
if (!pkey || EVP_PKEY_id(pkey.get()) != EVP_PKEY_EC) {
1022+
return false;
1023+
}
1024+
auto ctx = pkey.newCtx();
1025+
if (!ctx) {
1026+
return false;
1027+
}
1028+
return data_.GetKeyType() == KeyType::PRIVATE ? ctx.privateCheck() : ctx.publicCheck();
1029+
}
1030+
10191031
} // namespace margelo::nitro::crypto

packages/react-native-quick-crypto/cpp/keys/HybridKeyObjectHandle.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class HybridKeyObjectHandle : public HybridKeyObjectHandleSpec {
4141

4242
double getSymmetricKeySize() override;
4343

44+
bool checkEcKeyData() override;
45+
4446
const KeyObjectData& getKeyObjectData() const {
4547
return data_;
4648
}

packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.hpp

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-native-quick-crypto/src/ec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,18 @@ export function ecImportKey(
230230
KFormatType.DER,
231231
format === 'spki' ? KeyEncoding.SPKI : KeyEncoding.PKCS8,
232232
);
233+
234+
// Validate the imported curve matches the requested algorithm.namedCurve.
235+
const expectedAlias =
236+
kNamedCurveAliases[namedCurve as keyof typeof kNamedCurveAliases];
237+
if (keyObject.handle.keyDetail().namedCurve !== expectedAlias) {
238+
throw lazyDOMException('Named curve mismatch', 'DataError');
239+
}
240+
}
241+
242+
// Verify the public/private point lies on the named curve.
243+
if (!keyObject.handle.checkEcKeyData()) {
244+
throw lazyDOMException('Invalid keyData', 'DataError');
233245
}
234246

235247
return new CryptoKey(keyObject, algorithm, keyUsages, extractable);

packages/react-native-quick-crypto/src/specs/keyObjectHandle.nitro.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ export interface KeyObjectHandle
3636
keyDetail(): KeyDetail;
3737
keyEquals(other: KeyObjectHandle): boolean;
3838
getSymmetricKeySize(): number;
39+
checkEcKeyData(): boolean;
3940
}

packages/react-native-quick-crypto/src/subtle.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import type {
1818
RsaOaepParams,
1919
ChaCha20Poly1305Params,
2020
} from './utils';
21-
import { KFormatType, KeyEncoding, KeyType } from './utils';
21+
import { KFormatType, KeyEncoding, KeyType, kNamedCurveAliases } from './utils';
22+
import { Buffer } from '@craftzdog/react-native-buffer';
2223
import {
2324
CryptoKey,
2425
KeyObject,
@@ -168,15 +169,60 @@ function aliasKeyFormat(format: ImportFormat): ImportFormat {
168169
return format;
169170
}
170171

171-
// Placeholder implementations for missing functions
172+
const kUncompressedSpkiLength: Record<string, number> = {
173+
'P-256': 91,
174+
'P-384': 120,
175+
'P-521': 158,
176+
};
177+
172178
function ecExportKey(key: CryptoKey, format: KWebCryptoKeyFormat): ArrayBuffer {
173179
const keyObject = key.keyObject;
174180

175181
if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatRaw) {
176182
return bufferLikeToArrayBuffer(keyObject.handle.exportKey());
177183
} else if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatSPKI) {
178-
const exported = keyObject.export({ format: 'der', type: 'spki' });
179-
return bufferLikeToArrayBuffer(exported);
184+
const exported = bufferLikeToArrayBuffer(
185+
keyObject.export({ format: 'der', type: 'spki' }),
186+
);
187+
188+
// WebCrypto requires uncompressed point format for SPKI exports.
189+
// If the key was imported in compressed form, re-export as uncompressed
190+
// by reconstructing the point from the JWK x,y coordinates and
191+
// round-tripping through initECRaw.
192+
const namedCurve = key.algorithm.namedCurve;
193+
const expected =
194+
namedCurve === undefined
195+
? undefined
196+
: kUncompressedSpkiLength[namedCurve];
197+
if (expected !== undefined && exported.byteLength !== expected) {
198+
const jwk = keyObject.handle.exportJwk({}, false);
199+
if (!jwk.x || !jwk.y) {
200+
throw lazyDOMException(
201+
'Failed to re-export EC public key as uncompressed SPKI',
202+
'OperationError',
203+
);
204+
}
205+
const x = Buffer.from(jwk.x, 'base64url');
206+
const y = Buffer.from(jwk.y, 'base64url');
207+
const raw = new Uint8Array(1 + x.length + y.length);
208+
raw[0] = 0x04;
209+
raw.set(x, 1);
210+
raw.set(y, 1 + x.length);
211+
const tmp =
212+
NitroModules.createHybridObject<KeyObjectHandle>('KeyObjectHandle');
213+
const curveAlias =
214+
kNamedCurveAliases[namedCurve as keyof typeof kNamedCurveAliases];
215+
if (!tmp.initECRaw(curveAlias, raw.buffer as ArrayBuffer)) {
216+
throw lazyDOMException(
217+
'Failed to re-export EC public key as uncompressed SPKI',
218+
'OperationError',
219+
);
220+
}
221+
return bufferLikeToArrayBuffer(
222+
tmp.exportKey(KFormatType.DER, KeyEncoding.SPKI),
223+
);
224+
}
225+
return exported;
180226
} else if (format === KWebCryptoKeyFormat.kWebCryptoKeyFormatPKCS8) {
181227
const exported = keyObject.export({ format: 'der', type: 'pkcs8' });
182228
return bufferLikeToArrayBuffer(exported);

0 commit comments

Comments
 (0)