@riceawa/dsh-lan-gateway 0.3.0
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/LICENSE +20 -0
- package/README.md +221 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +733 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.js +1310 -0
- package/package.json +86 -0
- package/skills/lan-gateway.md +56 -0
- package/src/auth.ts +182 -0
- package/src/client/index.ts +89 -0
- package/src/client/lan-gateway-card.tsx +603 -0
- package/src/gateway.ts +343 -0
- package/src/index.ts +498 -0
- package/src/login.ts +133 -0
- package/src/state.ts +93 -0
- package/src/tls.ts +164 -0
- package/src/tool.ts +82 -0
- package/src/x509.ts +314 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent runtime state for the LAN gateway: the cookie-signing secret and
|
|
3
|
+
* the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
|
|
4
|
+
* NOT in the schemastery Config — secrets must never surface in
|
|
5
|
+
* `--dump-config` output. Writes are atomic (temp file + rename).
|
|
6
|
+
*
|
|
7
|
+
* @module @riceawa/dsh-lan-gateway/state
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
|
|
15
|
+
/** The state directory: `~/.dsh/lan-gateway`. */
|
|
16
|
+
export function stateDir(home: string = homedir()): string {
|
|
17
|
+
return join(home, '.dsh', 'lan-gateway')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PasswordRecord {
|
|
21
|
+
/** Hex scrypt-derived key. */
|
|
22
|
+
hash: string
|
|
23
|
+
/** Hex salt. */
|
|
24
|
+
salt: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GatewayState {
|
|
28
|
+
/** Base64 cookie-signing secret (32 random bytes). */
|
|
29
|
+
cookieSecret: string
|
|
30
|
+
/** scrypt password record, absent when no password is set. */
|
|
31
|
+
password?: PasswordRecord
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATE_FILENAME = 'state.json'
|
|
35
|
+
|
|
36
|
+
/** Whether a password is present and passes scrypt verification. */
|
|
37
|
+
export function verifyPassword(state: GatewayState, password: string): boolean {
|
|
38
|
+
if (state.password === undefined) return false
|
|
39
|
+
const { hash, salt } = state.password
|
|
40
|
+
try {
|
|
41
|
+
const expected = Buffer.from(hash, 'hex')
|
|
42
|
+
const actual = scryptSync(password, Buffer.from(salt, 'hex'), expected.length)
|
|
43
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Set (or clear) the password, re-salted on every write. */
|
|
50
|
+
export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
|
|
51
|
+
if (password === undefined) {
|
|
52
|
+
return { cookieSecret: state.cookieSecret }
|
|
53
|
+
}
|
|
54
|
+
const salt = randomBytes(16)
|
|
55
|
+
const hash = scryptSync(password, salt, 64)
|
|
56
|
+
return {
|
|
57
|
+
...state,
|
|
58
|
+
password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultState(): GatewayState {
|
|
63
|
+
return { cookieSecret: randomBytes(32).toString('base64') }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
67
|
+
export function loadState(home: string = homedir()): GatewayState {
|
|
68
|
+
const dir = stateDir(home)
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(join(dir, STATE_FILENAME), 'utf8')
|
|
71
|
+
const parsed = JSON.parse(raw) as GatewayState
|
|
72
|
+
if (typeof parsed?.cookieSecret === 'string' && parsed.cookieSecret.length >= 16) {
|
|
73
|
+
return parsed
|
|
74
|
+
}
|
|
75
|
+
return defaultState()
|
|
76
|
+
} catch {
|
|
77
|
+
return defaultState()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Persist state atomically. */
|
|
82
|
+
export function saveState(state: GatewayState, home: string = homedir()): void {
|
|
83
|
+
const dir = stateDir(home)
|
|
84
|
+
mkdirSync(dir, { recursive: true })
|
|
85
|
+
const target = join(dir, STATE_FILENAME)
|
|
86
|
+
const tmp = join(dir, `.state.${process.pid}.tmp`)
|
|
87
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
|
|
88
|
+
renameSync(tmp, target)
|
|
89
|
+
// Best-effort: keep the file private even if rename inherited a looser mode.
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(target, 0o600)
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
package/src/tls.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS material management for the gateway: self-signed certificates are
|
|
3
|
+
* generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
|
|
4
|
+
* restarts reuse the same certificate instead of minting a new one every
|
|
5
|
+
* boot; custom certificates are read straight from user-supplied PEM paths.
|
|
6
|
+
*
|
|
7
|
+
* @module @riceawa/dsh-lan-gateway/tls
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { X509Certificate } from 'node:crypto'
|
|
14
|
+
import { generateSelfSignedCert, type SelfSignedCertOptions } from './x509.ts'
|
|
15
|
+
|
|
16
|
+
/** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
|
|
17
|
+
export function tlsDir(home: string = homedir()): string {
|
|
18
|
+
return join(home, '.dsh', 'lan-gateway', 'tls')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SELF_SIGNED_CERT_FILE = 'selfsigned.crt'
|
|
22
|
+
export const SELF_SIGNED_KEY_FILE = 'selfsigned.key'
|
|
23
|
+
|
|
24
|
+
/** In-memory TLS material handed to the HTTPS server. */
|
|
25
|
+
export interface TlsMaterial {
|
|
26
|
+
cert: string
|
|
27
|
+
key: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options controlling self-signed certificate creation. */
|
|
31
|
+
export interface SelfSignedTlsOptions {
|
|
32
|
+
hosts: readonly string[]
|
|
33
|
+
days: number
|
|
34
|
+
commonName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function privateWrite(path: string, content: string): void {
|
|
38
|
+
writeFileSync(path, content, { mode: 0o600 })
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(path, 0o600)
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load the persisted self-signed certificate, generating it on first use.
|
|
46
|
+
* @param opts - hosts / validity for a fresh certificate.
|
|
47
|
+
* @param home - dsh home override (tests).
|
|
48
|
+
* @returns the material and whether it was just created.
|
|
49
|
+
*/
|
|
50
|
+
export function loadOrCreateSelfSigned(
|
|
51
|
+
opts: SelfSignedTlsOptions,
|
|
52
|
+
home: string = homedir(),
|
|
53
|
+
): { material: TlsMaterial; created: boolean } {
|
|
54
|
+
const dir = tlsDir(home)
|
|
55
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE)
|
|
56
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE)
|
|
57
|
+
if (existsSync(certPath) && existsSync(keyPath)) {
|
|
58
|
+
try {
|
|
59
|
+
const cert = readFileSync(certPath, 'utf8')
|
|
60
|
+
const key = readFileSync(keyPath, 'utf8')
|
|
61
|
+
new X509Certificate(cert) // sanity: must parse as a certificate
|
|
62
|
+
return { material: { cert, key }, created: false }
|
|
63
|
+
} catch {
|
|
64
|
+
// Corrupt or unreadable persisted material — regenerate below.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const material = generateSelfSignedMaterial(opts)
|
|
68
|
+
mkdirSync(dir, { recursive: true })
|
|
69
|
+
privateWrite(keyPath, material.key)
|
|
70
|
+
privateWrite(certPath, material.cert)
|
|
71
|
+
return { material, created: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
76
|
+
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
77
|
+
*/
|
|
78
|
+
export function regenerateSelfSigned(opts: SelfSignedTlsOptions, home: string = homedir()): TlsMaterial {
|
|
79
|
+
const dir = tlsDir(home)
|
|
80
|
+
mkdirSync(dir, { recursive: true })
|
|
81
|
+
const material = generateSelfSignedMaterial(opts)
|
|
82
|
+
privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key)
|
|
83
|
+
privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert)
|
|
84
|
+
return material
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function generateSelfSignedMaterial(opts: SelfSignedTlsOptions): TlsMaterial {
|
|
88
|
+
const hosts = opts.hosts.map(h => h.trim()).filter(h => h !== '')
|
|
89
|
+
if (hosts.length === 0) {
|
|
90
|
+
throw new Error('self-signed TLS needs at least one host in tlsSelfSignedHosts')
|
|
91
|
+
}
|
|
92
|
+
const certOptions: SelfSignedCertOptions = {
|
|
93
|
+
hosts,
|
|
94
|
+
days: opts.days,
|
|
95
|
+
...(opts.commonName !== undefined ? { commonName: opts.commonName } : {}),
|
|
96
|
+
}
|
|
97
|
+
const { certPem, keyPem } = generateSelfSignedCert(certOptions)
|
|
98
|
+
return { cert: certPem, key: keyPem }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a user-supplied certificate + key pair from PEM files.
|
|
103
|
+
* @param certPath - path to the PEM certificate (or chain).
|
|
104
|
+
* @param keyPath - path to the PEM private key.
|
|
105
|
+
* @returns the material.
|
|
106
|
+
*/
|
|
107
|
+
export function loadCustomCert(certPath: string, keyPath: string): TlsMaterial {
|
|
108
|
+
if (certPath === '') throw new Error('tlsMode=custom requires tlsCertPath (PEM certificate)')
|
|
109
|
+
if (keyPath === '') throw new Error('tlsMode=custom requires tlsKeyPath (PEM private key)')
|
|
110
|
+
let cert: string
|
|
111
|
+
try {
|
|
112
|
+
cert = readFileSync(certPath, 'utf8')
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`)
|
|
115
|
+
}
|
|
116
|
+
let key: string
|
|
117
|
+
try {
|
|
118
|
+
key = readFileSync(keyPath, 'utf8')
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`)
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
new X509Certificate(cert)
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error(`"${certPath}" does not contain a valid PEM certificate`)
|
|
126
|
+
}
|
|
127
|
+
return { cert, key }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
|
|
131
|
+
export function parseSelfSignedHosts(text: string | undefined): string[] {
|
|
132
|
+
return (text ?? '')
|
|
133
|
+
.split(/[,;]/)
|
|
134
|
+
.map(host => host.trim())
|
|
135
|
+
.filter(host => host !== '')
|
|
136
|
+
.slice(0, 32)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Readable summary of a PEM certificate for `status` output. */
|
|
140
|
+
export interface CertInfo {
|
|
141
|
+
subject: string
|
|
142
|
+
issuer: string
|
|
143
|
+
validFrom: string
|
|
144
|
+
validTo: string
|
|
145
|
+
fingerprint256: string
|
|
146
|
+
san?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Describe a PEM certificate (throws on malformed input). */
|
|
150
|
+
export function describeCert(certPem: string): CertInfo {
|
|
151
|
+
const cert = new X509Certificate(certPem)
|
|
152
|
+
return {
|
|
153
|
+
subject: cert.subject,
|
|
154
|
+
issuer: cert.issuer,
|
|
155
|
+
validFrom: cert.validFrom,
|
|
156
|
+
validTo: cert.validTo,
|
|
157
|
+
fingerprint256: cert.fingerprint256,
|
|
158
|
+
...(cert.subjectAltName !== undefined ? { san: cert.subjectAltName } : {}),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function errorMessage(error: unknown): string {
|
|
163
|
+
return error instanceof Error ? error.message : String(error)
|
|
164
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `lan_gateway` management tool: status, enable/disable, set
|
|
3
|
+
* password, rotate the cookie secret. Mirrors the harness convention of
|
|
4
|
+
* persistent plugins exposing runtime control through registered tools (as
|
|
5
|
+
* dsh-super-injector does with its `dev_*` tools). The password is passed as
|
|
6
|
+
* an explicit argument and never echoed back.
|
|
7
|
+
*
|
|
8
|
+
* @module @riceawa/dsh-lan-gateway/tool
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import type { GatewayController } from './index.ts'
|
|
14
|
+
|
|
15
|
+
export const LAN_GATEWAY_TOOL_NAME = 'lan_gateway'
|
|
16
|
+
|
|
17
|
+
type ToolCommand = 'status' | 'enable' | 'disable' | 'set-password' | 'rotate-secret' | 'tls-regenerate'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the `lan_gateway` tool over a controller interface implemented by the
|
|
21
|
+
* plugin entry. Split so the tool stays testable and the plugin decides how
|
|
22
|
+
* the controller mutates state.
|
|
23
|
+
*/
|
|
24
|
+
export function lanGatewayTool(control: GatewayController): ToolDefinition {
|
|
25
|
+
return defineTool({
|
|
26
|
+
name: LAN_GATEWAY_TOOL_NAME,
|
|
27
|
+
description:
|
|
28
|
+
'Manage the LAN/internet gateway for this DeepSeek Harness web GUI. '
|
|
29
|
+
+ '`status` shows whether the gateway is listening, on which port, toward which dsh port, '
|
|
30
|
+
+ 'whether a password is set, the trusted LAN CIDRs, and the TLS state. `enable` starts '
|
|
31
|
+
+ 'listening on 0.0.0.0 (loopback and LAN sources need no password; anything else must sign '
|
|
32
|
+
+ 'in). `disable` stops listening. `set-password` sets (or, with an empty password, clears) '
|
|
33
|
+
+ 'the gateway password for non-LAN access. `rotate-secret` invalidates every issued login '
|
|
34
|
+
+ 'cookie. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be '
|
|
35
|
+
+ 'self-signed) and restarts the listener.',
|
|
36
|
+
parameters: {
|
|
37
|
+
command: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['status', 'enable', 'disable', 'set-password', 'rotate-secret', 'tls-regenerate'],
|
|
40
|
+
description:
|
|
41
|
+
'`status` (default) — report gateway state. `enable` / `disable` — start or stop the '
|
|
42
|
+
+ 'listener. `set-password` — set or clear the login password. `rotate-secret` — '
|
|
43
|
+
+ 'invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate.',
|
|
44
|
+
},
|
|
45
|
+
password: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description:
|
|
48
|
+
'Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
output: {
|
|
52
|
+
schema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
properties: {
|
|
56
|
+
ok: { type: 'boolean', required: true },
|
|
57
|
+
message: { type: 'string', required: true },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
61
|
+
},
|
|
62
|
+
async execute(args, _exec) {
|
|
63
|
+
const command = (args.command ?? 'status') as ToolCommand
|
|
64
|
+
switch (command) {
|
|
65
|
+
case 'status':
|
|
66
|
+
return control.status()
|
|
67
|
+
case 'enable':
|
|
68
|
+
return control.enable()
|
|
69
|
+
case 'disable':
|
|
70
|
+
return control.disable()
|
|
71
|
+
case 'set-password': {
|
|
72
|
+
const password = args.password
|
|
73
|
+
return control.setPassword(typeof password === 'string' ? password : undefined)
|
|
74
|
+
}
|
|
75
|
+
case 'rotate-secret':
|
|
76
|
+
return control.rotateSecret()
|
|
77
|
+
case 'tls-regenerate':
|
|
78
|
+
return control.regenerateTls()
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|
package/src/x509.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal X.509 v3 self-signed certificate generator built on `node:crypto`
|
|
3
|
+
* only — no openssl binary, no npm dependencies.
|
|
4
|
+
*
|
|
5
|
+
* The certificate is a standard RSA-2048 / sha256WithRSAEncryption leaf cert
|
|
6
|
+
* (CA:FALSE) carrying the requested DNS/IP SANs, so browsers accept it for
|
|
7
|
+
* `https://<host>:<port>` after the user approves the self-signed warning.
|
|
8
|
+
*
|
|
9
|
+
* @module @riceawa/dsh-lan-gateway/x509
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createSign,
|
|
14
|
+
generateKeyPairSync,
|
|
15
|
+
randomBytes,
|
|
16
|
+
type KeyObject,
|
|
17
|
+
} from 'node:crypto'
|
|
18
|
+
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
/* ASN.1 DER primitives */
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
|
|
23
|
+
const TAG_SEQUENCE = 0x30
|
|
24
|
+
const TAG_SET = 0x31
|
|
25
|
+
const TAG_INTEGER = 0x02
|
|
26
|
+
const TAG_OID = 0x06
|
|
27
|
+
const TAG_BIT_STRING = 0x03
|
|
28
|
+
const TAG_OCTET_STRING = 0x04
|
|
29
|
+
const TAG_UTF8_STRING = 0x0c
|
|
30
|
+
const TAG_UTCTIME = 0x17
|
|
31
|
+
const TAG_NULL = 0x05
|
|
32
|
+
/** Context-specific primitive [2] (dNSName / iPAddress inside SAN). */
|
|
33
|
+
const TAG_CONTEXT_2 = 0x82
|
|
34
|
+
/** Context-specific primitive [7] (iPAddress). */
|
|
35
|
+
const TAG_CONTEXT_7 = 0x87
|
|
36
|
+
|
|
37
|
+
/** DER length octets (short form up to 127, long form above). */
|
|
38
|
+
function derLength(length: number): Buffer {
|
|
39
|
+
if (length < 0x80) return Buffer.from([length])
|
|
40
|
+
const bytes: number[] = []
|
|
41
|
+
let n = length
|
|
42
|
+
while (n > 0) {
|
|
43
|
+
bytes.unshift(n & 0xff)
|
|
44
|
+
n >>>= 8
|
|
45
|
+
}
|
|
46
|
+
return Buffer.from([0x80 | bytes.length, ...bytes])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tag a body with an identifier octet. */
|
|
50
|
+
function derTag(tag: number, body: Buffer): Buffer {
|
|
51
|
+
return Buffer.concat([Buffer.from([tag]), derLength(body.length), body])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** SEQUENCE OF parts. */
|
|
55
|
+
function derSeq(...parts: Buffer[]): Buffer {
|
|
56
|
+
return derTag(TAG_SEQUENCE, Buffer.concat(parts))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** SET OF parts (one RDN). */
|
|
60
|
+
function derSet(...parts: Buffer[]): Buffer {
|
|
61
|
+
return derTag(TAG_SET, Buffer.concat(parts))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** INTEGER from raw big-endian bytes (strips leading zeros, keeps sign bit clean). */
|
|
65
|
+
function derInt(value: Buffer): Buffer {
|
|
66
|
+
let start = 0
|
|
67
|
+
while (start < value.length - 1 && value[start] === 0) start += 1
|
|
68
|
+
let body = value.subarray(start)
|
|
69
|
+
if ((body[0]! & 0x80) !== 0) body = Buffer.concat([Buffer.from([0]), body])
|
|
70
|
+
return derTag(TAG_INTEGER, body)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** OBJECT IDENTIFIER from a dotted string like `1.2.840.113549.1.1.11`. */
|
|
74
|
+
function derOid(oid: string): Buffer {
|
|
75
|
+
const parts = oid.split('.').map(Number)
|
|
76
|
+
if (parts.length < 2 || parts.some(p => !Number.isInteger(p) || p < 0)) {
|
|
77
|
+
throw new Error(`invalid OID: ${oid}`)
|
|
78
|
+
}
|
|
79
|
+
const body: number[] = [parts[0]! * 40 + parts[1]!]
|
|
80
|
+
for (const part of parts.slice(2)) {
|
|
81
|
+
let n = part
|
|
82
|
+
const chunk: number[] = [n & 0x7f]
|
|
83
|
+
n >>>= 7
|
|
84
|
+
while (n > 0) {
|
|
85
|
+
chunk.unshift((n & 0x7f) | 0x80)
|
|
86
|
+
n >>>= 7
|
|
87
|
+
}
|
|
88
|
+
body.push(...chunk)
|
|
89
|
+
}
|
|
90
|
+
return derTag(TAG_OID, Buffer.from(body))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** BIT STRING over raw content (unused-bits octet prepended). */
|
|
94
|
+
function derBitString(content: Buffer, unusedBits = 0): Buffer {
|
|
95
|
+
return derTag(TAG_BIT_STRING, Buffer.concat([Buffer.from([unusedBits]), content]))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** OCTET STRING. */
|
|
99
|
+
function derOctetString(content: Buffer): Buffer {
|
|
100
|
+
return derTag(TAG_OCTET_STRING, content)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** UTF8String (legal DirectoryString for the CN). */
|
|
104
|
+
function derUtf8String(text: string): Buffer {
|
|
105
|
+
return derTag(TAG_UTF8_STRING, Buffer.from(text, 'utf8'))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** UTCTime: `YYMMDDHHMMSSZ` (valid until 2050). */
|
|
109
|
+
function derUtcTime(date: Date): Buffer {
|
|
110
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
111
|
+
const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}`
|
|
112
|
+
+ `${pad(date.getUTCDate())}${pad(date.getUTCHours())}`
|
|
113
|
+
+ `${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
|
|
114
|
+
return derTag(TAG_UTCTIME, Buffer.from(text, 'ascii'))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** BOOLEAN. */
|
|
118
|
+
function derBoolean(value: boolean): Buffer {
|
|
119
|
+
return derTag(0x01, Buffer.from([value ? 0xff : 0x00]))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** SHA-256 with RSA encryption (no parameters). */
|
|
123
|
+
function sha256WithRsa(): Buffer {
|
|
124
|
+
return derSeq(derOid('1.2.840.113549.1.1.11'))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** RSA encryption with NULL parameters (the SPKI algorithm id). */
|
|
128
|
+
function rsaEncryption(): Buffer {
|
|
129
|
+
return derSeq(derOid('1.2.840.113549.1.1.1'), derTag(TAG_NULL, Buffer.alloc(0)))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ------------------------------------------------------------------ */
|
|
133
|
+
/* IPv6 parsing for IP SANs */
|
|
134
|
+
/* ------------------------------------------------------------------ */
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse an IPv6 literal into its 16 raw bytes. Supports `::` compression,
|
|
138
|
+
* hex groups, and an embedded dotted-quad IPv4 tail.
|
|
139
|
+
*/
|
|
140
|
+
export function parseIpv6Bytes(text: string): Buffer | undefined {
|
|
141
|
+
let address = text.trim()
|
|
142
|
+
if (address.startsWith('[') && address.endsWith(']')) {
|
|
143
|
+
address = address.slice(1, -1)
|
|
144
|
+
}
|
|
145
|
+
if (address.includes('/')) address = address.split('/')[0]!
|
|
146
|
+
const embeddedIpv4 = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(address)
|
|
147
|
+
let head = address
|
|
148
|
+
let tail: number[] = []
|
|
149
|
+
if (embeddedIpv4 !== null) {
|
|
150
|
+
head = embeddedIpv4[1]!.replace(/:$/, '')
|
|
151
|
+
tail = embeddedIpv4[2]!.split('.').map(Number)
|
|
152
|
+
if (tail.some(b => !Number.isInteger(b) || b < 0 || b > 255)) return undefined
|
|
153
|
+
}
|
|
154
|
+
const doubleColon = head.indexOf('::')
|
|
155
|
+
if (doubleColon !== -1) {
|
|
156
|
+
if (head.indexOf('::', doubleColon + 1) !== -1) return undefined
|
|
157
|
+
const left = head.slice(0, doubleColon)
|
|
158
|
+
const right = head.slice(doubleColon + 2)
|
|
159
|
+
const leftWords = parseWords(left)
|
|
160
|
+
const rightWords = parseWords(right)
|
|
161
|
+
if (leftWords === undefined || rightWords === undefined) return undefined
|
|
162
|
+
if (leftWords.length + rightWords.length + tail.length / 2 > 8) return undefined
|
|
163
|
+
const gap = 8 - leftWords.length - rightWords.length - tail.length / 2
|
|
164
|
+
const words = [...leftWords, ...Array<number>(gap).fill(0), ...rightWords]
|
|
165
|
+
return wordsToBytes(words, tail)
|
|
166
|
+
}
|
|
167
|
+
const words = parseWords(head)
|
|
168
|
+
if (words === undefined) return undefined
|
|
169
|
+
if (words.length + tail.length / 2 !== 8) return undefined
|
|
170
|
+
return wordsToBytes(words, tail)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseWords(text: string): number[] | undefined {
|
|
174
|
+
if (text === '') return []
|
|
175
|
+
const parts = text.split(':')
|
|
176
|
+
if (parts.some(p => p === '')) return undefined
|
|
177
|
+
const words: number[] = []
|
|
178
|
+
for (const part of parts) {
|
|
179
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return undefined
|
|
180
|
+
words.push(Number.parseInt(part, 16))
|
|
181
|
+
}
|
|
182
|
+
return words
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function wordsToBytes(words: number[], ipv4Tail: number[]): Buffer {
|
|
186
|
+
const bytes: number[] = []
|
|
187
|
+
for (const word of words) {
|
|
188
|
+
bytes.push((word >> 8) & 0xff, word & 0xff)
|
|
189
|
+
}
|
|
190
|
+
bytes.push(...ipv4Tail)
|
|
191
|
+
return Buffer.from(bytes)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether `text` is an IPv4 literal. */
|
|
195
|
+
export function isIpv4Literal(text: string): boolean {
|
|
196
|
+
const parts = text.split('.')
|
|
197
|
+
return parts.length === 4 && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A SAN general name: [2] dNSName (IA5) or [7] iPAddress (raw bytes). */
|
|
201
|
+
function sanGeneralName(host: string): Buffer {
|
|
202
|
+
const trimmed = host.trim()
|
|
203
|
+
if (isIpv4Literal(trimmed)) {
|
|
204
|
+
return derTag(TAG_CONTEXT_7, Buffer.from(trimmed.split('.').map(Number)))
|
|
205
|
+
}
|
|
206
|
+
const ipv6 = parseIpv6Bytes(trimmed)
|
|
207
|
+
if (ipv6 !== undefined) return derTag(TAG_CONTEXT_7, ipv6)
|
|
208
|
+
return derTag(TAG_CONTEXT_2, Buffer.from(trimmed, 'ascii'))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
/* Certificate building */
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
/** PEM-encode a DER body. */
|
|
216
|
+
export function pemEncode(label: string, der: Buffer): string {
|
|
217
|
+
const base64 = der.toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
|
218
|
+
return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Options for {@link generateSelfSignedCert}. */
|
|
222
|
+
export interface SelfSignedCertOptions {
|
|
223
|
+
/** SAN host entries: DNS names and/or IP literals. */
|
|
224
|
+
hosts: readonly string[]
|
|
225
|
+
/** Validity in days. */
|
|
226
|
+
days: number
|
|
227
|
+
/** Subject CN; defaults to the first host. */
|
|
228
|
+
commonName?: string
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A freshly generated key pair plus the signed leaf certificate. */
|
|
232
|
+
export interface SelfSignedResult {
|
|
233
|
+
certDer: Buffer
|
|
234
|
+
certPem: string
|
|
235
|
+
keyPem: string
|
|
236
|
+
publicKey: KeyObject
|
|
237
|
+
privateKey: KeyObject
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate a self-signed X.509 v3 leaf certificate for `hosts`.
|
|
242
|
+
* @param options - hosts, validity, subject.
|
|
243
|
+
* @returns DER + PEM certificate, PEM private key, and the key objects.
|
|
244
|
+
*/
|
|
245
|
+
export function generateSelfSignedCert(options: SelfSignedCertOptions): SelfSignedResult {
|
|
246
|
+
const hosts = options.hosts.map(h => h.trim()).filter(h => h !== '')
|
|
247
|
+
if (hosts.length === 0) throw new Error('self-signed certificate needs at least one host')
|
|
248
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
249
|
+
modulusLength: 2048,
|
|
250
|
+
publicExponent: 0x10001,
|
|
251
|
+
})
|
|
252
|
+
const commonName = options.commonName?.trim() || hosts[0]!
|
|
253
|
+
|
|
254
|
+
// TBSCertificate ------------------------------------------------------
|
|
255
|
+
const serial = randomBytes(16)
|
|
256
|
+
serial[0]! &= 0x7f // positive integer
|
|
257
|
+
|
|
258
|
+
const issuer = derSeq(derSet(derSeq(derOid('2.5.4.3'), derUtf8String(commonName))))
|
|
259
|
+
const subject = issuer // self-signed: same name
|
|
260
|
+
|
|
261
|
+
const notBefore = new Date(Date.now() - 3600_000) // 1h skew allowance
|
|
262
|
+
const notAfter = new Date(notBefore.getTime() + options.days * 86_400_000)
|
|
263
|
+
const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter))
|
|
264
|
+
|
|
265
|
+
// subjectPublicKeyInfo = the full SPKI DER produced by node.
|
|
266
|
+
const spki = publicKey.export({ type: 'spki', format: 'der' })
|
|
267
|
+
|
|
268
|
+
// Extensions ----------------------------------------------------------
|
|
269
|
+
const basicConstraints = derSeq(
|
|
270
|
+
derOid('2.5.29.19'),
|
|
271
|
+
derBoolean(true), // critical
|
|
272
|
+
derOctetString(derSeq()), // CA:FALSE → empty SEQUENCE
|
|
273
|
+
)
|
|
274
|
+
const keyUsage = derSeq(
|
|
275
|
+
derOid('2.5.29.15'),
|
|
276
|
+
derBoolean(true), // critical
|
|
277
|
+
// KeyUsage is a BIT STRING with MSB-first bit numbering: bit 0 =
|
|
278
|
+
// digitalSignature, bit 2 = keyEncipherment → 1010 0000 = 0xa0.
|
|
279
|
+
derOctetString(derBitString(Buffer.from([0xa0]))),
|
|
280
|
+
)
|
|
281
|
+
const extendedKeyUsage = derSeq(
|
|
282
|
+
derOid('2.5.29.37'),
|
|
283
|
+
derOctetString(derSeq(derOid('1.3.6.1.5.5.7.3.1'))), // serverAuth
|
|
284
|
+
)
|
|
285
|
+
const subjectAltName = derSeq(
|
|
286
|
+
derOid('2.5.29.17'),
|
|
287
|
+
derOctetString(derSeq(...hosts.map(sanGeneralName))),
|
|
288
|
+
)
|
|
289
|
+
const extensions = derSeq(basicConstraints, keyUsage, extendedKeyUsage, subjectAltName)
|
|
290
|
+
const extensionsWrapper = derTag(0xa3, extensions) // [3] EXPLICIT Extensions
|
|
291
|
+
|
|
292
|
+
const tbs = derSeq(
|
|
293
|
+
derTag(0xa0, derInt(Buffer.from([2]))), // version [0] EXPLICIT v3
|
|
294
|
+
derInt(serial),
|
|
295
|
+
sha256WithRsa(),
|
|
296
|
+
issuer,
|
|
297
|
+
validity,
|
|
298
|
+
subject,
|
|
299
|
+
spki,
|
|
300
|
+
extensionsWrapper,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
// Signature -----------------------------------------------------------
|
|
304
|
+
const signature = createSign('sha256').update(tbs).end().sign(privateKey)
|
|
305
|
+
const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature))
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
certDer,
|
|
309
|
+
certPem: pemEncode('CERTIFICATE', certDer),
|
|
310
|
+
keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
311
|
+
publicKey,
|
|
312
|
+
privateKey,
|
|
313
|
+
}
|
|
314
|
+
}
|