Skip to content

Commit d7f1e83

Browse files
committed
feat: US-003 - Fix crypto DH/ECDH key agreement
1 parent d4a54df commit d7f1e83

13 files changed

Lines changed: 444 additions & 22 deletions

File tree

.agent/contracts/node-bridge.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ Bridge-provided randomness for global `crypto` APIs MUST delegate to host `node:
9494
- **WHEN** host `node:crypto` randomness primitives are unavailable or fail
9595
- **THEN** the bridge MUST throw a deterministic error matching the unsupported API format (`"<module>.<api> is not supported in sandbox"`) for the invoked randomness API and MUST NOT fall back to non-cryptographic randomness
9696

97+
### Requirement: Diffie-Hellman And ECDH Bridge Uses Host Node Crypto Objects
98+
Bridge-provided `crypto` Diffie-Hellman and ECDH APIs SHALL delegate to host `node:crypto` objects so constructor validation, session state, encodings, and shared-secret derivation match Node.js semantics.
99+
100+
#### Scenario: Sandbox creates a Diffie-Hellman session
101+
- **WHEN** sandboxed code calls `crypto.createDiffieHellman(...)`, `crypto.getDiffieHellman(...)`, or `crypto.createECDH(...)`
102+
- **THEN** the bridge MUST construct the corresponding host `node:crypto` object
103+
- **AND** subsequent method calls such as `generateKeys()`, `computeSecret()`, `getPublicKey()`, and `setPrivateKey()` MUST execute against that host object rather than an isolate-local reimplementation
104+
105+
#### Scenario: Sandbox uses stateless crypto.diffieHellman
106+
- **WHEN** sandboxed code calls `crypto.diffieHellman({ privateKey, publicKey })`
107+
- **THEN** the bridge MUST delegate to host `node:crypto.diffieHellman`
108+
- **AND** the returned shared secret and thrown validation errors MUST preserve Node-compatible behavior
109+
97110
### Requirement: Bridge FS Open Flag Translation Uses Named Constants
98111
The bridge `fs` implementation MUST express string-flag translation using named open-flag constants (for example `O_WRONLY | O_CREAT | O_TRUNC`) aligned with Node `fs.constants` semantics, and MUST NOT rely on undocumented numeric literals.
99112

docs/nodejs-conformance-report.mdx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ description: Node.js v22 test/parallel/ conformance results for the secure-exec
1212
| Node.js version | 22.14.0 |
1313
| Source | v22.14.0 (test/parallel/) |
1414
| Total tests | 3532 |
15-
| Passing (genuine) | 737 (20.9%) |
15+
| Passing (genuine) | 738 (20.9%) |
1616
| Passing (vacuous self-skip) | 34 |
17-
| Passing (total) | 771 (21.8%) |
18-
| Expected fail | 2690 |
17+
| Passing (total) | 772 (21.9%) |
18+
| Expected fail | 2689 |
1919
| Skip | 71 |
2020
| Last updated | 2026-03-25 |
2121

2222
## Failure Categories
2323

2424
| Category | Tests |
2525
| --- | --- |
26-
| implementation-gap | 1389 |
26+
| implementation-gap | 1388 |
2727
| unsupported-module | 737 |
2828
| requires-v8-flags | 239 |
2929
| requires-exec-path | 200 |
@@ -70,7 +70,7 @@ description: Node.js v22 test/parallel/ conformance results for the secure-exec
7070
| constants | 1 | 0 | 1 | 0 | 0.0% |
7171
| corepack | 1 | 0 | 1 | 0 | 0.0% |
7272
| coverage | 1 | 0 | 1 | 0 | 0.0% |
73-
| crypto | 99 | 49 (13 vacuous) | 50 | 0 | 49.5% |
73+
| crypto | 99 | 50 (13 vacuous) | 49 | 0 | 50.5% |
7474
| cwd | 3 | 0 | 3 | 0 | 0.0% |
7575
| data | 1 | 0 | 1 | 0 | 0.0% |
7676
| datetime | 1 | 0 | 1 | 0 | 0.0% |
@@ -245,11 +245,11 @@ description: Node.js v22 test/parallel/ conformance results for the secure-exec
245245
| wrap | 4 | 0 | 4 | 0 | 0.0% |
246246
| x509 | 1 | 0 | 1 | 0 | 0.0% |
247247
| zlib | 53 | 17 | 33 | 3 | 34.0% |
248-
| **Total** | **3532** | **771** | **2690** | **71** | **22.3%** |
248+
| **Total** | **3532** | **772** | **2689** | **71** | **22.3%** |
249249

250250
## Expectations Detail
251251

252-
### implementation-gap (708 entries)
252+
### implementation-gap (707 entries)
253253

254254
**Glob patterns:**
255255

@@ -260,7 +260,7 @@ description: Node.js v22 test/parallel/ conformance results for the secure-exec
260260
- `test-https-*.js` — https depends on tls — most tests fail on missing TLS fixture files or crypto API gaps
261261
- `test-http2-*.js` — http2 module bridged via kernel — most tests fail on API gaps, missing fixtures, or protocol handling
262262

263-
*702 individual tests — see expectations.json for full list.*
263+
*701 individual tests — see expectations.json for full list.*
264264

265265
### unsupported-module (190 entries)
266266

packages/core/isolate-runtime/src/inject/require-setup.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,216 @@
826826
};
827827
}
828828

829+
if (
830+
typeof _cryptoDiffieHellmanSessionCreate !== 'undefined' &&
831+
typeof _cryptoDiffieHellmanSessionCall !== 'undefined'
832+
) {
833+
function serializeDhKeyObject(value) {
834+
if (value.type === 'secret') {
835+
return {
836+
type: 'secret',
837+
raw: Buffer.from(value.export()).toString('base64'),
838+
};
839+
}
840+
return {
841+
type: value.type,
842+
pem: value._pem || value.export({
843+
type: value.type === 'private' ? 'pkcs8' : 'spki',
844+
format: 'pem',
845+
}),
846+
};
847+
}
848+
849+
function serializeDhValue(value) {
850+
if (
851+
value === null ||
852+
typeof value === 'string' ||
853+
typeof value === 'number' ||
854+
typeof value === 'boolean'
855+
) {
856+
return value;
857+
}
858+
if (Buffer.isBuffer(value)) {
859+
return {
860+
__type: 'buffer',
861+
value: Buffer.from(value).toString('base64'),
862+
};
863+
}
864+
if (value instanceof ArrayBuffer) {
865+
return {
866+
__type: 'buffer',
867+
value: Buffer.from(new Uint8Array(value)).toString('base64'),
868+
};
869+
}
870+
if (ArrayBuffer.isView(value)) {
871+
return {
872+
__type: 'buffer',
873+
value: Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64'),
874+
};
875+
}
876+
if (typeof value === 'bigint') {
877+
return {
878+
__type: 'bigint',
879+
value: value.toString(),
880+
};
881+
}
882+
if (
883+
value &&
884+
typeof value === 'object' &&
885+
(value.type === 'public' || value.type === 'private' || value.type === 'secret') &&
886+
typeof value.export === 'function'
887+
) {
888+
return {
889+
__type: 'keyObject',
890+
value: serializeDhKeyObject(value),
891+
};
892+
}
893+
if (Array.isArray(value)) {
894+
return value.map(serializeDhValue);
895+
}
896+
if (value && typeof value === 'object') {
897+
var output = {};
898+
var keys = Object.keys(value);
899+
for (var i = 0; i < keys.length; i++) {
900+
if (value[keys[i]] !== undefined) {
901+
output[keys[i]] = serializeDhValue(value[keys[i]]);
902+
}
903+
}
904+
return output;
905+
}
906+
return String(value);
907+
}
908+
909+
function restoreDhValue(value) {
910+
if (!value || typeof value !== 'object') {
911+
return value;
912+
}
913+
if (value.__type === 'buffer') {
914+
return Buffer.from(value.value, 'base64');
915+
}
916+
if (value.__type === 'bigint') {
917+
return BigInt(value.value);
918+
}
919+
if (Array.isArray(value)) {
920+
return value.map(restoreDhValue);
921+
}
922+
var output = {};
923+
var keys = Object.keys(value);
924+
for (var i = 0; i < keys.length; i++) {
925+
output[keys[i]] = restoreDhValue(value[keys[i]]);
926+
}
927+
return output;
928+
}
929+
930+
function createDhSession(type, name, argsLike) {
931+
var args = [];
932+
for (var i = 0; i < argsLike.length; i++) {
933+
args.push(serializeDhValue(argsLike[i]));
934+
}
935+
return _cryptoDiffieHellmanSessionCreate.applySync(undefined, [
936+
JSON.stringify({
937+
type: type,
938+
name: name,
939+
args: args,
940+
}),
941+
]);
942+
}
943+
944+
function callDhSession(sessionId, method, argsLike) {
945+
var args = [];
946+
for (var i = 0; i < argsLike.length; i++) {
947+
args.push(serializeDhValue(argsLike[i]));
948+
}
949+
var response = JSON.parse(_cryptoDiffieHellmanSessionCall.applySync(undefined, [
950+
sessionId,
951+
JSON.stringify({
952+
method: method,
953+
args: args,
954+
}),
955+
]));
956+
if (response && response.hasResult === false) {
957+
return undefined;
958+
}
959+
return restoreDhValue(response && response.result);
960+
}
961+
962+
function SandboxDiffieHellman(sessionId) {
963+
this._sessionId = sessionId;
964+
}
965+
966+
Object.defineProperty(SandboxDiffieHellman.prototype, 'verifyError', {
967+
get: function getVerifyError() {
968+
return callDhSession(this._sessionId, 'verifyError', []);
969+
},
970+
});
971+
972+
SandboxDiffieHellman.prototype.generateKeys = function generateKeys(encoding) {
973+
if (arguments.length === 0) return callDhSession(this._sessionId, 'generateKeys', []);
974+
return callDhSession(this._sessionId, 'generateKeys', [encoding]);
975+
};
976+
SandboxDiffieHellman.prototype.computeSecret = function computeSecret(key, inputEncoding, outputEncoding) {
977+
return callDhSession(this._sessionId, 'computeSecret', Array.prototype.slice.call(arguments));
978+
};
979+
SandboxDiffieHellman.prototype.getPrime = function getPrime(encoding) {
980+
if (arguments.length === 0) return callDhSession(this._sessionId, 'getPrime', []);
981+
return callDhSession(this._sessionId, 'getPrime', [encoding]);
982+
};
983+
SandboxDiffieHellman.prototype.getGenerator = function getGenerator(encoding) {
984+
if (arguments.length === 0) return callDhSession(this._sessionId, 'getGenerator', []);
985+
return callDhSession(this._sessionId, 'getGenerator', [encoding]);
986+
};
987+
SandboxDiffieHellman.prototype.getPublicKey = function getPublicKey(encoding) {
988+
if (arguments.length === 0) return callDhSession(this._sessionId, 'getPublicKey', []);
989+
return callDhSession(this._sessionId, 'getPublicKey', [encoding]);
990+
};
991+
SandboxDiffieHellman.prototype.getPrivateKey = function getPrivateKey(encoding) {
992+
if (arguments.length === 0) return callDhSession(this._sessionId, 'getPrivateKey', []);
993+
return callDhSession(this._sessionId, 'getPrivateKey', [encoding]);
994+
};
995+
SandboxDiffieHellman.prototype.setPublicKey = function setPublicKey(key, encoding) {
996+
return callDhSession(this._sessionId, 'setPublicKey', Array.prototype.slice.call(arguments));
997+
};
998+
SandboxDiffieHellman.prototype.setPrivateKey = function setPrivateKey(key, encoding) {
999+
return callDhSession(this._sessionId, 'setPrivateKey', Array.prototype.slice.call(arguments));
1000+
};
1001+
1002+
function SandboxECDH(sessionId) {
1003+
SandboxDiffieHellman.call(this, sessionId);
1004+
}
1005+
SandboxECDH.prototype = Object.create(SandboxDiffieHellman.prototype);
1006+
SandboxECDH.prototype.constructor = SandboxECDH;
1007+
SandboxECDH.prototype.getPublicKey = function getPublicKey(encoding, format) {
1008+
return callDhSession(this._sessionId, 'getPublicKey', Array.prototype.slice.call(arguments));
1009+
};
1010+
1011+
result.createDiffieHellman = function createDiffieHellman() {
1012+
return new SandboxDiffieHellman(createDhSession('dh', undefined, arguments));
1013+
};
1014+
1015+
result.getDiffieHellman = function getDiffieHellman(name) {
1016+
return new SandboxDiffieHellman(createDhSession('group', name, []));
1017+
};
1018+
1019+
result.createDiffieHellmanGroup = result.getDiffieHellman;
1020+
1021+
result.createECDH = function createECDH(curve) {
1022+
return new SandboxECDH(createDhSession('ecdh', curve, []));
1023+
};
1024+
1025+
if (typeof _cryptoDiffieHellman !== 'undefined') {
1026+
result.diffieHellman = function diffieHellman(options) {
1027+
var resultJson = _cryptoDiffieHellman.applySync(undefined, [
1028+
JSON.stringify(serializeDhValue(options)),
1029+
]);
1030+
return restoreDhValue(JSON.parse(resultJson));
1031+
};
1032+
}
1033+
1034+
result.DiffieHellman = SandboxDiffieHellman;
1035+
result.DiffieHellmanGroup = SandboxDiffieHellman;
1036+
result.ECDH = SandboxECDH;
1037+
}
1038+
8291039
// Overlay host-backed generateKeyPairSync/generateKeyPair and KeyObject helpers
8301040
if (typeof _cryptoGenerateKeyPairSync !== 'undefined') {
8311041
function restoreBridgeValue(value) {

packages/core/src/generated/isolate-runtime.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/core/src/shared/bridge-contract.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export const HOST_BRIDGE_GLOBAL_KEYS = {
4444
cryptoGenerateKeyPairSync: "_cryptoGenerateKeyPairSync",
4545
cryptoGenerateKeySync: "_cryptoGenerateKeySync",
4646
cryptoGeneratePrimeSync: "_cryptoGeneratePrimeSync",
47+
cryptoDiffieHellman: "_cryptoDiffieHellman",
48+
cryptoDiffieHellmanGroup: "_cryptoDiffieHellmanGroup",
49+
cryptoDiffieHellmanSessionCreate: "_cryptoDiffieHellmanSessionCreate",
50+
cryptoDiffieHellmanSessionCall: "_cryptoDiffieHellmanSessionCall",
4751
cryptoSubtle: "_cryptoSubtle",
4852
fsReadFile: "_fsReadFile",
4953
fsWriteFile: "_fsWriteFile",
@@ -231,6 +235,13 @@ export type CryptoGeneratePrimeSyncBridgeRef = BridgeApplySyncRef<
231235
[number, string],
232236
string
233237
>;
238+
export type CryptoDiffieHellmanBridgeRef = BridgeApplySyncRef<[string], string>;
239+
export type CryptoDiffieHellmanGroupBridgeRef = BridgeApplySyncRef<[string], string>;
240+
export type CryptoDiffieHellmanSessionCreateBridgeRef = BridgeApplySyncRef<[string], number>;
241+
export type CryptoDiffieHellmanSessionCallBridgeRef = BridgeApplySyncRef<
242+
[number, string],
243+
string
244+
>;
234245
export type CryptoSubtleBridgeRef = BridgeApplySyncRef<[string], string>;
235246

236247
// Filesystem boundary contracts.

packages/core/src/shared/global-exposure.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,26 @@ export const NODE_CUSTOM_GLOBAL_INVENTORY: readonly CustomGlobalInventoryEntry[]
268268
classification: "hardened",
269269
rationale: "Host prime generation bridge reference.",
270270
},
271+
{
272+
name: "_cryptoDiffieHellman",
273+
classification: "hardened",
274+
rationale: "Host stateless Diffie-Hellman bridge reference.",
275+
},
276+
{
277+
name: "_cryptoDiffieHellmanGroup",
278+
classification: "hardened",
279+
rationale: "Host Diffie-Hellman group bridge reference.",
280+
},
281+
{
282+
name: "_cryptoDiffieHellmanSessionCreate",
283+
classification: "hardened",
284+
rationale: "Host Diffie-Hellman/ECDH session creation bridge reference.",
285+
},
286+
{
287+
name: "_cryptoDiffieHellmanSessionCall",
288+
classification: "hardened",
289+
rationale: "Host Diffie-Hellman/ECDH session method bridge reference.",
290+
},
271291
{
272292
name: "_cryptoSubtle",
273293
classification: "hardened",

packages/nodejs/src/bridge-contract.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export const HOST_BRIDGE_GLOBAL_KEYS = {
4040
cryptoGenerateKeyPairSync: "_cryptoGenerateKeyPairSync",
4141
cryptoGenerateKeySync: "_cryptoGenerateKeySync",
4242
cryptoGeneratePrimeSync: "_cryptoGeneratePrimeSync",
43+
cryptoDiffieHellman: "_cryptoDiffieHellman",
44+
cryptoDiffieHellmanGroup: "_cryptoDiffieHellmanGroup",
45+
cryptoDiffieHellmanSessionCreate: "_cryptoDiffieHellmanSessionCreate",
46+
cryptoDiffieHellmanSessionCall: "_cryptoDiffieHellmanSessionCall",
4347
cryptoSubtle: "_cryptoSubtle",
4448
fsReadFile: "_fsReadFile",
4549
fsWriteFile: "_fsWriteFile",
@@ -236,6 +240,13 @@ export type CryptoGeneratePrimeSyncBridgeRef = BridgeApplySyncRef<
236240
[number, string],
237241
string
238242
>;
243+
export type CryptoDiffieHellmanBridgeRef = BridgeApplySyncRef<[string], string>;
244+
export type CryptoDiffieHellmanGroupBridgeRef = BridgeApplySyncRef<[string], string>;
245+
export type CryptoDiffieHellmanSessionCreateBridgeRef = BridgeApplySyncRef<[string], number>;
246+
export type CryptoDiffieHellmanSessionCallBridgeRef = BridgeApplySyncRef<
247+
[number, string],
248+
string
249+
>;
239250
export type CryptoSubtleBridgeRef = BridgeApplySyncRef<[string], string>;
240251

241252
// Filesystem boundary contracts.

0 commit comments

Comments
 (0)