@labacacia/nps-sdk 1.0.0-alpha.3 → 1.0.0-alpha.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.
Files changed (212) hide show
  1. package/CHANGELOG.cn.md +53 -0
  2. package/CHANGELOG.md +62 -0
  3. package/README.cn.md +8 -2
  4. package/README.md +8 -2
  5. package/dist/core/anchor-cache.js +104 -0
  6. package/dist/core/anchor-cache.js.map +1 -0
  7. package/dist/core/cache.js +80 -0
  8. package/dist/core/cache.js.map +1 -0
  9. package/dist/core/canonical-json.js +44 -0
  10. package/dist/core/canonical-json.js.map +1 -0
  11. package/dist/core/codec.js +119 -0
  12. package/dist/core/codec.js.map +1 -0
  13. package/dist/core/codecs/index.js +6 -0
  14. package/dist/core/codecs/index.js.map +1 -0
  15. package/dist/core/codecs/ncp-codec.js +93 -0
  16. package/dist/core/codecs/ncp-codec.js.map +1 -0
  17. package/dist/core/codecs/tier1-json-codec.js +28 -0
  18. package/dist/core/codecs/tier1-json-codec.js.map +1 -0
  19. package/dist/core/codecs/tier2-msgpack-codec.js +26 -0
  20. package/dist/core/codecs/tier2-msgpack-codec.js.map +1 -0
  21. package/dist/core/crypto-provider.js +10 -0
  22. package/dist/core/crypto-provider.js.map +1 -0
  23. package/dist/core/exceptions.js +52 -0
  24. package/dist/core/exceptions.js.map +1 -0
  25. package/dist/core/frame-header.js +185 -0
  26. package/dist/core/frame-header.js.map +1 -0
  27. package/dist/core/frame-registry.js +63 -0
  28. package/dist/core/frame-registry.js.map +1 -0
  29. package/dist/core/frames.js +154 -0
  30. package/dist/core/frames.js.map +1 -0
  31. package/dist/core/index.js +21 -405
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/registry.js +17 -0
  34. package/dist/core/registry.js.map +1 -0
  35. package/dist/core/status-codes.js +38 -0
  36. package/dist/core/status-codes.js.map +1 -0
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +9 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/ncp/frames/anchor-frame.js +54 -0
  41. package/dist/ncp/frames/anchor-frame.js.map +1 -0
  42. package/dist/ncp/frames/caps-frame.js +29 -0
  43. package/dist/ncp/frames/caps-frame.js.map +1 -0
  44. package/dist/ncp/frames/diff-frame.js +37 -0
  45. package/dist/ncp/frames/diff-frame.js.map +1 -0
  46. package/dist/ncp/frames/error-frame.js +13 -0
  47. package/dist/ncp/frames/error-frame.js.map +1 -0
  48. package/dist/ncp/frames/hello-frame.js +25 -0
  49. package/dist/ncp/frames/hello-frame.js.map +1 -0
  50. package/dist/ncp/frames/stream-frame.js +18 -0
  51. package/dist/ncp/frames/stream-frame.js.map +1 -0
  52. package/dist/ncp/frames.js +192 -0
  53. package/dist/ncp/frames.js.map +1 -0
  54. package/dist/ncp/handshake.js +80 -0
  55. package/dist/ncp/handshake.js.map +1 -0
  56. package/dist/ncp/index.d.ts +1 -0
  57. package/dist/ncp/index.d.ts.map +1 -1
  58. package/dist/ncp/index.js +13 -368
  59. package/dist/ncp/index.js.map +1 -1
  60. package/dist/ncp/ncp-error-codes.d.ts +1 -0
  61. package/dist/ncp/ncp-error-codes.d.ts.map +1 -1
  62. package/dist/ncp/ncp-error-codes.js +34 -0
  63. package/dist/ncp/ncp-error-codes.js.map +1 -0
  64. package/dist/ncp/ncp-patch-format.js +13 -0
  65. package/dist/ncp/ncp-patch-format.js.map +1 -0
  66. package/dist/ncp/preamble.d.ts +47 -0
  67. package/dist/ncp/preamble.d.ts.map +1 -0
  68. package/dist/ncp/preamble.js +74 -0
  69. package/dist/ncp/preamble.js.map +1 -0
  70. package/dist/ncp/registry.js +13 -0
  71. package/dist/ncp/registry.js.map +1 -0
  72. package/dist/ncp/stream-manager.js +163 -0
  73. package/dist/ncp/stream-manager.js.map +1 -0
  74. package/dist/ndp/frames.js +87 -0
  75. package/dist/ndp/frames.js.map +1 -0
  76. package/dist/ndp/index.js +6 -223
  77. package/dist/ndp/index.js.map +1 -1
  78. package/dist/ndp/ndp-registry.js +79 -0
  79. package/dist/ndp/ndp-registry.js.map +1 -0
  80. package/dist/ndp/registry.js +10 -0
  81. package/dist/ndp/registry.js.map +1 -0
  82. package/dist/ndp/validator.js +48 -0
  83. package/dist/ndp/validator.js.map +1 -0
  84. package/dist/nip/acme/client.d.ts +31 -0
  85. package/dist/nip/acme/client.d.ts.map +1 -0
  86. package/dist/nip/acme/client.js +136 -0
  87. package/dist/nip/acme/client.js.map +1 -0
  88. package/dist/nip/acme/index.d.ts +6 -0
  89. package/dist/nip/acme/index.d.ts.map +1 -0
  90. package/dist/nip/acme/index.js +8 -0
  91. package/dist/nip/acme/index.js.map +1 -0
  92. package/dist/nip/acme/jws.d.ts +31 -0
  93. package/dist/nip/acme/jws.d.ts.map +1 -0
  94. package/dist/nip/acme/jws.js +76 -0
  95. package/dist/nip/acme/jws.js.map +1 -0
  96. package/dist/nip/acme/messages.d.ts +71 -0
  97. package/dist/nip/acme/messages.d.ts.map +1 -0
  98. package/dist/nip/acme/messages.js +4 -0
  99. package/dist/nip/acme/messages.js.map +1 -0
  100. package/dist/nip/acme/server.d.ts +41 -0
  101. package/dist/nip/acme/server.d.ts.map +1 -0
  102. package/dist/nip/acme/server.js +458 -0
  103. package/dist/nip/acme/server.js.map +1 -0
  104. package/dist/nip/acme/wire.d.ts +19 -0
  105. package/dist/nip/acme/wire.d.ts.map +1 -0
  106. package/dist/nip/acme/wire.js +21 -0
  107. package/dist/nip/acme/wire.js.map +1 -0
  108. package/dist/nip/assurance-level.d.ts +14 -0
  109. package/dist/nip/assurance-level.d.ts.map +1 -0
  110. package/dist/nip/assurance-level.js +33 -0
  111. package/dist/nip/assurance-level.js.map +1 -0
  112. package/dist/nip/cert-format.d.ts +5 -0
  113. package/dist/nip/cert-format.d.ts.map +1 -0
  114. package/dist/nip/cert-format.js +6 -0
  115. package/dist/nip/cert-format.js.map +1 -0
  116. package/dist/nip/error-codes.d.ts +23 -0
  117. package/dist/nip/error-codes.d.ts.map +1 -0
  118. package/dist/nip/error-codes.js +30 -0
  119. package/dist/nip/error-codes.js.map +1 -0
  120. package/dist/nip/frames.d.ts +10 -1
  121. package/dist/nip/frames.d.ts.map +1 -1
  122. package/dist/nip/frames.js +106 -0
  123. package/dist/nip/frames.js.map +1 -0
  124. package/dist/nip/identity.js +94 -0
  125. package/dist/nip/identity.js.map +1 -0
  126. package/dist/nip/index.d.ts +6 -0
  127. package/dist/nip/index.d.ts.map +1 -1
  128. package/dist/nip/index.js +12 -187
  129. package/dist/nip/index.js.map +1 -1
  130. package/dist/nip/registry.js +10 -0
  131. package/dist/nip/registry.js.map +1 -0
  132. package/dist/nip/verifier.d.ts +23 -0
  133. package/dist/nip/verifier.d.ts.map +1 -0
  134. package/dist/nip/verifier.js +90 -0
  135. package/dist/nip/verifier.js.map +1 -0
  136. package/dist/nip/x509/builder.d.ts +35 -0
  137. package/dist/nip/x509/builder.d.ts.map +1 -0
  138. package/dist/nip/x509/builder.js +59 -0
  139. package/dist/nip/x509/builder.js.map +1 -0
  140. package/dist/nip/x509/index.d.ts +4 -0
  141. package/dist/nip/x509/index.d.ts.map +1 -0
  142. package/dist/nip/x509/index.js +6 -0
  143. package/dist/nip/x509/index.js.map +1 -0
  144. package/dist/nip/x509/oids.d.ts +17 -0
  145. package/dist/nip/x509/oids.d.ts.map +1 -0
  146. package/dist/nip/x509/oids.js +23 -0
  147. package/dist/nip/x509/oids.js.map +1 -0
  148. package/dist/nip/x509/verifier.d.ts +26 -0
  149. package/dist/nip/x509/verifier.d.ts.map +1 -0
  150. package/dist/nip/x509/verifier.js +171 -0
  151. package/dist/nip/x509/verifier.js.map +1 -0
  152. package/dist/nop/client.js +90 -0
  153. package/dist/nop/client.js.map +1 -0
  154. package/dist/nop/frames.js +148 -0
  155. package/dist/nop/frames.js.map +1 -0
  156. package/dist/nop/index.js +6 -789
  157. package/dist/nop/index.js.map +1 -1
  158. package/dist/nop/models.js +50 -0
  159. package/dist/nop/models.js.map +1 -0
  160. package/dist/nop/nop-types.js +44 -0
  161. package/dist/nop/nop-types.js.map +1 -0
  162. package/dist/nop/registry.js +11 -0
  163. package/dist/nop/registry.js.map +1 -0
  164. package/dist/nwp/client.js +101 -0
  165. package/dist/nwp/client.js.map +1 -0
  166. package/dist/nwp/frames.js +81 -0
  167. package/dist/nwp/frames.js.map +1 -0
  168. package/dist/nwp/index.js +5 -693
  169. package/dist/nwp/index.js.map +1 -1
  170. package/dist/nwp/registry.js +9 -0
  171. package/dist/nwp/registry.js.map +1 -0
  172. package/dist/setup.js +29 -0
  173. package/dist/setup.js.map +1 -0
  174. package/package.json +2 -1
  175. package/src/index.ts +1 -1
  176. package/src/ncp/index.ts +1 -0
  177. package/src/ncp/ncp-error-codes.ts +2 -0
  178. package/src/ncp/preamble.ts +79 -0
  179. package/src/nip/acme/client.ts +185 -0
  180. package/src/nip/acme/index.ts +8 -0
  181. package/src/nip/acme/jws.ts +109 -0
  182. package/src/nip/acme/messages.ts +85 -0
  183. package/src/nip/acme/server.ts +480 -0
  184. package/src/nip/acme/wire.ts +24 -0
  185. package/src/nip/assurance-level.ts +35 -0
  186. package/src/nip/cert-format.ts +9 -0
  187. package/src/nip/error-codes.ts +36 -0
  188. package/src/nip/frames.ts +35 -3
  189. package/src/nip/index.ts +8 -0
  190. package/src/nip/verifier.ts +122 -0
  191. package/src/nip/x509/builder.ts +91 -0
  192. package/src/nip/x509/index.ts +6 -0
  193. package/src/nip/x509/oids.ts +28 -0
  194. package/src/nip/x509/verifier.ts +214 -0
  195. package/tests/_rfc0002-keys.ts +57 -0
  196. package/tests/ncp/preamble.test.ts +93 -0
  197. package/tests/nip-acme-agent01.test.ts +192 -0
  198. package/tests/nip-x509.test.ts +280 -0
  199. package/dist/core/index.cjs +0 -452
  200. package/dist/core/index.cjs.map +0 -1
  201. package/dist/index.cjs +0 -8
  202. package/dist/index.cjs.map +0 -1
  203. package/dist/ncp/index.cjs +0 -388
  204. package/dist/ncp/index.cjs.map +0 -1
  205. package/dist/ndp/index.cjs +0 -252
  206. package/dist/ndp/index.cjs.map +0 -1
  207. package/dist/nip/index.cjs +0 -214
  208. package/dist/nip/index.cjs.map +0 -1
  209. package/dist/nop/index.cjs +0 -823
  210. package/dist/nop/index.cjs.map +0 -1
  211. package/dist/nwp/index.cjs +0 -720
  212. package/dist/nwp/index.cjs.map +0 -1
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) 2026 LabAcacia / INNO LOTUS PTY LTD
3
+ //
4
+ // Parity tests for NPS-RFC-0001 NCP native-mode connection preamble.
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ PREAMBLE_BYTES,
9
+ PREAMBLE_LENGTH,
10
+ PREAMBLE_LITERAL,
11
+ PREAMBLE_ERROR_CODE,
12
+ PREAMBLE_STATUS_CODE,
13
+ preambleMatches,
14
+ tryValidatePreamble,
15
+ validatePreamble,
16
+ writePreamble,
17
+ NcpPreambleInvalidError,
18
+ } from "../../src/ncp/preamble.js";
19
+
20
+ const SPEC_BYTES = new Uint8Array([0x4e, 0x50, 0x53, 0x2f, 0x31, 0x2e, 0x30, 0x0a]);
21
+
22
+ describe("NCP preamble", () => {
23
+ it("bytes are exactly the spec constant", () => {
24
+ expect(PREAMBLE_LENGTH).toBe(8);
25
+ expect(PREAMBLE_LITERAL).toBe("NPS/1.0\n");
26
+ expect(PREAMBLE_BYTES).toEqual(SPEC_BYTES);
27
+ });
28
+
29
+ it("matches returns true for exact preamble", () => {
30
+ expect(preambleMatches(PREAMBLE_BYTES)).toBe(true);
31
+ });
32
+
33
+ it("matches returns true when preamble is at start of longer buffer", () => {
34
+ const combined = new Uint8Array(16);
35
+ combined.set(PREAMBLE_BYTES, 0);
36
+ combined[8] = 0x06;
37
+ expect(preambleMatches(combined)).toBe(true);
38
+ });
39
+
40
+ it.each([0, 1, 7])("matches returns false on short read length=%i", (n) => {
41
+ expect(preambleMatches(PREAMBLE_BYTES.slice(0, n))).toBe(false);
42
+ });
43
+
44
+ it("tryValidate accepts exact preamble", () => {
45
+ const r = tryValidatePreamble(PREAMBLE_BYTES);
46
+ expect(r.valid).toBe(true);
47
+ expect(r.reason).toBe("");
48
+ });
49
+
50
+ it("tryValidate rejects short read with reason", () => {
51
+ const r = tryValidatePreamble(new Uint8Array(3));
52
+ expect(r.valid).toBe(false);
53
+ expect(r.reason).toContain("short read");
54
+ expect(r.reason).toContain("3/8");
55
+ });
56
+
57
+ it("tryValidate rejects arbitrary garbage", () => {
58
+ const r = tryValidatePreamble(new TextEncoder().encode("GET / HTT"));
59
+ expect(r.valid).toBe(false);
60
+ expect(r.reason).not.toContain("future");
61
+ expect(r.reason).toContain("not speaking NPS");
62
+ });
63
+
64
+ it("tryValidate flags future-major-version distinctly", () => {
65
+ const r = tryValidatePreamble(new TextEncoder().encode("NPS/2.0\n"));
66
+ expect(r.valid).toBe(false);
67
+ expect(r.reason).toContain("future-major");
68
+ });
69
+
70
+ it("validate throws with codes exposed", () => {
71
+ try {
72
+ validatePreamble(new TextEncoder().encode("BADXXXXX"));
73
+ throw new Error("expected throw");
74
+ } catch (e) {
75
+ const err = e as NcpPreambleInvalidError;
76
+ expect(err.errorCode).toBe("NCP-PREAMBLE-INVALID");
77
+ expect(err.statusCode).toBe("NPS-PROTO-PREAMBLE-INVALID");
78
+ expect(err.message).not.toBe("");
79
+ }
80
+ });
81
+
82
+ it("writePreamble emits exactly the constant bytes", () => {
83
+ const chunks: Uint8Array[] = [];
84
+ writePreamble({ write: (b) => chunks.push(b) });
85
+ expect(chunks).toHaveLength(1);
86
+ expect(chunks[0]).toEqual(SPEC_BYTES);
87
+ });
88
+
89
+ it("status and error code constants match spec", () => {
90
+ expect(PREAMBLE_ERROR_CODE).toBe("NCP-PREAMBLE-INVALID");
91
+ expect(PREAMBLE_STATUS_CODE).toBe("NPS-PROTO-PREAMBLE-INVALID");
92
+ });
93
+ });
@@ -0,0 +1,192 @@
1
+ // Copyright 2026 INNO LOTUS PTY LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // TypeScript parallel of Java AcmeAgent01Tests / .NET AcmeAgent01Tests
5
+ // per NPS-RFC-0002 §4.4. End-to-end agent-01 round-trip plus tampered-signature
6
+ // negative path.
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import * as ed25519 from "@noble/ed25519";
10
+ import { sha512 } from "@noble/hashes/sha512";
11
+ import * as x509 from "@peculiar/x509";
12
+
13
+ import { AssuranceLevel } from "../src/nip/assurance-level.js";
14
+ import * as ec from "../src/nip/error-codes.js";
15
+ import { issueRoot } from "../src/nip/x509/builder.js";
16
+ import { verify as verifyX509 } from "../src/nip/x509/verifier.js";
17
+ import { AcmeClient } from "../src/nip/acme/client.js";
18
+ import { AcmeServer } from "../src/nip/acme/server.js";
19
+ import * as Jws from "../src/nip/acme/jws.js";
20
+ import * as wire from "../src/nip/acme/wire.js";
21
+ import type {
22
+ Authorization, ChallengeRespondPayload, Directory, NewAccountPayload,
23
+ NewOrderPayload, Order, ProblemDetail,
24
+ } from "../src/nip/acme/messages.js";
25
+ import { generateDualKeyPair, randomHexSerial } from "./_rfc0002-keys.js";
26
+
27
+ ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
28
+ x509.cryptoProvider.set(globalThis.crypto);
29
+
30
+ interface Fixture {
31
+ caNid: string;
32
+ agentNid: string;
33
+ caRoot: x509.X509Certificate;
34
+ agentKeys: Awaited<ReturnType<typeof generateDualKeyPair>>;
35
+ server: AcmeServer;
36
+ }
37
+
38
+ async function createFixture(): Promise<Fixture> {
39
+ const caNid = "urn:nps:ca:acme-test";
40
+ const agentNid = "urn:nps:agent:acme-test:1";
41
+
42
+ const caKeys = await generateDualKeyPair();
43
+ const caRoot = await issueRoot({
44
+ caNid, caKeys: caKeys.webCrypto,
45
+ notBefore: new Date(Date.now() - 60_000),
46
+ notAfter: new Date(Date.now() + 365 * 24 * 3600_000),
47
+ serialNumber: "01",
48
+ });
49
+
50
+ const agentKeys = await generateDualKeyPair();
51
+
52
+ const server = new AcmeServer({
53
+ caNid, caKeys: caKeys.webCrypto, caRootCert: caRoot,
54
+ certValidityMs: 30 * 24 * 3600_000,
55
+ });
56
+ await server.start();
57
+
58
+ return { caNid, agentNid, caRoot, agentKeys, server };
59
+ }
60
+
61
+ describe("ACME agent-01 — RFC-0002 §4.4 round-trip", () => {
62
+
63
+ it("issueAgentCert round-trip returns a PEM chain that verifies against the CA root", async () => {
64
+ const fx = await createFixture();
65
+ try {
66
+ const client = new AcmeClient({
67
+ directoryUrl: fx.server.directoryUrl,
68
+ privateKey: fx.agentKeys.privRaw,
69
+ publicKey: fx.agentKeys.pubRaw,
70
+ webCryptoKeys: fx.agentKeys.webCrypto,
71
+ });
72
+
73
+ const pem = await client.issueAgentCert(fx.agentNid);
74
+ expect(pem).toContain("BEGIN CERTIFICATE");
75
+
76
+ // Parse PEM chain and re-encode as base64url DER for the X.509 verifier.
77
+ const certs = x509.PemConverter.decode(pem)
78
+ .map((buf) => new x509.X509Certificate(buf));
79
+ expect(certs.length).toBeGreaterThan(0);
80
+ const chainB64 = certs.map((c) => b64uEncode(new Uint8Array(c.rawData)));
81
+
82
+ const result = await verifyX509({
83
+ certChainBase64UrlDer: chainB64,
84
+ assertedNid: fx.agentNid,
85
+ assertedAssuranceLevel: AssuranceLevel.ANONYMOUS,
86
+ trustedRootCerts: [fx.caRoot],
87
+ });
88
+ expect(result.valid).toBe(true);
89
+ expect(extractCn(result.leaf!.subject)).toBe(fx.agentNid);
90
+ } finally {
91
+ await fx.server.close();
92
+ }
93
+ });
94
+
95
+ it("respondAgent01 with tampered agent_signature → server returns NIP-ACME-CHALLENGE-FAILED", async () => {
96
+ const fx = await createFixture();
97
+ try {
98
+ // Drive the flow manually so we can splice in a forged challenge response.
99
+ const dirResp = await fetch(fx.server.directoryUrl);
100
+ expect(dirResp.ok).toBe(true);
101
+ const dir = await dirResp.json() as Directory;
102
+
103
+ const nonceResp = await fetch(dir.newNonce, { method: "HEAD" });
104
+ let nonce = nonceResp.headers.get("Replay-Nonce")!;
105
+ expect(nonce).not.toBeNull();
106
+
107
+ // newAccount.
108
+ const jwk = Jws.jwkFromPublicKey(fx.agentKeys.pubRaw);
109
+ const acctEnv = Jws.sign(
110
+ { alg: Jws.ALG_EDDSA, nonce, url: dir.newAccount, jwk },
111
+ { termsOfServiceAgreed: true } as NewAccountPayload,
112
+ fx.agentKeys.privRaw);
113
+ const acctResp = await postJose(dir.newAccount, acctEnv);
114
+ expect(acctResp.status).toBe(201);
115
+ const accountUrl = acctResp.headers.get("Location")!;
116
+ nonce = acctResp.headers.get("Replay-Nonce")!;
117
+
118
+ // newOrder.
119
+ const orderEnv = Jws.sign(
120
+ { alg: Jws.ALG_EDDSA, nonce, url: dir.newOrder, kid: accountUrl },
121
+ {
122
+ identifiers: [{ type: wire.IDENTIFIER_TYPE_NID, value: fx.agentNid }],
123
+ } as NewOrderPayload,
124
+ fx.agentKeys.privRaw);
125
+ const orderResp = await postJose(dir.newOrder, orderEnv);
126
+ expect(orderResp.status).toBe(201);
127
+ const order = await orderResp.json() as Order;
128
+ nonce = orderResp.headers.get("Replay-Nonce")!;
129
+
130
+ // POST-as-GET on authz to discover the challenge URL + token.
131
+ const authzEnv = Jws.sign(
132
+ { alg: Jws.ALG_EDDSA, nonce, url: order.authorizations[0], kid: accountUrl },
133
+ null, fx.agentKeys.privRaw);
134
+ const authzResp = await postJose(order.authorizations[0], authzEnv);
135
+ const authz = await authzResp.json() as Authorization;
136
+ nonce = authzResp.headers.get("Replay-Nonce")!;
137
+
138
+ const challenge = authz.challenges.find((c) => c.type === wire.CHALLENGE_AGENT_01);
139
+ expect(challenge).toBeDefined();
140
+
141
+ // ★ Tampered: sign challenge token with a *different* keypair, but submit
142
+ // the JWS envelope under the registered account's key — server verifies
143
+ // the JWS sig (passes with account key) and then verifies the agent
144
+ // signature against the account key (fails).
145
+ const forger = await generateDualKeyPair();
146
+ const tokenBytes = new TextEncoder().encode(challenge!.token);
147
+ const forgedSig = ed25519.sign(tokenBytes, forger.privRaw);
148
+
149
+ const chEnv = Jws.sign(
150
+ { alg: Jws.ALG_EDDSA, nonce, url: challenge!.url, kid: accountUrl },
151
+ { agent_signature: Jws.b64uEncode(forgedSig) } as ChallengeRespondPayload,
152
+ fx.agentKeys.privRaw);
153
+ const chResp = await postJose(challenge!.url, chEnv);
154
+
155
+ expect(chResp.status).toBe(400);
156
+ const problem = await chResp.json() as ProblemDetail;
157
+ expect(problem.type).toBe(ec.ACME_CHALLENGE_FAILED);
158
+ } finally {
159
+ await fx.server.close();
160
+ }
161
+ });
162
+ });
163
+
164
+ // ── helpers ──────────────────────────────────────────────────────────────────
165
+
166
+ async function postJose(url: string, env: Jws.Envelope): Promise<Response> {
167
+ return await fetch(url, {
168
+ method: "POST",
169
+ headers: { "Content-Type": wire.CONTENT_TYPE_JOSE_JSON },
170
+ body: JSON.stringify(env),
171
+ });
172
+ }
173
+
174
+ function b64uEncode(bytes: Uint8Array): string {
175
+ return Buffer.from(bytes).toString("base64").replace(/=+$/, "")
176
+ .replace(/\+/g, "-").replace(/\//g, "_");
177
+ }
178
+
179
+ function extractCn(dn: string): string | null {
180
+ for (const rdn of dn.split(",")) {
181
+ const t = rdn.trim();
182
+ if (t.startsWith("CN=")) {
183
+ let v = t.slice(3);
184
+ if (v.startsWith("\"") && v.endsWith("\"")) v = v.slice(1, -1);
185
+ return v.replace(/\\([",+;<>\\])/g, "$1");
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // Touch the import so unused-symbol lint doesn't trip.
192
+ void randomHexSerial;
@@ -0,0 +1,280 @@
1
+ // Copyright 2026 INNO LOTUS PTY LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // TypeScript parallel of Java NipX509Tests / .NET NipX509Tests per NPS-RFC-0002 §4.
5
+ // Covers the 5 verification scenarios documented in the .NET reference.
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import * as ed25519 from "@noble/ed25519";
9
+ import { sha512 } from "@noble/hashes/sha512";
10
+ import * as x509 from "@peculiar/x509";
11
+
12
+ import { AssuranceLevel } from "../src/nip/assurance-level.js";
13
+ import { V2_X509 } from "../src/nip/cert-format.js";
14
+ import * as ec from "../src/nip/error-codes.js";
15
+ import { IdentFrame } from "../src/nip/frames.js";
16
+ import { NipIdentVerifier } from "../src/nip/verifier.js";
17
+ import { issueLeaf, issueRoot } from "../src/nip/x509/builder.js";
18
+ import { generateDualKeyPair, randomHexSerial, type DualKeyPair } from "./_rfc0002-keys.js";
19
+
20
+ ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
21
+ x509.cryptoProvider.set(globalThis.crypto);
22
+
23
+ describe("NipX509 — RFC-0002 §4 verifier scenarios", () => {
24
+
25
+ it("registerX509 round-trip — dual-trust verifier accepts", async () => {
26
+ const caNid = "urn:nps:ca:test";
27
+ const agentNid = "urn:nps:agent:happy:1";
28
+
29
+ const ca = await generateDualKeyPair();
30
+ const agent = await generateDualKeyPair();
31
+
32
+ const root = await issueRoot({
33
+ caNid, caKeys: ca.webCrypto,
34
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
35
+ serialNumber: "01",
36
+ });
37
+ const leaf = await issueLeaf({
38
+ subjectNid: agentNid, subjectPublicKey: agent.webCrypto.publicKey,
39
+ caKeys: ca.webCrypto, issuerNid: caNid, role: "agent",
40
+ assuranceLevel: AssuranceLevel.ATTESTED,
41
+ notBefore: minutesAgo(1), notAfter: daysFromNow(30),
42
+ serialNumber: "02",
43
+ });
44
+
45
+ const frame = await buildV2Frame(agentNid, agent.pubRaw, ca.privRaw,
46
+ AssuranceLevel.ATTESTED, leaf, root);
47
+
48
+ const verifier = new NipIdentVerifier({
49
+ trustedCaPublicKeys: { [caNid]: pubKeyHex(ca.pubRaw) },
50
+ trustedX509Roots: [root],
51
+ });
52
+ const result = await verifier.verify(frame, caNid);
53
+
54
+ expect(result.valid).toBe(true);
55
+ expect(result.stepFailed).toBe(0);
56
+ });
57
+
58
+ it("leaf without EKU extension — verifier rejects with NIP-CERT-EKU-MISSING", async () => {
59
+ const caNid = "urn:nps:ca:test";
60
+ const agentNid = "urn:nps:agent:eku-stripped:1";
61
+
62
+ const ca = await generateDualKeyPair();
63
+ const agent = await generateDualKeyPair();
64
+
65
+ const root = await issueRoot({
66
+ caNid, caKeys: ca.webCrypto,
67
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
68
+ serialNumber: "01",
69
+ });
70
+ const tampered = await buildLeafWithoutEku(
71
+ agentNid, agent.webCrypto.publicKey, ca.webCrypto, caNid, "63");
72
+
73
+ const frame = await buildV2Frame(agentNid, agent.pubRaw, ca.privRaw,
74
+ null, tampered, root);
75
+
76
+ const verifier = new NipIdentVerifier({
77
+ trustedCaPublicKeys: { [caNid]: pubKeyHex(ca.pubRaw) },
78
+ trustedX509Roots: [root],
79
+ });
80
+ const result = await verifier.verify(frame, caNid);
81
+
82
+ expect(result.valid).toBe(false);
83
+ expect(result.errorCode).toBe(ec.CERT_EKU_MISSING);
84
+ expect(result.stepFailed).toBe(3);
85
+ });
86
+
87
+ it("leaf for different NID — verifier rejects with NIP-CERT-SUBJECT-NID-MISMATCH", async () => {
88
+ const caNid = "urn:nps:ca:test";
89
+ const victimNid = "urn:nps:agent:victim:1";
90
+ const forgedNid = "urn:nps:agent:attacker:9";
91
+
92
+ const ca = await generateDualKeyPair();
93
+ const agent = await generateDualKeyPair();
94
+
95
+ const root = await issueRoot({
96
+ caNid, caKeys: ca.webCrypto,
97
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
98
+ serialNumber: "01",
99
+ });
100
+ // Issue a leaf whose CN/SAN are the *forged* NID, but splice it into a
101
+ // frame asserting the *victim* NID. The v1 Ed25519 sig still asserts victim.
102
+ const forgedLeaf = await issueLeaf({
103
+ subjectNid: forgedNid, subjectPublicKey: agent.webCrypto.publicKey,
104
+ caKeys: ca.webCrypto, issuerNid: caNid, role: "agent",
105
+ assuranceLevel: AssuranceLevel.ANONYMOUS,
106
+ notBefore: minutesAgo(1), notAfter: daysFromNow(30),
107
+ serialNumber: "4d",
108
+ });
109
+
110
+ const frame = await buildV2Frame(victimNid, agent.pubRaw, ca.privRaw,
111
+ null, forgedLeaf, root);
112
+
113
+ const verifier = new NipIdentVerifier({
114
+ trustedCaPublicKeys: { [caNid]: pubKeyHex(ca.pubRaw) },
115
+ trustedX509Roots: [root],
116
+ });
117
+ const result = await verifier.verify(frame, caNid);
118
+
119
+ expect(result.valid).toBe(false);
120
+ expect(result.errorCode).toBe(ec.CERT_SUBJECT_NID_MISMATCH);
121
+ expect(result.stepFailed).toBe(3);
122
+ });
123
+
124
+ it("v1-only verifier ignores cert_chain and accepts v2 frames (Phase 1 backward compat)", async () => {
125
+ const caNid = "urn:nps:ca:test";
126
+ const agentNid = "urn:nps:agent:v1-compat:1";
127
+
128
+ const ca = await generateDualKeyPair();
129
+ const agent = await generateDualKeyPair();
130
+
131
+ const root = await issueRoot({
132
+ caNid, caKeys: ca.webCrypto,
133
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
134
+ serialNumber: "01",
135
+ });
136
+ const leaf = await issueLeaf({
137
+ subjectNid: agentNid, subjectPublicKey: agent.webCrypto.publicKey,
138
+ caKeys: ca.webCrypto, issuerNid: caNid, role: "agent",
139
+ assuranceLevel: AssuranceLevel.ANONYMOUS,
140
+ notBefore: minutesAgo(1), notAfter: daysFromNow(30),
141
+ serialNumber: "02",
142
+ });
143
+
144
+ const frame = await buildV2Frame(agentNid, agent.pubRaw, ca.privRaw,
145
+ null, leaf, root);
146
+
147
+ // Verifier WITHOUT trustedX509Roots — Step 3b is skipped entirely.
148
+ const verifier = new NipIdentVerifier({
149
+ trustedCaPublicKeys: { [caNid]: pubKeyHex(ca.pubRaw) },
150
+ });
151
+ const result = await verifier.verify(frame, caNid);
152
+
153
+ expect(result.valid).toBe(true);
154
+ expect(result.stepFailed).toBe(0);
155
+ });
156
+
157
+ it("v2 verifier with unrelated trust root rejects with NIP-CERT-FORMAT-INVALID", async () => {
158
+ const caNid = "urn:nps:ca:test";
159
+ const agentNid = "urn:nps:agent:wrong-trust:1";
160
+
161
+ const ca = await generateDualKeyPair();
162
+ const agent = await generateDualKeyPair();
163
+
164
+ const root = await issueRoot({
165
+ caNid, caKeys: ca.webCrypto,
166
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
167
+ serialNumber: "01",
168
+ });
169
+ const leaf = await issueLeaf({
170
+ subjectNid: agentNid, subjectPublicKey: agent.webCrypto.publicKey,
171
+ caKeys: ca.webCrypto, issuerNid: caNid, role: "agent",
172
+ assuranceLevel: AssuranceLevel.ANONYMOUS,
173
+ notBefore: minutesAgo(1), notAfter: daysFromNow(30),
174
+ serialNumber: "02",
175
+ });
176
+
177
+ const frame = await buildV2Frame(agentNid, agent.pubRaw, ca.privRaw,
178
+ null, leaf, root);
179
+
180
+ // Different unrelated CA root — chain won't anchor.
181
+ const otherCa = await generateDualKeyPair();
182
+ const otherRoot = await issueRoot({
183
+ caNid: "urn:nps:ca:other", caKeys: otherCa.webCrypto,
184
+ notBefore: minutesAgo(1), notAfter: daysFromNow(365),
185
+ serialNumber: "01",
186
+ });
187
+
188
+ const verifier = new NipIdentVerifier({
189
+ trustedCaPublicKeys: { [caNid]: pubKeyHex(ca.pubRaw) },
190
+ trustedX509Roots: [otherRoot],
191
+ });
192
+ const result = await verifier.verify(frame, caNid);
193
+
194
+ expect(result.valid).toBe(false);
195
+ expect(result.errorCode).toBe(ec.CERT_FORMAT_INVALID);
196
+ expect(result.stepFailed).toBe(3);
197
+ });
198
+ });
199
+
200
+ // ── helpers ──────────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Build a v2 IdentFrame including a v1 Ed25519 CA signature over the canonical
204
+ * unsigned dict (matches IdentFrame.unsignedDict + NipIdentVerifier signing path).
205
+ */
206
+ async function buildV2Frame(
207
+ subjectNid: string,
208
+ subjectPub: Uint8Array,
209
+ caPriv: Uint8Array,
210
+ level: AssuranceLevel | null,
211
+ leaf: x509.X509Certificate,
212
+ root: x509.X509Certificate,
213
+ ): Promise<IdentFrame> {
214
+ const pubKeyStr = pubKeyHex(subjectPub);
215
+ const metadata = { issued_by: "test-ca" };
216
+
217
+ const unsigned: Record<string, unknown> = {
218
+ nid: subjectNid,
219
+ pub_key: pubKeyStr,
220
+ metadata,
221
+ };
222
+ if (level !== null) unsigned["assurance_level"] = level.wire;
223
+
224
+ const canonical = JSON.stringify(unsigned, Object.keys(unsigned).sort());
225
+ const sig = ed25519.sign(new TextEncoder().encode(canonical), caPriv);
226
+ const sigWire = "ed25519:" + Buffer.from(sig).toString("base64");
227
+
228
+ const chain = [
229
+ b64uEncode(new Uint8Array(leaf.rawData)),
230
+ b64uEncode(new Uint8Array(root.rawData)),
231
+ ];
232
+
233
+ return new IdentFrame(subjectNid, pubKeyStr, metadata, sigWire, {
234
+ assuranceLevel: level,
235
+ certFormat: V2_X509,
236
+ certChain: chain,
237
+ });
238
+ }
239
+
240
+ /** Issue a leaf cert with EKU extension deliberately omitted. */
241
+ async function buildLeafWithoutEku(
242
+ subjectNid: string,
243
+ subjectPub: CryptoKey,
244
+ caKeys: CryptoKeyPair,
245
+ caNid: string,
246
+ serial: string,
247
+ ): Promise<x509.X509Certificate> {
248
+ const escape = (v: string) => v.replace(/([",+;<>\\])/g, "\\$1");
249
+ return x509.X509CertificateGenerator.create({
250
+ serialNumber: serial,
251
+ issuer: `CN=${escape(caNid)}`,
252
+ subject: `CN=${escape(subjectNid)}`,
253
+ notBefore: minutesAgo(1),
254
+ notAfter: daysFromNow(30),
255
+ publicKey: subjectPub,
256
+ signingAlgorithm: { name: "Ed25519" },
257
+ signingKey: caKeys.privateKey,
258
+ extensions: [
259
+ new x509.BasicConstraintsExtension(false, undefined, true),
260
+ new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
261
+ // ★ Deliberately NO ExtendedKeyUsage extension.
262
+ new x509.SubjectAlternativeNameExtension([{ type: "url", value: subjectNid }], false),
263
+ ],
264
+ });
265
+ }
266
+
267
+ function pubKeyHex(raw: Uint8Array): string {
268
+ return "ed25519:" + Buffer.from(raw).toString("hex");
269
+ }
270
+
271
+ function b64uEncode(bytes: Uint8Array): string {
272
+ return Buffer.from(bytes).toString("base64").replace(/=+$/, "")
273
+ .replace(/\+/g, "-").replace(/\//g, "_");
274
+ }
275
+
276
+ function minutesAgo(n: number): Date { return new Date(Date.now() - n * 60_000); }
277
+ function daysFromNow(n: number): Date { return new Date(Date.now() + n * 24 * 3600_000); }
278
+
279
+ // Touch the type so TS doesn't tree-shake the import in declaration emit.
280
+ type _ = DualKeyPair;