Skip to content

Commit 01ec471

Browse files
committed
fix: implement AES-KW (RFC 3394) for wrapKey/unwrapKey operations
Fixes wrapKey/unwrapKey operations with AES-KW algorithm that were failing in e2e tests on both iOS and Android. C++ changes (HybridCipher.cpp): - Add EVP_CIPHER_CTX_FLAG_WRAP_ALLOW flag for wrap ciphers (required in OpenSSL 3.x) - Disable padding for AES-KW ciphers - Add error checking to EVP_CipherUpdate to catch OpenSSL failures TypeScript changes (subtle.ts): - Use RFC 3394 default IV (0xa6a6a6a6a6a6a6a6) instead of empty IV - Fix JWK format padding calculation to account for null terminator - Add input validation for AES-KW (8-byte alignment, minimum 16 bytes) - Fix cipher type naming to match Node.js (aes*-wrap) - Handle null terminator when unwrapping JWK format All wrapKey/unwrapKey tests now passing.
1 parent e2c1c80 commit 01ec471

File tree

4 files changed

+77
-17
lines changed

4 files changed

+77
-17
lines changed

example/src/hooks/useTestsList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import '../tests/keys/public_cipher';
1919
import '../tests/keys/sign_verify_streaming';
2020
import '../tests/pbkdf2/pbkdf2_tests';
2121
import '../tests/random/random_tests';
22+
import '../tests/subtle/x25519_x448';
2223
import '../tests/subtle/deriveBits';
2324
import '../tests/subtle/derive_key';
2425
import '../tests/subtle/digest';
@@ -28,7 +29,6 @@ import '../tests/subtle/import_export';
2829
import '../tests/subtle/jwk_rfc7517_tests';
2930
import '../tests/subtle/sign_verify';
3031
import '../tests/subtle/wrap_unwrap';
31-
import '../tests/subtle/x25519_x448';
3232

3333
export const useTestsList = (): [
3434
TestSuites,

example/src/tests/subtle/import_export.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,11 +1483,6 @@ async function testImportSpki(
14831483
expect(key.extractable).to.equal(extractable);
14841484
expect(key.usages).to.deep.equal(publicUsages);
14851485
expect(key.algorithm.name).to.equal(name);
1486-
console.log('[RSA TEST DEBUG]', {
1487-
modulusLength: key.algorithm.modulusLength,
1488-
expected: parseInt(size, 10),
1489-
algorithm: JSON.stringify(key.algorithm),
1490-
});
14911486
expect(key.algorithm.modulusLength).to.equal(parseInt(size, 10));
14921487
expect(key.algorithm.publicExponent).to.deep.equal(new Uint8Array([1, 0, 1]));
14931488
expect((key.algorithm.hash as { name: string }).name).to.equal(hash);

packages/react-native-quick-crypto/cpp/cipher/HybridCipher.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ void HybridCipher::init(const std::shared_ptr<ArrayBuffer> cipher_key, const std
8686
ctx = nullptr;
8787
throw std::runtime_error("HybridCipher: Failed to set key/IV: " + std::string(err_buf));
8888
}
89+
90+
// For AES-KW (wrap ciphers), set the WRAP_ALLOW flag and disable padding
91+
std::string cipher_name(cipher_type);
92+
if (cipher_name.find("-wrap") != std::string::npos) {
93+
// This flag is required for AES-KW in OpenSSL 3.x
94+
EVP_CIPHER_CTX_set_flags(ctx, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW);
95+
EVP_CIPHER_CTX_set_padding(ctx, 0);
96+
}
8997
}
9098

9199
std::shared_ptr<ArrayBuffer> HybridCipher::update(const std::shared_ptr<ArrayBuffer>& data) {
@@ -100,7 +108,15 @@ std::shared_ptr<ArrayBuffer> HybridCipher::update(const std::shared_ptr<ArrayBuf
100108
uint8_t* out = new uint8_t[out_len];
101109
// Perform the cipher update operation. The real size of the output is
102110
// returned in out_len
103-
EVP_CipherUpdate(ctx, out, &out_len, native_data->data(), in_len);
111+
int ret = EVP_CipherUpdate(ctx, out, &out_len, native_data->data(), in_len);
112+
113+
if (!ret) {
114+
unsigned long err = ERR_get_error();
115+
char err_buf[256];
116+
ERR_error_string_n(err, err_buf, sizeof(err_buf));
117+
delete[] out;
118+
throw std::runtime_error("Cipher update failed: " + std::string(err_buf));
119+
}
104120

105121
// Create and return a new buffer of exact size needed
106122
return std::make_shared<NativeArrayBuffer>(out, out_len, [=]() { delete[] out; });

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

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -380,23 +380,46 @@ async function aesKwCipher(
380380
key: CryptoKey,
381381
data: ArrayBuffer,
382382
): Promise<ArrayBuffer> {
383+
const isWrap = mode === CipherOrWrapMode.kWebCryptoCipherEncrypt;
384+
385+
// AES-KW requires input to be a multiple of 8 bytes (64 bits)
386+
if (data.byteLength % 8 !== 0) {
387+
throw lazyDOMException(
388+
`AES-KW input length must be a multiple of 8 bytes, got ${data.byteLength}`,
389+
'OperationError',
390+
);
391+
}
392+
393+
// AES-KW requires at least 16 bytes of input (128 bits)
394+
if (isWrap && data.byteLength < 16) {
395+
throw lazyDOMException(
396+
`AES-KW input must be at least 16 bytes, got ${data.byteLength}`,
397+
'OperationError',
398+
);
399+
}
400+
383401
// Get cipher type based on key length
384402
const keyLength = (key.algorithm as { length: number }).length;
385-
const isWrap = mode === CipherOrWrapMode.kWebCryptoCipherEncrypt;
386-
const cipherType = isWrap
387-
? `id-aes${keyLength}-wrap`
388-
: `id-aes${keyLength}-wrap`;
403+
// Use aes*-wrap for both operations (matching Node.js)
404+
const cipherType = `aes${keyLength}-wrap`;
405+
406+
// Export key material
407+
const exportedKey = key.keyObject.export();
408+
const cipherKey = bufferLikeToArrayBuffer(exportedKey);
409+
410+
// AES-KW uses a default IV as specified in RFC 3394
411+
const defaultWrapIV = new Uint8Array([
412+
0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6,
413+
]);
389414

390-
// AES-KW uses the same cipher for both wrap and unwrap,
391-
// but Node.js distinguishes with different cipher names
392415
const factory =
393416
NitroModules.createHybridObject<CipherFactory>('CipherFactory');
394417

395418
const cipher = factory.createCipher({
396419
isCipher: isWrap,
397420
cipherType,
398-
cipherKey: bufferLikeToArrayBuffer(key.keyObject.export()),
399-
iv: new ArrayBuffer(0), // AES-KW doesn't use IV
421+
cipherKey,
422+
iv: defaultWrapIV.buffer, // RFC 3394 default IV for AES-KW
400423
});
401424

402425
// Process data
@@ -1625,7 +1648,20 @@ export class Subtle {
16251648
if (format === 'jwk') {
16261649
const jwkString = JSON.stringify(exported);
16271650
const buffer = SBuffer.from(jwkString, 'utf8');
1628-
keyData = bufferLikeToArrayBuffer(buffer);
1651+
1652+
// For AES-KW, pad to multiple of 8 bytes (accounting for null terminator)
1653+
if (wrapAlgorithm.name === 'AES-KW') {
1654+
const length = buffer.length;
1655+
// Add 1 for null terminator, then pad to multiple of 8
1656+
const paddedLength = Math.ceil((length + 1) / 8) * 8;
1657+
const paddedBuffer = SBuffer.alloc(paddedLength);
1658+
buffer.copy(paddedBuffer);
1659+
// Null terminator for JSON string (remaining bytes are already zeros from alloc)
1660+
paddedBuffer.writeUInt8(0, length);
1661+
keyData = bufferLikeToArrayBuffer(paddedBuffer);
1662+
} else {
1663+
keyData = bufferLikeToArrayBuffer(buffer);
1664+
}
16291665
} else {
16301666
keyData = exported as ArrayBuffer;
16311667
}
@@ -1670,7 +1706,20 @@ export class Subtle {
16701706
let keyData: BufferLike | JWK;
16711707
if (format === 'jwk') {
16721708
const buffer = SBuffer.from(decrypted);
1673-
const jwkString = buffer.toString('utf8');
1709+
// For AES-KW, the data may be padded - find the null terminator
1710+
let jwkString: string;
1711+
if (unwrapAlgorithm.name === 'AES-KW') {
1712+
// Find the null terminator (if present) to get the original string
1713+
const nullIndex = buffer.indexOf(0);
1714+
if (nullIndex !== -1) {
1715+
jwkString = buffer.toString('utf8', 0, nullIndex);
1716+
} else {
1717+
// No null terminator, try to parse the whole buffer
1718+
jwkString = buffer.toString('utf8').trim();
1719+
}
1720+
} else {
1721+
jwkString = buffer.toString('utf8');
1722+
}
16741723
keyData = JSON.parse(jwkString) as JWK;
16751724
} else {
16761725
keyData = decrypted;

0 commit comments

Comments
 (0)