Skip to content

Commit 81a53a5

Browse files
committed
test: add CryptoKey class regression tests
Adds four focused tests that check guarantees either introduced by the native `CryptoKey` refactor or carried over from the existing state. - `test-webcrypto-cryptokey-brand-check.js` - each of the four prototype getters (`type`, `extractable`, `algorithm`, `usages`) throws `ERR_INVALID_THIS` for foreign receivers (plain objects, null-proto, primitives, null/undefined, functions, a subverted `Symbol.hasInstance`, and a real `BaseObject` of a different kind). `util.types.isCryptoKey()` remains accurate after a prototype swap, confirming it cannot be spoofed. - `test-webcrypto-cryptokey-clone-transfer.js` - exhaustive structured-clone, `MessagePort.postMessage`, and `Worker.postMessage` round-trips. Verifies slot preservation, inspect-output equivalence, and that crypto operations interoperate across clones including repeated round-trips. - `test-webcrypto-cryptokey-hidden-slots.js` - replaces all four prototype getters with forged versions and confirms internal consumers (export, inspect) still read the real native slots. - `test-webcrypto-cryptokey-no-own-symbols.js`, asserts CryptoKey instances expose no own symbol-keyed properties even after every public getter has been touched (proof the `#slots` private field plus native storage leaves the instance shape pristine). Signed-off-by: Filip Skokan <[email protected]>
1 parent 51cdf20 commit 81a53a5

4 files changed

Lines changed: 548 additions & 0 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict';
2+
3+
// The four CryptoKey prototype getters (`type`, `extractable`,
4+
// `algorithm`, `usages`) are user-configurable per Web IDL, so they
5+
// can be invoked with an arbitrary `this`. The native callbacks that
6+
// implement them must brand-check their receiver and throw cleanly
7+
// (ERR_INVALID_THIS) rather than crashing the process or returning
8+
// garbage. This test exercises four progressively more hostile
9+
// receiver shapes, including subverting `instanceof` via
10+
// `Symbol.hasInstance`, to make sure the C++ brand check holds.
11+
//
12+
// It also verifies that `util.types.isCryptoKey()` cannot be fooled
13+
// by prototype spoofing.
14+
15+
const common = require('../common');
16+
if (!common.hasCrypto)
17+
common.skip('missing crypto');
18+
19+
const assert = require('node:assert');
20+
const { types: { isCryptoKey } } = require('node:util');
21+
const { subtle } = globalThis.crypto;
22+
23+
(async () => {
24+
const key = await subtle.generateKey(
25+
{ name: 'HMAC', hash: 'SHA-256' },
26+
true,
27+
['sign'],
28+
);
29+
30+
const CryptoKey = key.constructor;
31+
32+
// Capture the underlying prototype getters once, so that subsequent
33+
// tampering with `CryptoKey.prototype` cannot affect what we call.
34+
const getters = {
35+
type: Object.getOwnPropertyDescriptor(CryptoKey.prototype, 'type').get,
36+
extractable:
37+
Object.getOwnPropertyDescriptor(CryptoKey.prototype, 'extractable').get,
38+
algorithm:
39+
Object.getOwnPropertyDescriptor(CryptoKey.prototype, 'algorithm').get,
40+
usages:
41+
Object.getOwnPropertyDescriptor(CryptoKey.prototype, 'usages').get,
42+
};
43+
44+
// Sanity: each getter works on a real CryptoKey.
45+
Object.entries(getters).forEach(([name, getter]) => {
46+
assert.notStrictEqual(getter.call(key), undefined, `baseline ${name}`);
47+
});
48+
assert.strictEqual(isCryptoKey(key), true);
49+
50+
const invalidThis = { code: 'ERR_INVALID_THIS', name: 'TypeError' };
51+
52+
// Plain object receiver.
53+
Object.entries(getters).forEach(([, getter]) => {
54+
assert.throws(() => getter.call({}), invalidThis);
55+
});
56+
57+
// Null-prototype object receiver.
58+
Object.entries(getters).forEach(([, getter]) => {
59+
assert.throws(() => getter.call({ __proto__: null }), invalidThis);
60+
});
61+
62+
// Primitive receiver.
63+
Object.entries(getters).forEach(([, getter]) => {
64+
assert.throws(() => getter.call(1), invalidThis);
65+
});
66+
67+
// Null.
68+
Object.entries(getters).forEach(([, getter]) => {
69+
// eslint-disable-next-line no-useless-call
70+
assert.throws(() => getter.call(null), invalidThis);
71+
});
72+
73+
// Undefined.
74+
Object.entries(getters).forEach(([, getter]) => {
75+
assert.throws(() => getter.call(), invalidThis);
76+
});
77+
78+
// Function
79+
Object.entries(getters).forEach(([, getter]) => {
80+
assert.throws(() => getter.call(function() {}), invalidThis);
81+
});
82+
83+
// Prototype spoofing with InternalCryptoKey.prototype must not pass
84+
// util.types.isCryptoKey().
85+
const spoofed = {};
86+
Object.setPrototypeOf(spoofed, Object.getPrototypeOf(key));
87+
assert.strictEqual(spoofed instanceof CryptoKey, true);
88+
assert.strictEqual(isCryptoKey(spoofed), false);
89+
90+
// Subvert `instanceof CryptoKey` via Symbol.hasInstance, then
91+
// invoke the native getters on a forged object. The C++ tag
92+
// check must reject the receiver even though `instanceof`
93+
// reports true.
94+
Object.defineProperty(CryptoKey, Symbol.hasInstance, {
95+
configurable: true,
96+
value: () => true,
97+
});
98+
const fake = { foo: 'bar' };
99+
assert.strictEqual(fake instanceof CryptoKey, true);
100+
assert.strictEqual(isCryptoKey(fake), false);
101+
Object.entries(getters).forEach(([, getter]) => {
102+
assert.throws(() => getter.call(fake), invalidThis);
103+
});
104+
105+
// Subverted `instanceof` plus a real BaseObject of a different
106+
// kind (a Buffer) as the receiver. Without the C++ tag check
107+
// this would type-confuse `Unwrap<NativeCryptoKey>`.
108+
const buf = Buffer.alloc(16);
109+
assert.strictEqual(buf instanceof CryptoKey, true);
110+
assert.strictEqual(isCryptoKey(buf), false);
111+
Object.entries(getters).forEach(([, getter]) => {
112+
assert.throws(() => getter.call(buf), invalidThis);
113+
});
114+
115+
// The real CryptoKey continues to work after all of the above.
116+
assert.strictEqual(getters.type.call(key), 'secret');
117+
assert.strictEqual(getters.extractable.call(key), true);
118+
assert.strictEqual(getters.algorithm.call(key).name, 'HMAC');
119+
assert.deepStrictEqual(getters.usages.call(key), ['sign']);
120+
})().then(common.mustCall());
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
'use strict';
2+
3+
// Tests that CryptoKey instances can be structured-cloned (same-realm
4+
// via `structuredClone`, cross-realm via `MessagePort.postMessage` and
5+
// `Worker.postMessage`) and that the clones:
6+
// 1. preserve all of [[type]], [[extractable]], [[algorithm]],
7+
// [[usages]] internal slots (as observed through both the public
8+
// accessors and the custom util.inspect output),
9+
// 2. are usable in cryptographic operations (sign/verify/encrypt/
10+
// decrypt/exportKey) and produce the same output as the original,
11+
// 3. can themselves be cloned again (round-trip), and
12+
// 4. work for secret, public, and private keys and for both
13+
// extractable and non-extractable keys.
14+
15+
const common = require('../common');
16+
if (!common.hasCrypto)
17+
common.skip('missing crypto');
18+
19+
const assert = require('node:assert');
20+
const { inspect } = require('node:util');
21+
const { once } = require('node:events');
22+
const { Worker, MessageChannel } = require('node:worker_threads');
23+
const { subtle } = globalThis.crypto;
24+
25+
function assertSameCryptoKey(a, b) {
26+
assert.notStrictEqual(a, b);
27+
assert.strictEqual(a.type, b.type);
28+
assert.strictEqual(a.extractable, b.extractable);
29+
assert.deepStrictEqual(a.algorithm, b.algorithm);
30+
assert.deepStrictEqual([...a.usages].sort(), [...b.usages].sort());
31+
// util.inspect reads native internal slots directly, so a clone's
32+
// rendered form must match the original's.
33+
assert.strictEqual(inspect(a, { depth: 4 }), inspect(b, { depth: 4 }));
34+
// assert.deepStrictEqual on CryptoKey objects goes through the
35+
// dedicated isCryptoKey branch in comparisons.js; a clone must be
36+
// deep-equal to its source.
37+
assert.deepStrictEqual(a, b);
38+
}
39+
40+
async function roundTripViaMessageChannel(key) {
41+
const { port1, port2 } = new MessageChannel();
42+
port1.postMessage(key);
43+
const [received] = await once(port2, 'message');
44+
port1.close();
45+
port2.close();
46+
return received;
47+
}
48+
49+
async function checkHmacKey(original) {
50+
const data = Buffer.from('some data to sign');
51+
52+
const cloned = structuredClone(original);
53+
assertSameCryptoKey(original, cloned);
54+
55+
const viaPort = await roundTripViaMessageChannel(original);
56+
assertSameCryptoKey(original, viaPort);
57+
58+
// Round-trip: clone a clone.
59+
const clonedAgain = structuredClone(viaPort);
60+
assertSameCryptoKey(original, clonedAgain);
61+
const viaPortAgain = await roundTripViaMessageChannel(cloned);
62+
assertSameCryptoKey(original, viaPortAgain);
63+
64+
// Signatures produced by every copy must match.
65+
const sigs = await Promise.all(
66+
[original, cloned, viaPort, clonedAgain, viaPortAgain].map(
67+
(k) => subtle.sign('HMAC', k, data),
68+
),
69+
);
70+
for (let i = 1; i < sigs.length; i++) {
71+
assert.deepStrictEqual(Buffer.from(sigs[0]), Buffer.from(sigs[i]));
72+
}
73+
74+
// Each copy must verify a signature produced by any other copy.
75+
for (const verifier of [original, cloned, viaPort, clonedAgain]) {
76+
for (const sig of sigs) {
77+
assert.strictEqual(
78+
await subtle.verify('HMAC', verifier, sig, data), true);
79+
}
80+
}
81+
82+
// Exported JWK must match byte-for-byte when extractable.
83+
if (original.extractable) {
84+
const jwks = await Promise.all(
85+
[original, cloned, viaPort, clonedAgain].map(
86+
(k) => subtle.exportKey('jwk', k),
87+
),
88+
);
89+
for (let i = 1; i < jwks.length; i++) {
90+
assert.deepStrictEqual(jwks[0], jwks[i]);
91+
}
92+
} else {
93+
// Non-extractable keys must refuse export on every copy.
94+
for (const k of [cloned, viaPort, clonedAgain]) {
95+
await assert.rejects(subtle.exportKey('jwk', k),
96+
{ name: 'InvalidAccessError' });
97+
}
98+
}
99+
}
100+
101+
async function checkAsymmetricKeyPair({ publicKey, privateKey }) {
102+
const data = Buffer.from('payload');
103+
104+
for (const original of [publicKey, privateKey]) {
105+
const cloned = structuredClone(original);
106+
assertSameCryptoKey(original, cloned);
107+
const viaPort = await roundTripViaMessageChannel(original);
108+
assertSameCryptoKey(original, viaPort);
109+
const clonedAgain = structuredClone(viaPort);
110+
assertSameCryptoKey(original, clonedAgain);
111+
}
112+
113+
// Sign with the original private key, verify with every cloned public key.
114+
const signature = await subtle.sign(
115+
{ name: 'ECDSA', hash: 'SHA-256' }, privateKey, data);
116+
const publicClones = [
117+
publicKey,
118+
structuredClone(publicKey),
119+
await roundTripViaMessageChannel(publicKey),
120+
structuredClone(await roundTripViaMessageChannel(publicKey)),
121+
];
122+
for (const pub of publicClones) {
123+
assert.strictEqual(
124+
await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' },
125+
pub, signature, data),
126+
true);
127+
}
128+
129+
// Sign with every cloned private key, verify with the original public key.
130+
const privateClones = [
131+
structuredClone(privateKey),
132+
await roundTripViaMessageChannel(privateKey),
133+
structuredClone(await roundTripViaMessageChannel(privateKey)),
134+
];
135+
for (const priv of privateClones) {
136+
const sig = await subtle.sign(
137+
{ name: 'ECDSA', hash: 'SHA-256' }, priv, data);
138+
assert.strictEqual(
139+
await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' },
140+
publicKey, sig, data),
141+
true);
142+
}
143+
}
144+
145+
async function checkTransferToWorker(key) {
146+
// A one-shot worker that receives a key, asserts its properties,
147+
// signs with it, and echoes the key back together with the signature.
148+
const worker = new Worker(`
149+
'use strict';
150+
const { parentPort } = require('node:worker_threads');
151+
const { subtle } = globalThis.crypto;
152+
parentPort.once('message', async ({ key, expected }) => {
153+
try {
154+
if (key.type !== expected.type ||
155+
key.extractable !== expected.extractable ||
156+
key.algorithm.name !== expected.algorithm.name ||
157+
key.algorithm.hash?.name !== expected.algorithm.hash?.name) {
158+
throw new Error('slot mismatch in worker');
159+
}
160+
const sig = await subtle.sign('HMAC', key, Buffer.from('wdata'));
161+
// Echo the key back so the parent can verify round-trip.
162+
parentPort.postMessage({ key, sig: Buffer.from(sig) });
163+
} catch (err) {
164+
parentPort.postMessage({ error: err.message });
165+
}
166+
});
167+
`, { eval: true });
168+
169+
worker.postMessage({
170+
key,
171+
expected: {
172+
type: key.type,
173+
extractable: key.extractable,
174+
algorithm: {
175+
name: key.algorithm.name,
176+
hash: key.algorithm.hash ? { name: key.algorithm.hash.name } : undefined,
177+
},
178+
},
179+
});
180+
const [msg] = await once(worker, 'message');
181+
await worker.terminate();
182+
183+
assert.strictEqual(msg.error, undefined, msg.error);
184+
// The key echoed back from the worker must itself be a fully-formed
185+
// CryptoKey with all slots preserved.
186+
assertSameCryptoKey(key, msg.key);
187+
// The signature produced inside the worker must verify against the
188+
// parent-side key.
189+
assert.strictEqual(
190+
await subtle.verify('HMAC', key, msg.sig, Buffer.from('wdata')),
191+
true);
192+
}
193+
194+
(async () => {
195+
// Extractable HMAC (secret)
196+
const hmacExtractable = await subtle.importKey(
197+
'raw',
198+
Buffer.from(
199+
'000102030405060708090a0b0c0d0e0f' +
200+
'101112131415161718191a1b1c1d1e1f', 'hex'),
201+
{ name: 'HMAC', hash: 'SHA-256' },
202+
true,
203+
['sign', 'verify']);
204+
await checkHmacKey(hmacExtractable);
205+
await checkTransferToWorker(hmacExtractable);
206+
207+
// Non-extractable HMAC (secret)
208+
const hmacNonExtractable = await subtle.generateKey(
209+
{ name: 'HMAC', hash: 'SHA-384' },
210+
false,
211+
['sign', 'verify']);
212+
await checkHmacKey(hmacNonExtractable);
213+
await checkTransferToWorker(hmacNonExtractable);
214+
215+
// AES-GCM secret key
216+
{
217+
const key = await subtle.generateKey(
218+
{ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
219+
const cloned = structuredClone(key);
220+
assertSameCryptoKey(key, cloned);
221+
const viaPort = await roundTripViaMessageChannel(key);
222+
assertSameCryptoKey(key, viaPort);
223+
const clonedAgain = structuredClone(viaPort);
224+
assertSameCryptoKey(key, clonedAgain);
225+
226+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
227+
const plaintext = Buffer.from('secret payload');
228+
const ciphertext = await subtle.encrypt(
229+
{ name: 'AES-GCM', iv }, key, plaintext);
230+
// Decrypt with every clone.
231+
for (const k of [cloned, viaPort, clonedAgain]) {
232+
const decrypted = await subtle.decrypt(
233+
{ name: 'AES-GCM', iv }, k, ciphertext);
234+
assert.deepStrictEqual(Buffer.from(decrypted), plaintext);
235+
}
236+
}
237+
238+
// ECDSA keypair (public extractable, private non-extractable)
239+
const ecKeypair = await subtle.generateKey(
240+
{ name: 'ECDSA', namedCurve: 'P-256' },
241+
false,
242+
['sign', 'verify']);
243+
await checkAsymmetricKeyPair(ecKeypair);
244+
245+
// ECDSA with extractable private key (covers the extractable-private path)
246+
const ecKeypairExtractable = await subtle.generateKey(
247+
{ name: 'ECDSA', namedCurve: 'P-384' },
248+
true,
249+
['sign', 'verify']);
250+
await checkAsymmetricKeyPair(ecKeypairExtractable);
251+
})().then(common.mustCall());

0 commit comments

Comments
 (0)