@quantakrypto/qprobe 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -0
- package/THREAT-MODEL.md +83 -0
- package/dist/args.d.ts +19 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +94 -0
- package/dist/args.js.map +1 -0
- package/dist/attest.d.ts +31 -0
- package/dist/attest.d.ts.map +1 -0
- package/dist/attest.js +39 -0
- package/dist/attest.js.map +1 -0
- package/dist/classify.d.ts +19 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +105 -0
- package/dist/classify.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +118 -0
- package/dist/cli.js.map +1 -0
- package/dist/clienthello.d.ts +40 -0
- package/dist/clienthello.d.ts.map +1 -0
- package/dist/clienthello.js +182 -0
- package/dist/clienthello.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -0
- package/dist/report.d.ts +22 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +51 -0
- package/dist/report.js.map +1 -0
- package/dist/smtp.d.ts +15 -0
- package/dist/smtp.d.ts.map +1 -0
- package/dist/smtp.js +103 -0
- package/dist/smtp.js.map +1 -0
- package/dist/ssh.d.ts +28 -0
- package/dist/ssh.d.ts.map +1 -0
- package/dist/ssh.js +118 -0
- package/dist/ssh.js.map +1 -0
- package/dist/target.d.ts +25 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +81 -0
- package/dist/target.js.map +1 -0
- package/dist/tls.d.ts +53 -0
- package/dist/tls.d.ts.map +1 -0
- package/dist/tls.js +109 -0
- package/dist/tls.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/x509.d.ts +28 -0
- package/dist/x509.d.ts.map +1 -0
- package/dist/x509.js +73 -0
- package/dist/x509.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE7D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,GAAc,EAAE,SAAiB,EAAE,UAAkB;IAChF,OAAO;QACL,IAAI,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,cAAc;QAC9F,QAAQ,EAAE,GAAG,CAAC,QAAQ;QACtB,YAAY,EAAE,GAAG,CAAC,OAAO,CAAC,MAAM;QAChC,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,SAAS;QACT,UAAU;QACV,WAAW,EAAE,UAAU,OAAO,EAAE;KACjC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,GAAc,EAAE,SAAiB,EAAE,UAAkB;IACjF,OAAO,OAAO,CAAC,YAAY,CAAC,GAAG,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAc,EAAE,SAAiB,EAAE,UAAkB;IAChF,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;IAC7D,8EAA8E;IAC9E,sEAAsE;IACtE,MAAM,KAAK,GAAI,GAAG,CAAC,QAA6D,CAAC,KAAK;QACpF,EAAE,UAAU,CAAC;IACf,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,KAAK,MAAM,CAAC,IAAI,KAAK;YAAE,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO;gBAAE,CAAC,CAAC,IAAI,GAAG,QAAQ,CAAC;IAC3F,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,UAAU,YAAY,CAC1B,GAAc,EACd,SAAiB,EACjB,UAAkB;IAElB,iFAAiF;IACjF,2EAA2E;IAC3E,MAAM,IAAI,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;IAC9D,OAAO;QACL,GAAG,IAAI;QACP,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACjC,MAAM,EAAE,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE;YAC3C,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,SAAS,EAAE,CAAC,CAAC,SAAS;YACtB,GAAG,EAAE,CAAC,CAAC,GAAG;YACV,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,GAAG,EAAE,CAAC,CAAC,GAAG;gBACR,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE;gBAChF,CAAC,CAAC,SAAS;SACd,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Reporting: turn a qProbe {@link RunResult} into `@quantakrypto/core`'s shared\n * output formats, so live-endpoint findings emit the SAME SARIF 2.1.0 and\n * CycloneDX 1.6 CBOM as qScan (code) and the infra detectors (config). This is\n * what lets the three planes compose into one post-quantum posture: a qScan CBOM\n * and a qProbe CBOM are both CycloneDX 1.6 documents and merge cleanly.\n *\n * The core reporters are findings-based (they derive SARIF `rules[]` and CBOM\n * crypto-assets from the findings themselves), so no registry entry is needed for\n * the `qprobe-*` rules.\n */\nimport type { ScanResult, SarifLog, CycloneDxBom } from \"@quantakrypto/core\";\nimport { toSarif, toJson, toCbom } from \"@quantakrypto/core\";\nimport type { RunResult } from \"./index.js\";\nimport { VERSION } from \"./version.js\";\n\n/**\n * Adapt a {@link RunResult} to a {@link ScanResult}. `filesScanned` is the number\n * of endpoints probed; `root` is the probed target list.\n */\nexport function toScanResult(run: RunResult, startedAt: string, finishedAt: string): ScanResult {\n return {\n root: run.reports.map((r) => `${r.target.host}:${r.target.port}`).join(\", \") || \"(no targets)\",\n findings: run.findings,\n filesScanned: run.reports.length,\n inventory: run.inventory,\n startedAt,\n finishedAt,\n toolVersion: `qprobe ${VERSION}`,\n };\n}\n\nexport function toSarifReport(run: RunResult, startedAt: string, finishedAt: string): SarifLog {\n return toSarif(toScanResult(run, startedAt, finishedAt));\n}\n\nexport function toCbomReport(run: RunResult, startedAt: string, finishedAt: string): CycloneDxBom {\n const bom = toCbom(toScanResult(run, startedAt, finishedAt));\n // core's toCbom labels the producing tool \"qScan\"; correct it for qProbe so a\n // merged code+endpoints CBOM attributes each plane to the right tool.\n const tools = (bom.metadata as { tools?: { components?: { name?: string }[] } }).tools\n ?.components;\n if (Array.isArray(tools)) for (const t of tools) if (t.name === \"qScan\") t.name = \"qProbe\";\n return bom;\n}\n\nexport function toJsonReport(\n run: RunResult,\n startedAt: string,\n finishedAt: string,\n): Record<string, unknown> {\n // Core's structured JSON (findings + inventory), augmented with the per-endpoint\n // probe detail qProbe uniquely carries (negotiated params, hybrid result).\n const base = toJson(toScanResult(run, startedAt, finishedAt));\n return {\n ...base,\n endpoints: run.reports.map((r) => ({\n target: `${r.target.host}:${r.target.port}`,\n mode: r.mode,\n positives: r.positives,\n tls: r.tls,\n hybrid: r.hybrid,\n ssh: r.ssh\n ? { banner: r.ssh.banner, pqKexOffered: r.ssh.pqKexOffered, error: r.ssh.error }\n : undefined,\n })),\n };\n}\n"]}
|
package/dist/smtp.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TlsNegotiated } from "./tls.js";
|
|
2
|
+
/** True once `buf` contains a COMPLETE SMTP reply (last line is `NNN␠…`). */
|
|
3
|
+
export declare function smtpReplyComplete(buf: string): boolean;
|
|
4
|
+
/** True if an EHLO reply advertises the STARTTLS capability. */
|
|
5
|
+
export declare function smtpAdvertisesStartTls(ehlo: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Probe an SMTP endpoint: dial, EHLO, STARTTLS, upgrade to TLS, and read the
|
|
8
|
+
* negotiated parameters + certificate posture. Returns `{ error }` when the server
|
|
9
|
+
* does not offer STARTTLS or the upgrade fails.
|
|
10
|
+
*/
|
|
11
|
+
export declare function probeSmtpStartTls(host: string, port: number, opts?: {
|
|
12
|
+
servername?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<TlsNegotiated>;
|
|
15
|
+
//# sourceMappingURL=smtp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smtp.d.ts","sourceRoot":"","sources":["../src/smtp.ts"],"names":[],"mappings":"AAYA,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAEjE,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAKtD;AAED,gEAAgE;AAChE,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE5D;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACrD,OAAO,CAAC,aAAa,CAAC,CA8DxB"}
|
package/dist/smtp.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMTP STARTTLS probing. Mail transport is "communication between things": an MTA
|
|
3
|
+
* that upgrades to TLS via STARTTLS negotiates the same classical (EC)DHE key
|
|
4
|
+
* exchange as any TLS endpoint, so the session protecting mail in transit is
|
|
5
|
+
* harvest-now-decrypt-later exposed. We speak the minimal SMTP dialog
|
|
6
|
+
* (banner → EHLO → STARTTLS), upgrade the socket to TLS, and reuse the same
|
|
7
|
+
* negotiated-parameter inspection as the direct TLS probe.
|
|
8
|
+
*
|
|
9
|
+
* The reply-framing helpers are pure and unit-tested; the socket dialog adds I/O.
|
|
10
|
+
*/
|
|
11
|
+
import { connect as netConnect } from "node:net";
|
|
12
|
+
import { connect as tlsConnect } from "node:tls";
|
|
13
|
+
import { extractNegotiated } from "./tls.js";
|
|
14
|
+
/** True once `buf` contains a COMPLETE SMTP reply (last line is `NNN␠…`). */
|
|
15
|
+
export function smtpReplyComplete(buf) {
|
|
16
|
+
if (!/\n$/.test(buf))
|
|
17
|
+
return false;
|
|
18
|
+
const lines = buf.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
19
|
+
const last = lines[lines.length - 1] ?? "";
|
|
20
|
+
return /^\d{3} /.test(last); // a space (not '-') after the code = final line
|
|
21
|
+
}
|
|
22
|
+
/** True if an EHLO reply advertises the STARTTLS capability. */
|
|
23
|
+
export function smtpAdvertisesStartTls(ehlo) {
|
|
24
|
+
return /^\d{3}[- ]STARTTLS\b/im.test(ehlo);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Probe an SMTP endpoint: dial, EHLO, STARTTLS, upgrade to TLS, and read the
|
|
28
|
+
* negotiated parameters + certificate posture. Returns `{ error }` when the server
|
|
29
|
+
* does not offer STARTTLS or the upgrade fails.
|
|
30
|
+
*/
|
|
31
|
+
export function probeSmtpStartTls(host, port, opts = {}) {
|
|
32
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let done = false;
|
|
35
|
+
let buf = Buffer.alloc(0);
|
|
36
|
+
let stage = "banner";
|
|
37
|
+
const finish = (r) => {
|
|
38
|
+
if (done)
|
|
39
|
+
return;
|
|
40
|
+
done = true;
|
|
41
|
+
try {
|
|
42
|
+
socket.destroy();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* already closed */
|
|
46
|
+
}
|
|
47
|
+
resolve(r);
|
|
48
|
+
};
|
|
49
|
+
const socket = netConnect({ host, port });
|
|
50
|
+
socket.setTimeout(timeoutMs);
|
|
51
|
+
socket.on("timeout", () => finish({ error: "timeout" }));
|
|
52
|
+
socket.on("error", (e) => finish({ error: e.message }));
|
|
53
|
+
socket.on("data", (chunk) => {
|
|
54
|
+
buf = Buffer.concat([buf, chunk]);
|
|
55
|
+
if (buf.length > 128 * 1024)
|
|
56
|
+
return finish({ error: "SMTP response too large" });
|
|
57
|
+
const text = buf.toString("ascii");
|
|
58
|
+
if (!smtpReplyComplete(text))
|
|
59
|
+
return;
|
|
60
|
+
if (stage === "banner") {
|
|
61
|
+
buf = Buffer.alloc(0);
|
|
62
|
+
stage = "ehlo";
|
|
63
|
+
socket.write("EHLO qprobe.local\r\n");
|
|
64
|
+
}
|
|
65
|
+
else if (stage === "ehlo") {
|
|
66
|
+
if (!smtpAdvertisesStartTls(text))
|
|
67
|
+
return finish({ error: "STARTTLS not advertised" });
|
|
68
|
+
buf = Buffer.alloc(0);
|
|
69
|
+
stage = "starttls";
|
|
70
|
+
socket.write("STARTTLS\r\n");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
if (!/^220 /m.test(text))
|
|
74
|
+
return finish({ error: "STARTTLS refused" });
|
|
75
|
+
socket.removeAllListeners("data");
|
|
76
|
+
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
|
|
77
|
+
const tls = tlsConnect({
|
|
78
|
+
socket,
|
|
79
|
+
servername: opts.servername ?? (isIp ? undefined : host),
|
|
80
|
+
rejectUnauthorized: false,
|
|
81
|
+
minVersion: "TLSv1.2",
|
|
82
|
+
});
|
|
83
|
+
tls.setTimeout(timeoutMs);
|
|
84
|
+
tls.on("timeout", () => finish({ error: "timeout" }));
|
|
85
|
+
tls.on("error", (e) => finish({ error: e.message }));
|
|
86
|
+
tls.on("secureConnect", () => {
|
|
87
|
+
const negotiated = extractNegotiated(tls);
|
|
88
|
+
if (done)
|
|
89
|
+
return;
|
|
90
|
+
done = true;
|
|
91
|
+
try {
|
|
92
|
+
tls.destroy();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* already closed */
|
|
96
|
+
}
|
|
97
|
+
resolve(negotiated);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=smtp.js.map
|
package/dist/smtp.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smtp.js","sourceRoot":"","sources":["../src/smtp.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SMTP STARTTLS probing. Mail transport is \"communication between things\": an MTA\n * that upgrades to TLS via STARTTLS negotiates the same classical (EC)DHE key\n * exchange as any TLS endpoint, so the session protecting mail in transit is\n * harvest-now-decrypt-later exposed. We speak the minimal SMTP dialog\n * (banner → EHLO → STARTTLS), upgrade the socket to TLS, and reuse the same\n * negotiated-parameter inspection as the direct TLS probe.\n *\n * The reply-framing helpers are pure and unit-tested; the socket dialog adds I/O.\n */\nimport { connect as netConnect } from \"node:net\";\nimport { connect as tlsConnect } from \"node:tls\";\nimport { extractNegotiated, type TlsNegotiated } from \"./tls.js\";\n\n/** True once `buf` contains a COMPLETE SMTP reply (last line is `NNN␠…`). */\nexport function smtpReplyComplete(buf: string): boolean {\n if (!/\\n$/.test(buf)) return false;\n const lines = buf.replace(/\\r?\\n$/, \"\").split(/\\r?\\n/);\n const last = lines[lines.length - 1] ?? \"\";\n return /^\\d{3} /.test(last); // a space (not '-') after the code = final line\n}\n\n/** True if an EHLO reply advertises the STARTTLS capability. */\nexport function smtpAdvertisesStartTls(ehlo: string): boolean {\n return /^\\d{3}[- ]STARTTLS\\b/im.test(ehlo);\n}\n\n/**\n * Probe an SMTP endpoint: dial, EHLO, STARTTLS, upgrade to TLS, and read the\n * negotiated parameters + certificate posture. Returns `{ error }` when the server\n * does not offer STARTTLS or the upgrade fails.\n */\nexport function probeSmtpStartTls(\n host: string,\n port: number,\n opts: { servername?: string; timeoutMs?: number } = {},\n): Promise<TlsNegotiated> {\n const timeoutMs = opts.timeoutMs ?? 8000;\n return new Promise((resolve) => {\n let done = false;\n let buf = Buffer.alloc(0);\n let stage: \"banner\" | \"ehlo\" | \"starttls\" = \"banner\";\n const finish = (r: TlsNegotiated) => {\n if (done) return;\n done = true;\n try {\n socket.destroy();\n } catch {\n /* already closed */\n }\n resolve(r);\n };\n const socket = netConnect({ host, port });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => finish({ error: \"timeout\" }));\n socket.on(\"error\", (e) => finish({ error: e.message }));\n socket.on(\"data\", (chunk: Buffer) => {\n buf = Buffer.concat([buf, chunk]);\n if (buf.length > 128 * 1024) return finish({ error: \"SMTP response too large\" });\n const text = buf.toString(\"ascii\");\n if (!smtpReplyComplete(text)) return;\n\n if (stage === \"banner\") {\n buf = Buffer.alloc(0);\n stage = \"ehlo\";\n socket.write(\"EHLO qprobe.local\\r\\n\");\n } else if (stage === \"ehlo\") {\n if (!smtpAdvertisesStartTls(text)) return finish({ error: \"STARTTLS not advertised\" });\n buf = Buffer.alloc(0);\n stage = \"starttls\";\n socket.write(\"STARTTLS\\r\\n\");\n } else {\n if (!/^220 /m.test(text)) return finish({ error: \"STARTTLS refused\" });\n socket.removeAllListeners(\"data\");\n const isIp = /^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(host) || host.includes(\":\");\n const tls = tlsConnect({\n socket,\n servername: opts.servername ?? (isIp ? undefined : host),\n rejectUnauthorized: false,\n minVersion: \"TLSv1.2\",\n });\n tls.setTimeout(timeoutMs);\n tls.on(\"timeout\", () => finish({ error: \"timeout\" }));\n tls.on(\"error\", (e) => finish({ error: e.message }));\n tls.on(\"secureConnect\", () => {\n const negotiated = extractNegotiated(tls);\n if (done) return;\n done = true;\n try {\n tls.destroy();\n } catch {\n /* already closed */\n }\n resolve(negotiated);\n });\n }\n });\n });\n}\n"]}
|
package/dist/ssh.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** PQC / hybrid SSH key-exchange algorithm names (OpenSSH + drafts). */
|
|
2
|
+
export declare const PQ_SSH_KEX: string[];
|
|
3
|
+
export interface KexInit {
|
|
4
|
+
kexAlgorithms: string[];
|
|
5
|
+
hostKeyAlgorithms: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse an SSH_MSG_KEXINIT payload (starting at the message-code byte) into its
|
|
9
|
+
* kex and host-key algorithm name-lists.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseKexinit(payload: Buffer): KexInit;
|
|
12
|
+
/**
|
|
13
|
+
* Extract the first binary SSH packet's payload from a raw server buffer that
|
|
14
|
+
* begins after the identification banner line. Returns undefined if the packet is
|
|
15
|
+
* not yet complete.
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractPacketPayload(afterBanner: Buffer): Buffer | undefined;
|
|
18
|
+
/** Locate the end of the SSH identification banner line (`SSH-...\n`). */
|
|
19
|
+
export declare function bannerEnd(buf: Buffer): number | undefined;
|
|
20
|
+
export interface SshProbeResult {
|
|
21
|
+
banner?: string;
|
|
22
|
+
kex?: KexInit;
|
|
23
|
+
pqKexOffered: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Connect to an SSH endpoint and read its KEXINIT (no auth, no crypto). */
|
|
27
|
+
export declare function probeSsh(host: string, port: number, timeoutMs?: number): Promise<SshProbeResult>;
|
|
28
|
+
//# sourceMappingURL=ssh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssh.d.ts","sourceRoot":"","sources":["../src/ssh.ts"],"names":[],"mappings":"AAeA,wEAAwE;AACxE,eAAO,MAAM,UAAU,UAMtB,CAAC;AAEF,MAAM,WAAW,OAAO;IACtB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAYD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CASrD;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAU5E;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOzD;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,YAAY,EAAE,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAmC9F"}
|
package/dist/ssh.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH endpoint inspection. An SSH server sends its identification banner and then
|
|
3
|
+
* a cleartext SSH_MSG_KEXINIT (message 20) BEFORE any encryption or authentication
|
|
4
|
+
* — so reading the offered key-exchange and host-key algorithm name-lists needs no
|
|
5
|
+
* credentials and performs no crypto. We look for post-quantum KEX
|
|
6
|
+
* (`sntrup761x25519-sha512@openssh.com`, `mlkem768x25519-sha256`) as a positive
|
|
7
|
+
* readiness signal, and flag classical-only servers.
|
|
8
|
+
*
|
|
9
|
+
* The wire parsing (`parseKexinit`) is pure and unit-tested; `probeSsh` adds the
|
|
10
|
+
* socket I/O.
|
|
11
|
+
*/
|
|
12
|
+
import { connect } from "node:net";
|
|
13
|
+
const SSH_MSG_KEXINIT = 20;
|
|
14
|
+
/** PQC / hybrid SSH key-exchange algorithm names (OpenSSH + drafts). */
|
|
15
|
+
export const PQ_SSH_KEX = [
|
|
16
|
+
"sntrup761x25519-sha512@openssh.com",
|
|
17
|
+
"sntrup761x25519-sha512",
|
|
18
|
+
"mlkem768x25519-sha256",
|
|
19
|
+
"mlkem768nistp256-sha256",
|
|
20
|
+
"mlkem1024nistp384-sha384",
|
|
21
|
+
];
|
|
22
|
+
/** Read a `uint32`-length-prefixed comma-separated name-list at `off`. */
|
|
23
|
+
function readNameList(payload, off) {
|
|
24
|
+
if (off + 4 > payload.length)
|
|
25
|
+
throw new RangeError("truncated name-list length");
|
|
26
|
+
const len = payload.readUInt32BE(off);
|
|
27
|
+
const start = off + 4;
|
|
28
|
+
if (start + len > payload.length)
|
|
29
|
+
throw new RangeError("truncated name-list");
|
|
30
|
+
const s = payload.subarray(start, start + len).toString("ascii");
|
|
31
|
+
return { list: s === "" ? [] : s.split(","), next: start + len };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse an SSH_MSG_KEXINIT payload (starting at the message-code byte) into its
|
|
35
|
+
* kex and host-key algorithm name-lists.
|
|
36
|
+
*/
|
|
37
|
+
export function parseKexinit(payload) {
|
|
38
|
+
if (payload.length < 1 + 16 || payload[0] !== SSH_MSG_KEXINIT) {
|
|
39
|
+
throw new RangeError("not an SSH_MSG_KEXINIT payload");
|
|
40
|
+
}
|
|
41
|
+
let off = 1 + 16; // message code + 16-byte cookie
|
|
42
|
+
const kex = readNameList(payload, off);
|
|
43
|
+
off = kex.next;
|
|
44
|
+
const hostKey = readNameList(payload, off);
|
|
45
|
+
return { kexAlgorithms: kex.list, hostKeyAlgorithms: hostKey.list };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extract the first binary SSH packet's payload from a raw server buffer that
|
|
49
|
+
* begins after the identification banner line. Returns undefined if the packet is
|
|
50
|
+
* not yet complete.
|
|
51
|
+
*/
|
|
52
|
+
export function extractPacketPayload(afterBanner) {
|
|
53
|
+
if (afterBanner.length < 5)
|
|
54
|
+
return undefined;
|
|
55
|
+
const packetLength = afterBanner.readUInt32BE(0);
|
|
56
|
+
if (packetLength < 2 || packetLength > 1_000_000)
|
|
57
|
+
throw new RangeError("implausible SSH packet length");
|
|
58
|
+
if (afterBanner.length < 4 + packetLength)
|
|
59
|
+
return undefined;
|
|
60
|
+
const paddingLength = afterBanner[4];
|
|
61
|
+
const payloadLen = packetLength - paddingLength - 1;
|
|
62
|
+
if (payloadLen < 1)
|
|
63
|
+
throw new RangeError("bad SSH padding");
|
|
64
|
+
return afterBanner.subarray(5, 5 + payloadLen);
|
|
65
|
+
}
|
|
66
|
+
/** Locate the end of the SSH identification banner line (`SSH-...\n`). */
|
|
67
|
+
export function bannerEnd(buf) {
|
|
68
|
+
// Servers may send pre-banner lines; find the SSH- line and its terminating LF.
|
|
69
|
+
const idx = buf.indexOf("SSH-");
|
|
70
|
+
if (idx < 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
const lf = buf.indexOf(0x0a, idx);
|
|
73
|
+
if (lf < 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
return lf + 1;
|
|
76
|
+
}
|
|
77
|
+
/** Connect to an SSH endpoint and read its KEXINIT (no auth, no crypto). */
|
|
78
|
+
export function probeSsh(host, port, timeoutMs = 8000) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
let buf = Buffer.alloc(0);
|
|
81
|
+
let done = false;
|
|
82
|
+
const finish = (r) => {
|
|
83
|
+
if (done)
|
|
84
|
+
return;
|
|
85
|
+
done = true;
|
|
86
|
+
socket.destroy();
|
|
87
|
+
resolve(r);
|
|
88
|
+
};
|
|
89
|
+
const socket = connect({ host, port });
|
|
90
|
+
socket.setTimeout(timeoutMs);
|
|
91
|
+
socket.on("connect", () => socket.write("SSH-2.0-qprobe_0.1\r\n"));
|
|
92
|
+
socket.on("timeout", () => finish({ pqKexOffered: false, error: "timeout" }));
|
|
93
|
+
socket.on("error", (e) => finish({ pqKexOffered: false, error: e.message }));
|
|
94
|
+
socket.on("data", (chunk) => {
|
|
95
|
+
buf = Buffer.concat([buf, chunk]);
|
|
96
|
+
if (buf.length > 512 * 1024) {
|
|
97
|
+
finish({ pqKexOffered: false, error: "response too large" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const end = bannerEnd(buf);
|
|
101
|
+
if (end === undefined)
|
|
102
|
+
return;
|
|
103
|
+
const banner = buf.subarray(0, end).toString("ascii").trim();
|
|
104
|
+
try {
|
|
105
|
+
const payload = extractPacketPayload(buf.subarray(end));
|
|
106
|
+
if (!payload)
|
|
107
|
+
return; // wait for more data
|
|
108
|
+
const kex = parseKexinit(payload);
|
|
109
|
+
const pqKexOffered = kex.kexAlgorithms.some((a) => PQ_SSH_KEX.includes(a));
|
|
110
|
+
finish({ banner, kex, pqKexOffered });
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
finish({ banner, pqKexOffered: false, error: e.message });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=ssh.js.map
|
package/dist/ssh.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssh.js","sourceRoot":"","sources":["../src/ssh.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSH endpoint inspection. An SSH server sends its identification banner and then\n * a cleartext SSH_MSG_KEXINIT (message 20) BEFORE any encryption or authentication\n * — so reading the offered key-exchange and host-key algorithm name-lists needs no\n * credentials and performs no crypto. We look for post-quantum KEX\n * (`sntrup761x25519-sha512@openssh.com`, `mlkem768x25519-sha256`) as a positive\n * readiness signal, and flag classical-only servers.\n *\n * The wire parsing (`parseKexinit`) is pure and unit-tested; `probeSsh` adds the\n * socket I/O.\n */\nimport { connect } from \"node:net\";\n\nconst SSH_MSG_KEXINIT = 20;\n\n/** PQC / hybrid SSH key-exchange algorithm names (OpenSSH + drafts). */\nexport const PQ_SSH_KEX = [\n \"sntrup761x25519-sha512@openssh.com\",\n \"sntrup761x25519-sha512\",\n \"mlkem768x25519-sha256\",\n \"mlkem768nistp256-sha256\",\n \"mlkem1024nistp384-sha384\",\n];\n\nexport interface KexInit {\n kexAlgorithms: string[];\n hostKeyAlgorithms: string[];\n}\n\n/** Read a `uint32`-length-prefixed comma-separated name-list at `off`. */\nfunction readNameList(payload: Buffer, off: number): { list: string[]; next: number } {\n if (off + 4 > payload.length) throw new RangeError(\"truncated name-list length\");\n const len = payload.readUInt32BE(off);\n const start = off + 4;\n if (start + len > payload.length) throw new RangeError(\"truncated name-list\");\n const s = payload.subarray(start, start + len).toString(\"ascii\");\n return { list: s === \"\" ? [] : s.split(\",\"), next: start + len };\n}\n\n/**\n * Parse an SSH_MSG_KEXINIT payload (starting at the message-code byte) into its\n * kex and host-key algorithm name-lists.\n */\nexport function parseKexinit(payload: Buffer): KexInit {\n if (payload.length < 1 + 16 || payload[0] !== SSH_MSG_KEXINIT) {\n throw new RangeError(\"not an SSH_MSG_KEXINIT payload\");\n }\n let off = 1 + 16; // message code + 16-byte cookie\n const kex = readNameList(payload, off);\n off = kex.next;\n const hostKey = readNameList(payload, off);\n return { kexAlgorithms: kex.list, hostKeyAlgorithms: hostKey.list };\n}\n\n/**\n * Extract the first binary SSH packet's payload from a raw server buffer that\n * begins after the identification banner line. Returns undefined if the packet is\n * not yet complete.\n */\nexport function extractPacketPayload(afterBanner: Buffer): Buffer | undefined {\n if (afterBanner.length < 5) return undefined;\n const packetLength = afterBanner.readUInt32BE(0);\n if (packetLength < 2 || packetLength > 1_000_000)\n throw new RangeError(\"implausible SSH packet length\");\n if (afterBanner.length < 4 + packetLength) return undefined;\n const paddingLength = afterBanner[4];\n const payloadLen = packetLength - paddingLength - 1;\n if (payloadLen < 1) throw new RangeError(\"bad SSH padding\");\n return afterBanner.subarray(5, 5 + payloadLen);\n}\n\n/** Locate the end of the SSH identification banner line (`SSH-...\\n`). */\nexport function bannerEnd(buf: Buffer): number | undefined {\n // Servers may send pre-banner lines; find the SSH- line and its terminating LF.\n const idx = buf.indexOf(\"SSH-\");\n if (idx < 0) return undefined;\n const lf = buf.indexOf(0x0a, idx);\n if (lf < 0) return undefined;\n return lf + 1;\n}\n\nexport interface SshProbeResult {\n banner?: string;\n kex?: KexInit;\n pqKexOffered: boolean;\n error?: string;\n}\n\n/** Connect to an SSH endpoint and read its KEXINIT (no auth, no crypto). */\nexport function probeSsh(host: string, port: number, timeoutMs = 8000): Promise<SshProbeResult> {\n return new Promise((resolve) => {\n let buf = Buffer.alloc(0);\n let done = false;\n const finish = (r: SshProbeResult) => {\n if (done) return;\n done = true;\n socket.destroy();\n resolve(r);\n };\n const socket = connect({ host, port });\n socket.setTimeout(timeoutMs);\n socket.on(\"connect\", () => socket.write(\"SSH-2.0-qprobe_0.1\\r\\n\"));\n socket.on(\"timeout\", () => finish({ pqKexOffered: false, error: \"timeout\" }));\n socket.on(\"error\", (e) => finish({ pqKexOffered: false, error: e.message }));\n socket.on(\"data\", (chunk: Buffer) => {\n buf = Buffer.concat([buf, chunk]);\n if (buf.length > 512 * 1024) {\n finish({ pqKexOffered: false, error: \"response too large\" });\n return;\n }\n const end = bannerEnd(buf);\n if (end === undefined) return;\n const banner = buf.subarray(0, end).toString(\"ascii\").trim();\n try {\n const payload = extractPacketPayload(buf.subarray(end));\n if (!payload) return; // wait for more data\n const kex = parseKexinit(payload);\n const pqKexOffered = kex.kexAlgorithms.some((a) => PQ_SSH_KEX.includes(a));\n finish({ banner, kex, pqKexOffered });\n } catch (e) {\n finish({ banner, pqKexOffered: false, error: (e as Error).message });\n }\n });\n });\n}\n"]}
|
package/dist/target.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target parsing + validation. qProbe opens real network connections, so the
|
|
3
|
+
* FIRST job is to refuse anything that looks like a scan of infrastructure the
|
|
4
|
+
* operator has not named one host at a time: CIDR blocks, IP ranges, wildcards,
|
|
5
|
+
* and comma-separated lists are rejected outright (see THREAT-MODEL.md). A target
|
|
6
|
+
* is exactly one `host` or `host:port`.
|
|
7
|
+
*/
|
|
8
|
+
/** A single validated endpoint. */
|
|
9
|
+
export interface Target {
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class TargetError extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse and validate a single target. Throws {@link TargetError} for anything
|
|
18
|
+
* that is not a single host / host:port — the refusal is a security control, not
|
|
19
|
+
* a convenience check.
|
|
20
|
+
*
|
|
21
|
+
* @param input e.g. "example.com", "example.com:8443", "10.0.0.5:22"
|
|
22
|
+
* @param defaultPort port to use when the input carries none
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseTarget(input: string, defaultPort: number): Target;
|
|
25
|
+
//# sourceMappingURL=target.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"target.d.ts","sourceRoot":"","sources":["../src/target.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,mCAAmC;AACnC,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qBAAa,WAAY,SAAQ,KAAK;gBACxB,OAAO,EAAE,MAAM;CAI5B;AAKD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAwDtE"}
|
package/dist/target.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target parsing + validation. qProbe opens real network connections, so the
|
|
3
|
+
* FIRST job is to refuse anything that looks like a scan of infrastructure the
|
|
4
|
+
* operator has not named one host at a time: CIDR blocks, IP ranges, wildcards,
|
|
5
|
+
* and comma-separated lists are rejected outright (see THREAT-MODEL.md). A target
|
|
6
|
+
* is exactly one `host` or `host:port`.
|
|
7
|
+
*/
|
|
8
|
+
export class TargetError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "TargetError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** IPv4 range in the last octet, e.g. `10.0.0.1-50`, or a full-range dash form. */
|
|
15
|
+
const IP_RANGE = /^\d{1,3}(?:\.\d{1,3}){3}\s*-\s*\d{1,3}/;
|
|
16
|
+
/**
|
|
17
|
+
* Parse and validate a single target. Throws {@link TargetError} for anything
|
|
18
|
+
* that is not a single host / host:port — the refusal is a security control, not
|
|
19
|
+
* a convenience check.
|
|
20
|
+
*
|
|
21
|
+
* @param input e.g. "example.com", "example.com:8443", "10.0.0.5:22"
|
|
22
|
+
* @param defaultPort port to use when the input carries none
|
|
23
|
+
*/
|
|
24
|
+
export function parseTarget(input, defaultPort) {
|
|
25
|
+
const raw = input.trim();
|
|
26
|
+
if (raw === "")
|
|
27
|
+
throw new TargetError("empty target");
|
|
28
|
+
// Range / sweep / list markers are refused BEFORE any parsing.
|
|
29
|
+
if (raw.includes("/")) {
|
|
30
|
+
throw new TargetError(`refusing CIDR block "${raw}" — qProbe probes one host at a time, not ranges.`);
|
|
31
|
+
}
|
|
32
|
+
if (raw.includes("*")) {
|
|
33
|
+
throw new TargetError(`refusing wildcard target "${raw}".`);
|
|
34
|
+
}
|
|
35
|
+
if (raw.includes(",")) {
|
|
36
|
+
throw new TargetError(`refusing target list "${raw}" — pass one target per invocation / manifest line.`);
|
|
37
|
+
}
|
|
38
|
+
if (/\s/.test(raw)) {
|
|
39
|
+
throw new TargetError(`invalid target "${raw}" (whitespace).`);
|
|
40
|
+
}
|
|
41
|
+
if (IP_RANGE.test(raw)) {
|
|
42
|
+
throw new TargetError(`refusing IP range "${raw}" — qProbe probes one host at a time.`);
|
|
43
|
+
}
|
|
44
|
+
// Split host:port (IPv6 in brackets is supported: [::1]:443).
|
|
45
|
+
let host;
|
|
46
|
+
let portStr;
|
|
47
|
+
if (raw.startsWith("[")) {
|
|
48
|
+
const end = raw.indexOf("]");
|
|
49
|
+
if (end < 0)
|
|
50
|
+
throw new TargetError(`invalid IPv6 target "${raw}" (missing ]).`);
|
|
51
|
+
host = raw.slice(1, end);
|
|
52
|
+
const rest = raw.slice(end + 1);
|
|
53
|
+
if (rest.startsWith(":"))
|
|
54
|
+
portStr = rest.slice(1);
|
|
55
|
+
else if (rest !== "")
|
|
56
|
+
throw new TargetError(`invalid target "${raw}".`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const idx = raw.lastIndexOf(":");
|
|
60
|
+
// A single ':' → host:port. Multiple ':' with no brackets → bare IPv6 (no port).
|
|
61
|
+
if (idx >= 0 && raw.indexOf(":") === idx) {
|
|
62
|
+
host = raw.slice(0, idx);
|
|
63
|
+
portStr = raw.slice(idx + 1);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
host = raw;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (host === "")
|
|
70
|
+
throw new TargetError(`invalid target "${raw}" (empty host).`);
|
|
71
|
+
let port = defaultPort;
|
|
72
|
+
if (portStr !== undefined) {
|
|
73
|
+
if (!/^\d+$/.test(portStr))
|
|
74
|
+
throw new TargetError(`invalid port "${portStr}" in "${raw}".`);
|
|
75
|
+
port = Number(portStr);
|
|
76
|
+
if (port < 1 || port > 65535)
|
|
77
|
+
throw new TargetError(`port out of range in "${raw}".`);
|
|
78
|
+
}
|
|
79
|
+
return { host, port };
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=target.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"target.js","sourceRoot":"","sources":["../src/target.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Target parsing + validation. qProbe opens real network connections, so the\n * FIRST job is to refuse anything that looks like a scan of infrastructure the\n * operator has not named one host at a time: CIDR blocks, IP ranges, wildcards,\n * and comma-separated lists are rejected outright (see THREAT-MODEL.md). A target\n * is exactly one `host` or `host:port`.\n */\n\n/** A single validated endpoint. */\nexport interface Target {\n host: string;\n port: number;\n}\n\nexport class TargetError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"TargetError\";\n }\n}\n\n/** IPv4 range in the last octet, e.g. `10.0.0.1-50`, or a full-range dash form. */\nconst IP_RANGE = /^\\d{1,3}(?:\\.\\d{1,3}){3}\\s*-\\s*\\d{1,3}/;\n\n/**\n * Parse and validate a single target. Throws {@link TargetError} for anything\n * that is not a single host / host:port — the refusal is a security control, not\n * a convenience check.\n *\n * @param input e.g. \"example.com\", \"example.com:8443\", \"10.0.0.5:22\"\n * @param defaultPort port to use when the input carries none\n */\nexport function parseTarget(input: string, defaultPort: number): Target {\n const raw = input.trim();\n if (raw === \"\") throw new TargetError(\"empty target\");\n\n // Range / sweep / list markers are refused BEFORE any parsing.\n if (raw.includes(\"/\")) {\n throw new TargetError(\n `refusing CIDR block \"${raw}\" — qProbe probes one host at a time, not ranges.`,\n );\n }\n if (raw.includes(\"*\")) {\n throw new TargetError(`refusing wildcard target \"${raw}\".`);\n }\n if (raw.includes(\",\")) {\n throw new TargetError(\n `refusing target list \"${raw}\" — pass one target per invocation / manifest line.`,\n );\n }\n if (/\\s/.test(raw)) {\n throw new TargetError(`invalid target \"${raw}\" (whitespace).`);\n }\n if (IP_RANGE.test(raw)) {\n throw new TargetError(`refusing IP range \"${raw}\" — qProbe probes one host at a time.`);\n }\n\n // Split host:port (IPv6 in brackets is supported: [::1]:443).\n let host: string;\n let portStr: string | undefined;\n if (raw.startsWith(\"[\")) {\n const end = raw.indexOf(\"]\");\n if (end < 0) throw new TargetError(`invalid IPv6 target \"${raw}\" (missing ]).`);\n host = raw.slice(1, end);\n const rest = raw.slice(end + 1);\n if (rest.startsWith(\":\")) portStr = rest.slice(1);\n else if (rest !== \"\") throw new TargetError(`invalid target \"${raw}\".`);\n } else {\n const idx = raw.lastIndexOf(\":\");\n // A single ':' → host:port. Multiple ':' with no brackets → bare IPv6 (no port).\n if (idx >= 0 && raw.indexOf(\":\") === idx) {\n host = raw.slice(0, idx);\n portStr = raw.slice(idx + 1);\n } else {\n host = raw;\n }\n }\n\n if (host === \"\") throw new TargetError(`invalid target \"${raw}\" (empty host).`);\n\n let port = defaultPort;\n if (portStr !== undefined) {\n if (!/^\\d+$/.test(portStr)) throw new TargetError(`invalid port \"${portStr}\" in \"${raw}\".`);\n port = Number(portStr);\n if (port < 1 || port > 65535) throw new TargetError(`port out of range in \"${raw}\".`);\n }\n\n return { host, port };\n}\n"]}
|
package/dist/tls.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS endpoint inspection. Two engines:
|
|
3
|
+
* 1. `probeTlsNegotiated` — a normal `node:tls` handshake to read what the server
|
|
4
|
+
* actually negotiates: TLS version, cipher suite, ephemeral key-exchange group
|
|
5
|
+
* (the harvestable part), and the leaf certificate's public-key type/size.
|
|
6
|
+
* 2. `probeHybridSupport` — a raw ClientHello (clienthello.ts) advertising
|
|
7
|
+
* X25519MLKEM768, to detect PQC-HYBRID support that Node's bundled OpenSSL
|
|
8
|
+
* cannot itself negotiate.
|
|
9
|
+
*
|
|
10
|
+
* `engine disposes`: we read the negotiated reality and disconnect; we never
|
|
11
|
+
* modify the endpoint.
|
|
12
|
+
*/
|
|
13
|
+
import { type TLSSocket } from "node:tls";
|
|
14
|
+
export interface TlsNegotiated {
|
|
15
|
+
protocol?: string;
|
|
16
|
+
cipher?: string;
|
|
17
|
+
/** Ephemeral key-exchange group, e.g. "X25519", "P-256", "DH". */
|
|
18
|
+
kexGroup?: string;
|
|
19
|
+
kexType?: string;
|
|
20
|
+
/** Leaf certificate public-key summary. */
|
|
21
|
+
certKeyType?: string;
|
|
22
|
+
certKeyBits?: number;
|
|
23
|
+
certSubject?: string;
|
|
24
|
+
/** How the leaf certificate is SIGNED (the CA's algorithm) — e.g. "RSA", "ECDSA". */
|
|
25
|
+
certSigFamily?: string;
|
|
26
|
+
certSigOid?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Read the negotiated parameters + leaf-cert posture from a connected TLS socket. */
|
|
30
|
+
export declare function extractNegotiated(socket: TLSSocket): TlsNegotiated;
|
|
31
|
+
/** Perform a normal TLS handshake and read the negotiated parameters. */
|
|
32
|
+
export declare function probeTlsNegotiated(host: string, port: number, opts?: {
|
|
33
|
+
servername?: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}): Promise<TlsNegotiated>;
|
|
36
|
+
export interface HybridSupport {
|
|
37
|
+
/** True only when the server SELECTED the hybrid group (proof of support). */
|
|
38
|
+
hybridSelected: boolean;
|
|
39
|
+
selectedGroup?: number;
|
|
40
|
+
isHelloRetryRequest?: boolean;
|
|
41
|
+
negotiatedVersion?: number;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send a raw ClientHello advertising X25519MLKEM768 and read the group the server
|
|
46
|
+
* selects. A server that supports+prefers the hybrid group answers with a
|
|
47
|
+
* HelloRetryRequest selecting 0x11EC — positive proof of PQC-hybrid support.
|
|
48
|
+
*/
|
|
49
|
+
export declare function probeHybridSupport(host: string, port: number, opts?: {
|
|
50
|
+
servername?: string;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}): Promise<HybridSupport>;
|
|
53
|
+
//# sourceMappingURL=tls.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tls.d.ts","sourceRoot":"","sources":["../src/tls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAyB,KAAK,SAAS,EAAE,MAAM,UAAU,CAAC;AAUjE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,aAAa,CAmBlE;AAED,yEAAyE;AACzE,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACrD,OAAO,CAAC,aAAa,CAAC,CA2BxB;AAED,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,cAAc,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GACrD,OAAO,CAAC,aAAa,CAAC,CAiCxB"}
|
package/dist/tls.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS endpoint inspection. Two engines:
|
|
3
|
+
* 1. `probeTlsNegotiated` — a normal `node:tls` handshake to read what the server
|
|
4
|
+
* actually negotiates: TLS version, cipher suite, ephemeral key-exchange group
|
|
5
|
+
* (the harvestable part), and the leaf certificate's public-key type/size.
|
|
6
|
+
* 2. `probeHybridSupport` — a raw ClientHello (clienthello.ts) advertising
|
|
7
|
+
* X25519MLKEM768, to detect PQC-HYBRID support that Node's bundled OpenSSL
|
|
8
|
+
* cannot itself negotiate.
|
|
9
|
+
*
|
|
10
|
+
* `engine disposes`: we read the negotiated reality and disconnect; we never
|
|
11
|
+
* modify the endpoint.
|
|
12
|
+
*/
|
|
13
|
+
import { connect as tlsConnect } from "node:tls";
|
|
14
|
+
import { connect as netConnect } from "node:net";
|
|
15
|
+
import { buildClientHello, readServerHello, GROUP_X25519MLKEM768, } from "./clienthello.js";
|
|
16
|
+
import { certSignatureAlgorithm } from "./x509.js";
|
|
17
|
+
/** Read the negotiated parameters + leaf-cert posture from a connected TLS socket. */
|
|
18
|
+
export function extractNegotiated(socket) {
|
|
19
|
+
const cipher = socket.getCipher();
|
|
20
|
+
const kex = socket.getEphemeralKeyInfo();
|
|
21
|
+
const cert = socket.getPeerCertificate(false);
|
|
22
|
+
const cn = cert?.subject?.CN;
|
|
23
|
+
// node:tls exposes the public-key type but not how the leaf was SIGNED; read the
|
|
24
|
+
// signature algorithm (the forgeable-at-Q-day part) from the raw DER.
|
|
25
|
+
const sig = cert?.raw ? certSignatureAlgorithm(cert.raw) : undefined;
|
|
26
|
+
return {
|
|
27
|
+
protocol: socket.getProtocol() ?? undefined,
|
|
28
|
+
cipher: cipher?.standardName ?? cipher?.name,
|
|
29
|
+
kexGroup: kex && "name" in kex ? kex.name : undefined,
|
|
30
|
+
kexType: kex && "type" in kex ? kex.type : undefined,
|
|
31
|
+
certKeyType: cert?.asn1Curve ? `EC(${cert.asn1Curve})` : cert?.pubkey ? "RSA" : undefined,
|
|
32
|
+
certKeyBits: typeof cert?.bits === "number" ? cert.bits : undefined,
|
|
33
|
+
certSubject: Array.isArray(cn) ? cn[0] : cn,
|
|
34
|
+
certSigFamily: sig?.family,
|
|
35
|
+
certSigOid: sig?.oid,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Perform a normal TLS handshake and read the negotiated parameters. */
|
|
39
|
+
export function probeTlsNegotiated(host, port, opts = {}) {
|
|
40
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
let done = false;
|
|
43
|
+
const finish = (r) => {
|
|
44
|
+
if (done)
|
|
45
|
+
return;
|
|
46
|
+
done = true;
|
|
47
|
+
socket.destroy();
|
|
48
|
+
resolve(r);
|
|
49
|
+
};
|
|
50
|
+
// SNI must not be an IP literal (RFC 6066); omit it for bare IPs.
|
|
51
|
+
const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
|
|
52
|
+
const servername = opts.servername ?? (isIp ? undefined : host);
|
|
53
|
+
const socket = tlsConnect({
|
|
54
|
+
host,
|
|
55
|
+
port,
|
|
56
|
+
servername,
|
|
57
|
+
rejectUnauthorized: false, // we inspect posture; we do not assert trust
|
|
58
|
+
minVersion: "TLSv1.2",
|
|
59
|
+
});
|
|
60
|
+
socket.setTimeout(timeoutMs);
|
|
61
|
+
socket.on("timeout", () => finish({ error: "timeout" }));
|
|
62
|
+
socket.on("error", (e) => finish({ error: e.message }));
|
|
63
|
+
socket.on("secureConnect", () => {
|
|
64
|
+
finish(extractNegotiated(socket));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Send a raw ClientHello advertising X25519MLKEM768 and read the group the server
|
|
70
|
+
* selects. A server that supports+prefers the hybrid group answers with a
|
|
71
|
+
* HelloRetryRequest selecting 0x11EC — positive proof of PQC-hybrid support.
|
|
72
|
+
*/
|
|
73
|
+
export function probeHybridSupport(host, port, opts = {}) {
|
|
74
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let buf = Buffer.alloc(0);
|
|
77
|
+
let done = false;
|
|
78
|
+
const finish = (r) => {
|
|
79
|
+
if (done)
|
|
80
|
+
return;
|
|
81
|
+
done = true;
|
|
82
|
+
socket.destroy();
|
|
83
|
+
resolve(r);
|
|
84
|
+
};
|
|
85
|
+
const socket = netConnect({ host, port });
|
|
86
|
+
socket.setTimeout(timeoutMs);
|
|
87
|
+
socket.on("connect", () => {
|
|
88
|
+
socket.write(buildClientHello({ serverName: opts.servername ?? host }));
|
|
89
|
+
});
|
|
90
|
+
socket.on("timeout", () => finish({ hybridSelected: false, error: "timeout" }));
|
|
91
|
+
socket.on("error", (e) => finish({ hybridSelected: false, error: e.message }));
|
|
92
|
+
socket.on("data", (chunk) => {
|
|
93
|
+
buf = Buffer.concat([buf, chunk]);
|
|
94
|
+
const sh = readServerHello(buf);
|
|
95
|
+
if (!sh) {
|
|
96
|
+
if (buf.length > 64 * 1024)
|
|
97
|
+
finish({ hybridSelected: false, error: "no ServerHello" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
finish({
|
|
101
|
+
hybridSelected: sh.selectedGroup === GROUP_X25519MLKEM768,
|
|
102
|
+
selectedGroup: sh.selectedGroup,
|
|
103
|
+
isHelloRetryRequest: sh.isHelloRetryRequest,
|
|
104
|
+
negotiatedVersion: sh.negotiatedVersion,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=tls.js.map
|