@labacacia/nps-sdk 1.0.0-alpha.2 → 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.
- package/CHANGELOG.cn.md +73 -2
- package/CHANGELOG.md +82 -2
- package/README.cn.md +8 -2
- package/README.md +8 -2
- package/dist/core/anchor-cache.d.ts +0 -0
- package/dist/core/anchor-cache.d.ts.map +0 -0
- package/dist/core/anchor-cache.js +0 -0
- package/dist/core/anchor-cache.js.map +0 -0
- package/dist/core/cache.d.ts +0 -0
- package/dist/core/cache.d.ts.map +0 -0
- package/dist/core/cache.js +0 -0
- package/dist/core/cache.js.map +0 -0
- package/dist/core/canonical-json.d.ts +0 -0
- package/dist/core/canonical-json.d.ts.map +0 -0
- package/dist/core/canonical-json.js +0 -0
- package/dist/core/canonical-json.js.map +0 -0
- package/dist/core/codec.d.ts +0 -0
- package/dist/core/codec.d.ts.map +0 -0
- package/dist/core/codec.js +0 -0
- package/dist/core/codec.js.map +0 -0
- package/dist/core/codecs/index.d.ts +0 -0
- package/dist/core/codecs/index.d.ts.map +0 -0
- package/dist/core/codecs/index.js +0 -0
- package/dist/core/codecs/index.js.map +0 -0
- package/dist/core/codecs/ncp-codec.d.ts +0 -0
- package/dist/core/codecs/ncp-codec.d.ts.map +0 -0
- package/dist/core/codecs/ncp-codec.js +0 -0
- package/dist/core/codecs/ncp-codec.js.map +0 -0
- package/dist/core/codecs/tier1-json-codec.d.ts +0 -0
- package/dist/core/codecs/tier1-json-codec.d.ts.map +0 -0
- package/dist/core/codecs/tier1-json-codec.js +0 -0
- package/dist/core/codecs/tier1-json-codec.js.map +0 -0
- package/dist/core/codecs/tier2-msgpack-codec.d.ts +0 -0
- package/dist/core/codecs/tier2-msgpack-codec.d.ts.map +0 -0
- package/dist/core/codecs/tier2-msgpack-codec.js +0 -0
- package/dist/core/codecs/tier2-msgpack-codec.js.map +0 -0
- package/dist/core/crypto-provider.d.ts +0 -0
- package/dist/core/crypto-provider.d.ts.map +0 -0
- package/dist/core/crypto-provider.js +0 -0
- package/dist/core/crypto-provider.js.map +0 -0
- package/dist/core/exceptions.d.ts +0 -0
- package/dist/core/exceptions.d.ts.map +0 -0
- package/dist/core/exceptions.js +0 -0
- package/dist/core/exceptions.js.map +0 -0
- package/dist/core/frame-header.d.ts +0 -0
- package/dist/core/frame-header.d.ts.map +0 -0
- package/dist/core/frame-header.js +0 -0
- package/dist/core/frame-header.js.map +0 -0
- package/dist/core/frame-registry.d.ts +0 -0
- package/dist/core/frame-registry.d.ts.map +0 -0
- package/dist/core/frame-registry.js +0 -0
- package/dist/core/frame-registry.js.map +0 -0
- package/dist/core/frames.d.ts +0 -0
- package/dist/core/frames.d.ts.map +0 -0
- package/dist/core/frames.js +0 -0
- package/dist/core/frames.js.map +0 -0
- package/dist/core/index.d.ts +0 -0
- package/dist/core/index.d.ts.map +0 -0
- package/dist/core/index.js +0 -0
- package/dist/core/index.js.map +0 -0
- package/dist/core/registry.d.ts +0 -0
- package/dist/core/registry.d.ts.map +0 -0
- package/dist/core/registry.js +0 -0
- package/dist/core/registry.js.map +0 -0
- package/dist/core/status-codes.d.ts +0 -0
- package/dist/core/status-codes.d.ts.map +0 -0
- package/dist/core/status-codes.js +0 -0
- package/dist/core/status-codes.js.map +0 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.d.ts.map +0 -0
- package/dist/index.js +0 -0
- package/dist/index.js.map +0 -0
- package/dist/ncp/frames/anchor-frame.d.ts +0 -0
- package/dist/ncp/frames/anchor-frame.d.ts.map +0 -0
- package/dist/ncp/frames/anchor-frame.js +0 -0
- package/dist/ncp/frames/anchor-frame.js.map +0 -0
- package/dist/ncp/frames/caps-frame.d.ts +0 -0
- package/dist/ncp/frames/caps-frame.d.ts.map +0 -0
- package/dist/ncp/frames/caps-frame.js +0 -0
- package/dist/ncp/frames/caps-frame.js.map +0 -0
- package/dist/ncp/frames/diff-frame.d.ts +0 -0
- package/dist/ncp/frames/diff-frame.d.ts.map +0 -0
- package/dist/ncp/frames/diff-frame.js +0 -0
- package/dist/ncp/frames/diff-frame.js.map +0 -0
- package/dist/ncp/frames/error-frame.d.ts +0 -0
- package/dist/ncp/frames/error-frame.d.ts.map +0 -0
- package/dist/ncp/frames/error-frame.js +0 -0
- package/dist/ncp/frames/error-frame.js.map +0 -0
- package/dist/ncp/frames/hello-frame.d.ts +0 -0
- package/dist/ncp/frames/hello-frame.d.ts.map +0 -0
- package/dist/ncp/frames/hello-frame.js +0 -0
- package/dist/ncp/frames/hello-frame.js.map +0 -0
- package/dist/ncp/frames/stream-frame.d.ts +0 -0
- package/dist/ncp/frames/stream-frame.d.ts.map +0 -0
- package/dist/ncp/frames/stream-frame.js +0 -0
- package/dist/ncp/frames/stream-frame.js.map +0 -0
- package/dist/ncp/frames.d.ts +0 -0
- package/dist/ncp/frames.d.ts.map +0 -0
- package/dist/ncp/frames.js +0 -0
- package/dist/ncp/frames.js.map +0 -0
- package/dist/ncp/handshake.d.ts +0 -0
- package/dist/ncp/handshake.d.ts.map +0 -0
- package/dist/ncp/handshake.js +0 -0
- package/dist/ncp/handshake.js.map +0 -0
- package/dist/ncp/index.d.ts +1 -0
- package/dist/ncp/index.d.ts.map +1 -1
- package/dist/ncp/index.js +1 -0
- package/dist/ncp/index.js.map +1 -1
- package/dist/ncp/ncp-error-codes.d.ts +1 -0
- package/dist/ncp/ncp-error-codes.d.ts.map +1 -1
- package/dist/ncp/ncp-error-codes.js +2 -0
- package/dist/ncp/ncp-error-codes.js.map +1 -1
- package/dist/ncp/ncp-patch-format.d.ts +0 -0
- package/dist/ncp/ncp-patch-format.d.ts.map +0 -0
- package/dist/ncp/ncp-patch-format.js +0 -0
- package/dist/ncp/ncp-patch-format.js.map +0 -0
- package/dist/ncp/preamble.d.ts +47 -0
- package/dist/ncp/preamble.d.ts.map +1 -0
- package/dist/ncp/preamble.js +74 -0
- package/dist/ncp/preamble.js.map +1 -0
- package/dist/ncp/registry.d.ts +0 -0
- package/dist/ncp/registry.d.ts.map +0 -0
- package/dist/ncp/registry.js +0 -0
- package/dist/ncp/registry.js.map +0 -0
- package/dist/ncp/stream-manager.d.ts +0 -0
- package/dist/ncp/stream-manager.d.ts.map +0 -0
- package/dist/ncp/stream-manager.js +0 -0
- package/dist/ncp/stream-manager.js.map +0 -0
- package/dist/ndp/frames.d.ts +0 -0
- package/dist/ndp/frames.d.ts.map +0 -0
- package/dist/ndp/frames.js +0 -0
- package/dist/ndp/frames.js.map +0 -0
- package/dist/ndp/index.d.ts +0 -0
- package/dist/ndp/index.d.ts.map +0 -0
- package/dist/ndp/index.js +0 -0
- package/dist/ndp/index.js.map +0 -0
- package/dist/ndp/ndp-registry.d.ts +0 -0
- package/dist/ndp/ndp-registry.d.ts.map +0 -0
- package/dist/ndp/ndp-registry.js +0 -0
- package/dist/ndp/ndp-registry.js.map +0 -0
- package/dist/ndp/registry.d.ts +0 -0
- package/dist/ndp/registry.d.ts.map +0 -0
- package/dist/ndp/registry.js +0 -0
- package/dist/ndp/registry.js.map +0 -0
- package/dist/ndp/validator.d.ts +0 -0
- package/dist/ndp/validator.d.ts.map +0 -0
- package/dist/ndp/validator.js +0 -0
- package/dist/ndp/validator.js.map +0 -0
- package/dist/nip/acme/client.d.ts +31 -0
- package/dist/nip/acme/client.d.ts.map +1 -0
- package/dist/nip/acme/client.js +136 -0
- package/dist/nip/acme/client.js.map +1 -0
- package/dist/nip/acme/index.d.ts +6 -0
- package/dist/nip/acme/index.d.ts.map +1 -0
- package/dist/nip/acme/index.js +8 -0
- package/dist/nip/acme/index.js.map +1 -0
- package/dist/nip/acme/jws.d.ts +31 -0
- package/dist/nip/acme/jws.d.ts.map +1 -0
- package/dist/nip/acme/jws.js +76 -0
- package/dist/nip/acme/jws.js.map +1 -0
- package/dist/nip/acme/messages.d.ts +71 -0
- package/dist/nip/acme/messages.d.ts.map +1 -0
- package/dist/nip/acme/messages.js +4 -0
- package/dist/nip/acme/messages.js.map +1 -0
- package/dist/nip/acme/server.d.ts +41 -0
- package/dist/nip/acme/server.d.ts.map +1 -0
- package/dist/nip/acme/server.js +458 -0
- package/dist/nip/acme/server.js.map +1 -0
- package/dist/nip/acme/wire.d.ts +19 -0
- package/dist/nip/acme/wire.d.ts.map +1 -0
- package/dist/nip/acme/wire.js +21 -0
- package/dist/nip/acme/wire.js.map +1 -0
- package/dist/nip/assurance-level.d.ts +14 -0
- package/dist/nip/assurance-level.d.ts.map +1 -0
- package/dist/nip/assurance-level.js +33 -0
- package/dist/nip/assurance-level.js.map +1 -0
- package/dist/nip/cert-format.d.ts +5 -0
- package/dist/nip/cert-format.d.ts.map +1 -0
- package/dist/nip/cert-format.js +6 -0
- package/dist/nip/cert-format.js.map +1 -0
- package/dist/nip/error-codes.d.ts +23 -0
- package/dist/nip/error-codes.d.ts.map +1 -0
- package/dist/nip/error-codes.js +30 -0
- package/dist/nip/error-codes.js.map +1 -0
- package/dist/nip/frames.d.ts +10 -1
- package/dist/nip/frames.d.ts.map +1 -1
- package/dist/nip/frames.js +29 -4
- package/dist/nip/frames.js.map +1 -1
- package/dist/nip/identity.d.ts +0 -0
- package/dist/nip/identity.d.ts.map +0 -0
- package/dist/nip/identity.js +0 -0
- package/dist/nip/identity.js.map +0 -0
- package/dist/nip/index.d.ts +6 -0
- package/dist/nip/index.d.ts.map +1 -1
- package/dist/nip/index.js +7 -0
- package/dist/nip/index.js.map +1 -1
- package/dist/nip/registry.d.ts +0 -0
- package/dist/nip/registry.d.ts.map +0 -0
- package/dist/nip/registry.js +0 -0
- package/dist/nip/registry.js.map +0 -0
- package/dist/nip/verifier.d.ts +23 -0
- package/dist/nip/verifier.d.ts.map +1 -0
- package/dist/nip/verifier.js +90 -0
- package/dist/nip/verifier.js.map +1 -0
- package/dist/nip/x509/builder.d.ts +35 -0
- package/dist/nip/x509/builder.d.ts.map +1 -0
- package/dist/nip/x509/builder.js +59 -0
- package/dist/nip/x509/builder.js.map +1 -0
- package/dist/nip/x509/index.d.ts +4 -0
- package/dist/nip/x509/index.d.ts.map +1 -0
- package/dist/nip/x509/index.js +6 -0
- package/dist/nip/x509/index.js.map +1 -0
- package/dist/nip/x509/oids.d.ts +17 -0
- package/dist/nip/x509/oids.d.ts.map +1 -0
- package/dist/nip/x509/oids.js +23 -0
- package/dist/nip/x509/oids.js.map +1 -0
- package/dist/nip/x509/verifier.d.ts +26 -0
- package/dist/nip/x509/verifier.d.ts.map +1 -0
- package/dist/nip/x509/verifier.js +171 -0
- package/dist/nip/x509/verifier.js.map +1 -0
- package/dist/nop/client.d.ts +0 -0
- package/dist/nop/client.d.ts.map +0 -0
- package/dist/nop/client.js +0 -0
- package/dist/nop/client.js.map +0 -0
- package/dist/nop/frames.d.ts +0 -0
- package/dist/nop/frames.d.ts.map +0 -0
- package/dist/nop/frames.js +0 -0
- package/dist/nop/frames.js.map +0 -0
- package/dist/nop/index.d.ts +0 -0
- package/dist/nop/index.d.ts.map +0 -0
- package/dist/nop/index.js +0 -0
- package/dist/nop/index.js.map +0 -0
- package/dist/nop/models.d.ts +0 -0
- package/dist/nop/models.d.ts.map +0 -0
- package/dist/nop/models.js +0 -0
- package/dist/nop/models.js.map +0 -0
- package/dist/nop/nop-types.d.ts +0 -0
- package/dist/nop/nop-types.d.ts.map +0 -0
- package/dist/nop/nop-types.js +0 -0
- package/dist/nop/nop-types.js.map +0 -0
- package/dist/nop/registry.d.ts +0 -0
- package/dist/nop/registry.d.ts.map +0 -0
- package/dist/nop/registry.js +0 -0
- package/dist/nop/registry.js.map +0 -0
- package/dist/nwp/client.d.ts +0 -0
- package/dist/nwp/client.d.ts.map +0 -0
- package/dist/nwp/client.js +0 -0
- package/dist/nwp/client.js.map +0 -0
- package/dist/nwp/frames.d.ts +0 -0
- package/dist/nwp/frames.d.ts.map +0 -0
- package/dist/nwp/frames.js +0 -0
- package/dist/nwp/frames.js.map +0 -0
- package/dist/nwp/index.d.ts +0 -0
- package/dist/nwp/index.d.ts.map +0 -0
- package/dist/nwp/index.js +0 -0
- package/dist/nwp/index.js.map +0 -0
- package/dist/nwp/registry.d.ts +0 -0
- package/dist/nwp/registry.d.ts.map +0 -0
- package/dist/nwp/registry.js +0 -0
- package/dist/nwp/registry.js.map +0 -0
- package/dist/setup.d.ts +0 -0
- package/dist/setup.d.ts.map +0 -0
- package/dist/setup.js +0 -0
- package/dist/setup.js.map +0 -0
- package/package.json +2 -1
- package/src/index.ts +0 -0
- package/src/ncp/index.ts +1 -0
- package/src/ncp/ncp-error-codes.ts +2 -0
- package/src/ncp/preamble.ts +79 -0
- package/src/nip/acme/client.ts +185 -0
- package/src/nip/acme/index.ts +8 -0
- package/src/nip/acme/jws.ts +109 -0
- package/src/nip/acme/messages.ts +85 -0
- package/src/nip/acme/server.ts +480 -0
- package/src/nip/acme/wire.ts +24 -0
- package/src/nip/assurance-level.ts +35 -0
- package/src/nip/cert-format.ts +9 -0
- package/src/nip/error-codes.ts +36 -0
- package/src/nip/frames.ts +35 -3
- package/src/nip/index.ts +8 -0
- package/src/nip/verifier.ts +122 -0
- package/src/nip/x509/builder.ts +91 -0
- package/src/nip/x509/index.ts +6 -0
- package/src/nip/x509/oids.ts +28 -0
- package/src/nip/x509/verifier.ts +214 -0
- package/tests/_rfc0002-keys.ts +57 -0
- package/tests/ncp/preamble.test.ts +93 -0
- package/tests/nip-acme-agent01.test.ts +192 -0
- package/tests/nip-x509.test.ts +280 -0
- package/.npmrc.publish +0 -1
- package/dist/codec-CmHeovTV.d.cts +0 -120
- package/dist/codec-CmHeovTV.d.ts +0 -120
- package/dist/core/index.cjs +0 -371
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -41
- package/dist/frames-B3qLdl_g.d.cts +0 -77
- package/dist/frames-Ff7-ZPUl.d.ts +0 -77
- package/dist/index.cjs +0 -1556
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -21
- package/dist/ncp/index.cjs +0 -188
- package/dist/ncp/index.cjs.map +0 -1
- package/dist/ncp/index.d.cts +0 -6
- package/dist/ndp/index.cjs +0 -252
- package/dist/ndp/index.cjs.map +0 -1
- package/dist/ndp/index.d.cts +0 -86
- package/dist/nip/index.cjs +0 -214
- package/dist/nip/index.cjs.map +0 -1
- package/dist/nip/index.d.cts +0 -65
- package/dist/nop/index.cjs +0 -762
- package/dist/nop/index.cjs.map +0 -1
- package/dist/nop/index.d.cts +0 -155
- package/dist/nwp/index.cjs +0 -658
- package/dist/nwp/index.cjs.map +0 -1
- package/dist/nwp/index.d.cts +0 -65
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Copyright 2026 INNO LOTUS PTY LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JWS signing helpers for ACME with Ed25519 (`alg: "EdDSA"` per RFC 8037).
|
|
6
|
+
*
|
|
7
|
+
* Wire shape (RFC 8555 §6.2 + RFC 7515 flattened JWS JSON serialization):
|
|
8
|
+
* {
|
|
9
|
+
* "protected": base64url(JSON({alg, nonce, url, [jwk|kid]})),
|
|
10
|
+
* "payload": base64url(JSON(payload)),
|
|
11
|
+
* "signature": base64url(Ed25519(protected || "." || payload))
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as ed25519 from "@noble/ed25519";
|
|
16
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
17
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
18
|
+
|
|
19
|
+
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
|
|
20
|
+
|
|
21
|
+
export const ALG_EDDSA = "EdDSA"; // RFC 8037 §3.1
|
|
22
|
+
export const KTY_OKP = "OKP"; // RFC 8037 §2
|
|
23
|
+
export const CRV_ED25519 = "Ed25519"; // RFC 8037 §2
|
|
24
|
+
|
|
25
|
+
export interface Jwk {
|
|
26
|
+
kty: string;
|
|
27
|
+
crv: string;
|
|
28
|
+
x: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProtectedHeader {
|
|
32
|
+
alg: string;
|
|
33
|
+
nonce: string;
|
|
34
|
+
url: string;
|
|
35
|
+
jwk?: Jwk;
|
|
36
|
+
kid?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Envelope {
|
|
40
|
+
protected: string;
|
|
41
|
+
payload: string;
|
|
42
|
+
signature: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function jwkFromPublicKey(rawPubKey: Uint8Array): Jwk {
|
|
46
|
+
if (rawPubKey.length !== 32) {
|
|
47
|
+
throw new Error(`Ed25519 public key must be 32 bytes, got ${rawPubKey.length}`);
|
|
48
|
+
}
|
|
49
|
+
return { kty: KTY_OKP, crv: CRV_ED25519, x: b64uEncode(rawPubKey) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function publicKeyFromJwk(jwk: Jwk): Uint8Array {
|
|
53
|
+
if (jwk.kty !== KTY_OKP || jwk.crv !== CRV_ED25519) {
|
|
54
|
+
throw new Error(`JWK is not OKP/Ed25519: kty=${jwk.kty} crv=${jwk.crv}`);
|
|
55
|
+
}
|
|
56
|
+
return b64uDecode(jwk.x);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** RFC 7638 §3 thumbprint of an Ed25519 JWK (lex-sorted compact JSON, SHA-256, base64url). */
|
|
60
|
+
export function thumbprint(jwk: Jwk): string {
|
|
61
|
+
const canonical = `{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}"}`;
|
|
62
|
+
return b64uEncode(sha256(new TextEncoder().encode(canonical)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sign(
|
|
66
|
+
header: ProtectedHeader,
|
|
67
|
+
payload: unknown | null,
|
|
68
|
+
privKey: Uint8Array,
|
|
69
|
+
): Envelope {
|
|
70
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
|
|
71
|
+
const headerB64u = b64uEncode(headerBytes);
|
|
72
|
+
const payloadB64u = payload === null
|
|
73
|
+
? ""
|
|
74
|
+
: b64uEncode(new TextEncoder().encode(JSON.stringify(payload)));
|
|
75
|
+
const signingInput = new TextEncoder().encode(`${headerB64u}.${payloadB64u}`);
|
|
76
|
+
const sig = ed25519.sign(signingInput, privKey);
|
|
77
|
+
return { protected: headerB64u, payload: payloadB64u, signature: b64uEncode(sig) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Verify a JWS envelope. Returns the parsed protected header on success, else null. */
|
|
81
|
+
export function verify(envelope: Envelope, pubKey: Uint8Array): ProtectedHeader | null {
|
|
82
|
+
try {
|
|
83
|
+
const signingInput = new TextEncoder().encode(`${envelope.protected}.${envelope.payload}`);
|
|
84
|
+
const sigBytes = b64uDecode(envelope.signature);
|
|
85
|
+
if (!ed25519.verify(sigBytes, signingInput, pubKey)) return null;
|
|
86
|
+
const headerJson = new TextDecoder().decode(b64uDecode(envelope.protected));
|
|
87
|
+
return JSON.parse(headerJson) as ProtectedHeader;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decodePayload<T = unknown>(envelope: Envelope): T | null {
|
|
94
|
+
if (!envelope.payload) return null;
|
|
95
|
+
return JSON.parse(new TextDecoder().decode(b64uDecode(envelope.payload))) as T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function b64uEncode(bytes: Uint8Array): string {
|
|
101
|
+
return Buffer.from(bytes).toString("base64").replace(/=+$/, "")
|
|
102
|
+
.replace(/\+/g, "-").replace(/\//g, "_");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function b64uDecode(s: string): Uint8Array {
|
|
106
|
+
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
|
|
107
|
+
const std = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
108
|
+
return new Uint8Array(Buffer.from(std, "base64"));
|
|
109
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright 2026 INNO LOTUS PTY LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** ACME wire-level DTOs (RFC 8555 + NPS-RFC-0002 §4.4) — plain interfaces. */
|
|
5
|
+
|
|
6
|
+
export interface DirectoryMeta {
|
|
7
|
+
termsOfService?: string;
|
|
8
|
+
website?: string;
|
|
9
|
+
caaIdentities?: readonly string[];
|
|
10
|
+
externalAccountRequired?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Directory {
|
|
14
|
+
newNonce: string;
|
|
15
|
+
newAccount: string;
|
|
16
|
+
newOrder: string;
|
|
17
|
+
revokeCert?: string;
|
|
18
|
+
keyChange?: string;
|
|
19
|
+
meta?: DirectoryMeta;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NewAccountPayload {
|
|
23
|
+
termsOfServiceAgreed?: boolean;
|
|
24
|
+
contact?: readonly string[];
|
|
25
|
+
onlyReturnExisting?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Account {
|
|
29
|
+
status: string;
|
|
30
|
+
contact?: readonly string[];
|
|
31
|
+
orders?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Identifier {
|
|
35
|
+
type: string; // "nid" per NPS-RFC-0002 §4.4
|
|
36
|
+
value: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NewOrderPayload {
|
|
40
|
+
identifiers: readonly Identifier[];
|
|
41
|
+
notBefore?: string;
|
|
42
|
+
notAfter?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ProblemDetail {
|
|
46
|
+
type: string;
|
|
47
|
+
detail?: string;
|
|
48
|
+
status?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Order {
|
|
52
|
+
status: string;
|
|
53
|
+
expires?: string;
|
|
54
|
+
identifiers: readonly Identifier[];
|
|
55
|
+
authorizations: readonly string[];
|
|
56
|
+
finalize: string;
|
|
57
|
+
certificate?: string;
|
|
58
|
+
error?: ProblemDetail;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Challenge {
|
|
62
|
+
type: string; // "agent-01" per NPS-RFC-0002 §4.4
|
|
63
|
+
url: string;
|
|
64
|
+
status: string;
|
|
65
|
+
token: string;
|
|
66
|
+
validated?: string;
|
|
67
|
+
error?: ProblemDetail;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Authorization {
|
|
71
|
+
status: string;
|
|
72
|
+
expires?: string;
|
|
73
|
+
identifier: Identifier;
|
|
74
|
+
challenges: readonly Challenge[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ChallengeRespondPayload {
|
|
78
|
+
/** base64url(Ed25519(token)) per NPS-RFC-0002 §4.4. */
|
|
79
|
+
agent_signature: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FinalizePayload {
|
|
83
|
+
/** base64url(CSR DER). */
|
|
84
|
+
csr: string;
|
|
85
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Copyright 2026 INNO LOTUS PTY LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-process ACME server implementing the `agent-01` challenge for NPS-RFC-0002 §4.4.
|
|
6
|
+
*
|
|
7
|
+
* Backed by Node's stdlib `http.createServer`. Suitable for tests and reference
|
|
8
|
+
* deployments. State is kept in memory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as ed25519 from "@noble/ed25519";
|
|
12
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
13
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import * as x509 from "@peculiar/x509";
|
|
16
|
+
|
|
17
|
+
import { AssuranceLevel } from "../assurance-level.js";
|
|
18
|
+
import { ACME_CHALLENGE_FAILED } from "../error-codes.js";
|
|
19
|
+
import { issueLeaf } from "../x509/builder.js";
|
|
20
|
+
import * as Jws from "./jws.js";
|
|
21
|
+
import type {
|
|
22
|
+
Authorization, Challenge, ChallengeRespondPayload, Directory,
|
|
23
|
+
FinalizePayload, Identifier, NewOrderPayload, Order, ProblemDetail,
|
|
24
|
+
} from "./messages.js";
|
|
25
|
+
import * as wire from "./wire.js";
|
|
26
|
+
|
|
27
|
+
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
|
|
28
|
+
x509.cryptoProvider.set(globalThis.crypto);
|
|
29
|
+
|
|
30
|
+
export interface AcmeServerOptions {
|
|
31
|
+
caNid: string;
|
|
32
|
+
caKeys: CryptoKeyPair; // Web Crypto Ed25519 keypair (for issuing X.509 leaves).
|
|
33
|
+
caRootCert: x509.X509Certificate;
|
|
34
|
+
certValidityMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface OrderState {
|
|
38
|
+
id: string;
|
|
39
|
+
identifier: Identifier;
|
|
40
|
+
status: string;
|
|
41
|
+
authzId: string;
|
|
42
|
+
finalizeUrl: string;
|
|
43
|
+
accountUrl: string;
|
|
44
|
+
certificateUrl?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AuthzState {
|
|
48
|
+
id: string;
|
|
49
|
+
identifier: Identifier;
|
|
50
|
+
status: string;
|
|
51
|
+
challengeIds: string[];
|
|
52
|
+
accountUrl: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ChallengeState {
|
|
56
|
+
id: string;
|
|
57
|
+
type: string;
|
|
58
|
+
status: string;
|
|
59
|
+
token: string;
|
|
60
|
+
authzId: string;
|
|
61
|
+
accountUrl: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AcmeServer {
|
|
65
|
+
private readonly server: Server;
|
|
66
|
+
private readonly nonces = new Set<string>();
|
|
67
|
+
private readonly accountJwks = new Map<string, Jws.Jwk>();
|
|
68
|
+
private readonly orders = new Map<string, OrderState>();
|
|
69
|
+
private readonly authzs = new Map<string, AuthzState>();
|
|
70
|
+
private readonly challenges = new Map<string, ChallengeState>();
|
|
71
|
+
private readonly certs = new Map<string, string>();
|
|
72
|
+
private boundPort: number = 0;
|
|
73
|
+
|
|
74
|
+
constructor(public readonly options: AcmeServerOptions) {
|
|
75
|
+
this.server = createServer((req, res) => this.dispatch(req, res));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async start(): Promise<this> {
|
|
79
|
+
await new Promise<void>((resolve) => {
|
|
80
|
+
this.server.listen(0, "127.0.0.1", () => resolve());
|
|
81
|
+
});
|
|
82
|
+
const addr = this.server.address();
|
|
83
|
+
this.boundPort = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): Promise<void> {
|
|
88
|
+
return new Promise((resolve) => this.server.close(() => resolve()));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get baseUrl(): string { return `http://127.0.0.1:${this.boundPort}`; }
|
|
92
|
+
get directoryUrl(): string { return `${this.baseUrl}/directory`; }
|
|
93
|
+
|
|
94
|
+
// ── Routing ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
private async dispatch(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
97
|
+
const url = req.url ?? "/";
|
|
98
|
+
const method = req.method ?? "GET";
|
|
99
|
+
try {
|
|
100
|
+
if (method === "GET" && url === "/directory") return this.handleDirectory(res);
|
|
101
|
+
if (url === "/new-nonce") return this.handleNewNonce(method, res);
|
|
102
|
+
if (method === "POST" && url === "/new-account") return await this.handleNewAccount(req, res);
|
|
103
|
+
if (method === "POST" && url === "/new-order") return await this.handleNewOrder(req, res);
|
|
104
|
+
if (method === "POST" && url.startsWith("/authz/")) return await this.handleAuthz(req, res, url);
|
|
105
|
+
if (method === "POST" && url.startsWith("/chall/")) return await this.handleChallenge(req, res, url);
|
|
106
|
+
if (method === "POST" && url.startsWith("/finalize/")) return await this.handleFinalize(req, res, url);
|
|
107
|
+
if (method === "POST" && url.startsWith("/cert/")) return await this.handleCert(req, res, url);
|
|
108
|
+
if (method === "POST" && url.startsWith("/order/")) return await this.handleOrder(req, res, url);
|
|
109
|
+
this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no such resource");
|
|
110
|
+
} catch (e) {
|
|
111
|
+
this.sendProblem(res, 500, "urn:ietf:params:acme:error:serverInternal",
|
|
112
|
+
(e as Error).message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Endpoint handlers ────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
private handleDirectory(res: ServerResponse): void {
|
|
119
|
+
const dir: Directory = {
|
|
120
|
+
newNonce: `${this.baseUrl}/new-nonce`,
|
|
121
|
+
newAccount: `${this.baseUrl}/new-account`,
|
|
122
|
+
newOrder: `${this.baseUrl}/new-order`,
|
|
123
|
+
};
|
|
124
|
+
this.sendJson(res, 200, dir);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private handleNewNonce(method: string, res: ServerResponse): void {
|
|
128
|
+
res.statusCode = method === "HEAD" ? 200 : 204;
|
|
129
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
130
|
+
res.setHeader("Cache-Control", "no-store");
|
|
131
|
+
res.end();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async handleNewAccount(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
135
|
+
const env = await this.readEnvelope(req, res);
|
|
136
|
+
if (!env) return;
|
|
137
|
+
const header = this.parseHeader(env, res);
|
|
138
|
+
if (!header) return;
|
|
139
|
+
if (!header.jwk) {
|
|
140
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed",
|
|
141
|
+
"newAccount must include a 'jwk' member");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
145
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const pub = Jws.publicKeyFromJwk(header.jwk);
|
|
149
|
+
if (Jws.verify(env, pub) === null) {
|
|
150
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed",
|
|
151
|
+
"JWS signature verify failed");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const accountId = `acc-${shortId()}`;
|
|
156
|
+
const accountUrl = `${this.baseUrl}/account/${accountId}`;
|
|
157
|
+
this.accountJwks.set(accountUrl, header.jwk);
|
|
158
|
+
|
|
159
|
+
res.statusCode = 201;
|
|
160
|
+
res.setHeader("Content-Type", "application/json");
|
|
161
|
+
res.setHeader("Location", accountUrl);
|
|
162
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
163
|
+
res.end(JSON.stringify({ status: wire.Status.VALID }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async handleNewOrder(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
167
|
+
const env = await this.readEnvelope(req, res);
|
|
168
|
+
if (!env) return;
|
|
169
|
+
const header = this.parseHeader(env, res);
|
|
170
|
+
if (!header) return;
|
|
171
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
172
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
173
|
+
}
|
|
174
|
+
if (!this.verifyAccount(env, header)) {
|
|
175
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:accountDoesNotExist",
|
|
176
|
+
`unknown kid: ${header.kid ?? "<missing>"}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const payload = Jws.decodePayload<NewOrderPayload>(env);
|
|
181
|
+
if (!payload || !payload.identifiers?.length) {
|
|
182
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed", "missing identifiers");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const ident = payload.identifiers[0];
|
|
186
|
+
const orderId = `ord-${shortId()}`;
|
|
187
|
+
const authzId = `az-${shortId()}`;
|
|
188
|
+
const challId = `ch-${shortId()}`;
|
|
189
|
+
const token = Jws.b64uEncode(new Uint8Array(randomBytes(32)));
|
|
190
|
+
|
|
191
|
+
const orderUrl = `${this.baseUrl}/order/${orderId}`;
|
|
192
|
+
const authzUrl = `${this.baseUrl}/authz/${authzId}`;
|
|
193
|
+
const challUrl = `${this.baseUrl}/chall/${challId}`;
|
|
194
|
+
const finalizeUrl = `${this.baseUrl}/finalize/${orderId}`;
|
|
195
|
+
|
|
196
|
+
this.challenges.set(challId, {
|
|
197
|
+
id: challId, type: wire.CHALLENGE_AGENT_01, status: wire.Status.PENDING,
|
|
198
|
+
token, authzId, accountUrl: header.kid ?? "",
|
|
199
|
+
});
|
|
200
|
+
this.authzs.set(authzId, {
|
|
201
|
+
id: authzId, identifier: ident, status: wire.Status.PENDING,
|
|
202
|
+
challengeIds: [challId], accountUrl: header.kid ?? "",
|
|
203
|
+
});
|
|
204
|
+
this.orders.set(orderId, {
|
|
205
|
+
id: orderId, identifier: ident, status: wire.Status.PENDING,
|
|
206
|
+
authzId, finalizeUrl, accountUrl: header.kid ?? "",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const order: Order = {
|
|
210
|
+
status: wire.Status.PENDING,
|
|
211
|
+
identifiers: [ident],
|
|
212
|
+
authorizations: [authzUrl],
|
|
213
|
+
finalize: finalizeUrl,
|
|
214
|
+
};
|
|
215
|
+
res.statusCode = 201;
|
|
216
|
+
res.setHeader("Content-Type", "application/json");
|
|
217
|
+
res.setHeader("Location", orderUrl);
|
|
218
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
219
|
+
res.end(JSON.stringify(order));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async handleAuthz(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
|
223
|
+
const env = await this.readEnvelope(req, res); if (!env) return;
|
|
224
|
+
const header = this.parseHeader(env, res); if (!header) return;
|
|
225
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
226
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
227
|
+
}
|
|
228
|
+
if (!this.verifyAccount(env, header)) {
|
|
229
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:unauthorized", "bad sig"); return;
|
|
230
|
+
}
|
|
231
|
+
const id = url.replace(/^\/authz\//, "");
|
|
232
|
+
const az = this.authzs.get(id);
|
|
233
|
+
if (!az) { this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no authz"); return; }
|
|
234
|
+
|
|
235
|
+
const challenges: Challenge[] = az.challengeIds.map((cid) => {
|
|
236
|
+
const cs = this.challenges.get(cid)!;
|
|
237
|
+
return {
|
|
238
|
+
type: cs.type, url: `${this.baseUrl}/chall/${cs.id}`,
|
|
239
|
+
status: cs.status, token: cs.token,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
const authz: Authorization = {
|
|
243
|
+
status: az.status, identifier: az.identifier, challenges,
|
|
244
|
+
};
|
|
245
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
246
|
+
this.sendJson(res, 200, authz);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async handleChallenge(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
|
250
|
+
const env = await this.readEnvelope(req, res); if (!env) return;
|
|
251
|
+
const header = this.parseHeader(env, res); if (!header) return;
|
|
252
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
253
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
254
|
+
}
|
|
255
|
+
const accountJwk = this.accountJwks.get(header.kid ?? "");
|
|
256
|
+
if (!accountJwk) {
|
|
257
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:accountDoesNotExist", "unknown kid");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const accountPub = Jws.publicKeyFromJwk(accountJwk);
|
|
261
|
+
if (Jws.verify(env, accountPub) === null) {
|
|
262
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed", "JWS sig fail"); return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const id = url.replace(/^\/chall\//, "");
|
|
266
|
+
const ch = this.challenges.get(id);
|
|
267
|
+
if (!ch) { this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no chall"); return; }
|
|
268
|
+
|
|
269
|
+
const payload = Jws.decodePayload<ChallengeRespondPayload>(env);
|
|
270
|
+
if (!payload?.agent_signature) {
|
|
271
|
+
ch.status = wire.Status.INVALID;
|
|
272
|
+
this.sendProblem(res, 400, ACME_CHALLENGE_FAILED,
|
|
273
|
+
"missing agent_signature in challenge response");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const sigBytes = Jws.b64uDecode(payload.agent_signature);
|
|
278
|
+
const tokenBytes = new TextEncoder().encode(ch.token);
|
|
279
|
+
if (!ed25519.verify(sigBytes, tokenBytes, accountPub)) {
|
|
280
|
+
ch.status = wire.Status.INVALID;
|
|
281
|
+
this.sendProblem(res, 400, ACME_CHALLENGE_FAILED,
|
|
282
|
+
"agent-01 signature did not verify");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {
|
|
286
|
+
ch.status = wire.Status.INVALID;
|
|
287
|
+
this.sendProblem(res, 400, ACME_CHALLENGE_FAILED,
|
|
288
|
+
`agent-01 verification error: ${(e as Error).message}`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
ch.status = wire.Status.VALID;
|
|
293
|
+
const az = this.authzs.get(ch.authzId);
|
|
294
|
+
if (az) az.status = wire.Status.VALID;
|
|
295
|
+
for (const o of this.orders.values()) {
|
|
296
|
+
if (o.authzId === ch.authzId) o.status = wire.Status.READY;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
300
|
+
this.sendJson(res, 200, {
|
|
301
|
+
type: ch.type, url: `${this.baseUrl}/chall/${ch.id}`,
|
|
302
|
+
status: ch.status, token: ch.token,
|
|
303
|
+
} as Challenge);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async handleFinalize(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
|
307
|
+
const env = await this.readEnvelope(req, res); if (!env) return;
|
|
308
|
+
const header = this.parseHeader(env, res); if (!header) return;
|
|
309
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
310
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
311
|
+
}
|
|
312
|
+
if (!this.verifyAccount(env, header)) {
|
|
313
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:unauthorized", "bad sig"); return;
|
|
314
|
+
}
|
|
315
|
+
const orderId = url.replace(/^\/finalize\//, "");
|
|
316
|
+
const os = this.orders.get(orderId);
|
|
317
|
+
if (!os) { this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no order"); return; }
|
|
318
|
+
if (os.status !== wire.Status.READY) {
|
|
319
|
+
this.sendProblem(res, 403, "urn:ietf:params:acme:error:orderNotReady",
|
|
320
|
+
`order is in state '${os.status}', not 'ready'`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const fp = Jws.decodePayload<FinalizePayload>(env);
|
|
324
|
+
if (!fp?.csr) {
|
|
325
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed", "missing csr"); return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const csrDer = Jws.b64uDecode(fp.csr);
|
|
330
|
+
const csr = new x509.Pkcs10CertificateRequest(csrDer.buffer as ArrayBuffer);
|
|
331
|
+
const subjectCn = (() => {
|
|
332
|
+
for (const rdn of csr.subject.split(",")) {
|
|
333
|
+
const t = rdn.trim();
|
|
334
|
+
if (t.startsWith("CN=")) return t.slice(3).replace(/\\([",+;<>\\])/g, "$1");
|
|
335
|
+
}
|
|
336
|
+
return null as string | null;
|
|
337
|
+
})();
|
|
338
|
+
if (subjectCn !== os.identifier.value) {
|
|
339
|
+
this.sendProblem(res, 400, "NIP-CERT-SUBJECT-NID-MISMATCH",
|
|
340
|
+
`CSR subject CN '${subjectCn ?? ""}' does not match order identifier '${os.identifier.value}'`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const subjectPub = await csr.publicKey.export();
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const leaf = await issueLeaf({
|
|
346
|
+
subjectNid: os.identifier.value,
|
|
347
|
+
subjectPublicKey: subjectPub,
|
|
348
|
+
caKeys: this.options.caKeys,
|
|
349
|
+
issuerNid: this.options.caNid,
|
|
350
|
+
role: "agent",
|
|
351
|
+
assuranceLevel: AssuranceLevel.ANONYMOUS,
|
|
352
|
+
notBefore: new Date(now.getTime() - 60_000),
|
|
353
|
+
notAfter: new Date(now.getTime() + this.options.certValidityMs),
|
|
354
|
+
serialNumber: randomHexSerial(),
|
|
355
|
+
});
|
|
356
|
+
const certId = `crt-${shortId()}`;
|
|
357
|
+
const certUrl = `${this.baseUrl}/cert/${certId}`;
|
|
358
|
+
const pem = leaf.toString("pem") + this.options.caRootCert.toString("pem");
|
|
359
|
+
this.certs.set(certId, pem);
|
|
360
|
+
os.status = wire.Status.VALID;
|
|
361
|
+
os.certificateUrl = certUrl;
|
|
362
|
+
} catch (e) {
|
|
363
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badCSR",
|
|
364
|
+
`CSR processing failed: ${(e as Error).message}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const authzUrl = `${this.baseUrl}/authz/${os.authzId}`;
|
|
369
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
370
|
+
this.sendJson(res, 200, {
|
|
371
|
+
status: os.status, identifiers: [os.identifier],
|
|
372
|
+
authorizations: [authzUrl], finalize: os.finalizeUrl,
|
|
373
|
+
certificate: os.certificateUrl,
|
|
374
|
+
} as Order);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private async handleCert(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
|
378
|
+
const env = await this.readEnvelope(req, res); if (!env) return;
|
|
379
|
+
const header = this.parseHeader(env, res); if (!header) return;
|
|
380
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
381
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
382
|
+
}
|
|
383
|
+
if (!this.verifyAccount(env, header)) {
|
|
384
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:unauthorized", "bad sig"); return;
|
|
385
|
+
}
|
|
386
|
+
const certId = url.replace(/^\/cert\//, "");
|
|
387
|
+
const pem = this.certs.get(certId);
|
|
388
|
+
if (!pem) { this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no cert"); return; }
|
|
389
|
+
|
|
390
|
+
res.statusCode = 200;
|
|
391
|
+
res.setHeader("Content-Type", wire.CONTENT_TYPE_PEM_CERT);
|
|
392
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
393
|
+
res.end(pem);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async handleOrder(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
|
397
|
+
const env = await this.readEnvelope(req, res); if (!env) return;
|
|
398
|
+
const header = this.parseHeader(env, res); if (!header) return;
|
|
399
|
+
if (!this.consumeNonce(header.nonce)) {
|
|
400
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:badNonce", "invalid nonce"); return;
|
|
401
|
+
}
|
|
402
|
+
if (!this.verifyAccount(env, header)) {
|
|
403
|
+
this.sendProblem(res, 401, "urn:ietf:params:acme:error:unauthorized", "bad sig"); return;
|
|
404
|
+
}
|
|
405
|
+
const orderId = url.replace(/^\/order\//, "");
|
|
406
|
+
const os = this.orders.get(orderId);
|
|
407
|
+
if (!os) { this.sendProblem(res, 404, "urn:ietf:params:acme:error:malformed", "no order"); return; }
|
|
408
|
+
const authzUrl = `${this.baseUrl}/authz/${os.authzId}`;
|
|
409
|
+
res.setHeader("Replay-Nonce", this.mintNonce());
|
|
410
|
+
this.sendJson(res, 200, {
|
|
411
|
+
status: os.status, identifiers: [os.identifier],
|
|
412
|
+
authorizations: [authzUrl], finalize: os.finalizeUrl,
|
|
413
|
+
certificate: os.certificateUrl,
|
|
414
|
+
} as Order);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
private mintNonce(): string {
|
|
420
|
+
const n = Jws.b64uEncode(new Uint8Array(randomBytes(16)));
|
|
421
|
+
this.nonces.add(n);
|
|
422
|
+
return n;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private consumeNonce(nonce: string): boolean {
|
|
426
|
+
return this.nonces.delete(nonce);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private verifyAccount(env: Jws.Envelope, header: Jws.ProtectedHeader): boolean {
|
|
430
|
+
if (!header.kid) return false;
|
|
431
|
+
const jwk = this.accountJwks.get(header.kid);
|
|
432
|
+
if (!jwk) return false;
|
|
433
|
+
return Jws.verify(env, Jws.publicKeyFromJwk(jwk)) !== null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async readEnvelope(req: IncomingMessage, res: ServerResponse): Promise<Jws.Envelope | null> {
|
|
437
|
+
try {
|
|
438
|
+
const chunks: Buffer[] = [];
|
|
439
|
+
for await (const chunk of req) {
|
|
440
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
441
|
+
}
|
|
442
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
443
|
+
return JSON.parse(body) as Jws.Envelope;
|
|
444
|
+
} catch (e) {
|
|
445
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed",
|
|
446
|
+
`body read/parse failed: ${(e as Error).message}`);
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private parseHeader(env: Jws.Envelope, res: ServerResponse): Jws.ProtectedHeader | null {
|
|
452
|
+
try {
|
|
453
|
+
return JSON.parse(new TextDecoder().decode(Jws.b64uDecode(env.protected))) as Jws.ProtectedHeader;
|
|
454
|
+
} catch (e) {
|
|
455
|
+
this.sendProblem(res, 400, "urn:ietf:params:acme:error:malformed",
|
|
456
|
+
`malformed protected header: ${(e as Error).message}`);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
462
|
+
res.statusCode = status;
|
|
463
|
+
res.setHeader("Content-Type", "application/json");
|
|
464
|
+
res.end(JSON.stringify(body));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private sendProblem(res: ServerResponse, status: number, type: string, detail: string): void {
|
|
468
|
+
res.statusCode = status;
|
|
469
|
+
res.setHeader("Content-Type", wire.CONTENT_TYPE_PROBLEM);
|
|
470
|
+
res.end(JSON.stringify({ type, detail, status } as ProblemDetail));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function shortId(): string {
|
|
475
|
+
return Buffer.from(randomBytes(8)).toString("hex");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function randomHexSerial(): string {
|
|
479
|
+
return Buffer.from(randomBytes(20)).toString("hex");
|
|
480
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Copyright 2026 INNO LOTUS PTY LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** ACME wire constants (RFC 8555 + NPS-RFC-0002 §4.4). */
|
|
5
|
+
|
|
6
|
+
export const CONTENT_TYPE_JOSE_JSON = "application/jose+json";
|
|
7
|
+
export const CONTENT_TYPE_PROBLEM = "application/problem+json";
|
|
8
|
+
export const CONTENT_TYPE_PEM_CERT = "application/pem-certificate-chain";
|
|
9
|
+
|
|
10
|
+
export const CHALLENGE_AGENT_01 = "agent-01";
|
|
11
|
+
export const IDENTIFIER_TYPE_NID = "nid";
|
|
12
|
+
|
|
13
|
+
/** ACME status enumeration values (RFC 8555 §7.1.6). */
|
|
14
|
+
export const Status = {
|
|
15
|
+
PENDING: "pending",
|
|
16
|
+
READY: "ready",
|
|
17
|
+
PROCESSING: "processing",
|
|
18
|
+
VALID: "valid",
|
|
19
|
+
INVALID: "invalid",
|
|
20
|
+
EXPIRED: "expired",
|
|
21
|
+
DEACTIVATED: "deactivated",
|
|
22
|
+
REVOKED: "revoked",
|
|
23
|
+
SUBMITTED: "submitted",
|
|
24
|
+
} as const;
|