|
| 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