kczx-user-management 1.0.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/CUTOVER-RUNBOOK.md +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
package/src/totp.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TOTP (RFC 6238) — time-based one-time passwords behind the login password.
|
|
5
|
+
*
|
|
6
|
+
* Zero-dependency by house rule: HMAC-SHA1 via node:crypto, base32 (RFC 4648)
|
|
7
|
+
* hand-rolled. The wire format matches every mainstream authenticator app
|
|
8
|
+
* (Google Authenticator, 1Password, Microsoft Authenticator, Aegis, …):
|
|
9
|
+
* 6 digits over a 30-second step, SHA-1, ±1 step of clock-drift tolerance.
|
|
10
|
+
*
|
|
11
|
+
* Verification is constant-time per candidate step and returns the matched
|
|
12
|
+
* step counter so callers can enforce single use (replay guard) by storing it
|
|
13
|
+
* on the user record and rejecting counters ≤ the last used one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
19
|
+
const OTP_STEP_SECONDS = 30
|
|
20
|
+
const OTP_DIGITS = 6
|
|
21
|
+
/** Accept the previous/next step so a slightly drifting clock still works. */
|
|
22
|
+
const OTP_WINDOW = 1
|
|
23
|
+
/** 160-bit secrets — the RFC 6238 recommendation and every app's minimum. */
|
|
24
|
+
const SECRET_BYTES = 20
|
|
25
|
+
|
|
26
|
+
function base32Encode(buf) {
|
|
27
|
+
let bits = 0
|
|
28
|
+
let value = 0
|
|
29
|
+
let out = ''
|
|
30
|
+
for (const byte of buf) {
|
|
31
|
+
value = (value << 8) | byte
|
|
32
|
+
bits += 8
|
|
33
|
+
while (bits >= 5) {
|
|
34
|
+
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]
|
|
35
|
+
bits -= 5
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Tolerant decode: case-insensitive, ignores `=` padding and whitespace. */
|
|
43
|
+
function base32Decode(str) {
|
|
44
|
+
const clean = String(str || '').toUpperCase().replace(/[=\s]/g, '')
|
|
45
|
+
const out = []
|
|
46
|
+
let bits = 0
|
|
47
|
+
let value = 0
|
|
48
|
+
for (const ch of clean) {
|
|
49
|
+
const idx = BASE32_ALPHABET.indexOf(ch)
|
|
50
|
+
if (idx === -1) return Buffer.alloc(0)
|
|
51
|
+
value = (value << 5) | idx
|
|
52
|
+
bits += 5
|
|
53
|
+
if (bits >= 8) {
|
|
54
|
+
out.push((value >>> (bits - 8)) & 0xff)
|
|
55
|
+
bits -= 8
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Buffer.from(out)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fresh enrollment secret, base32 (authenticator manual-entry format). */
|
|
62
|
+
function generateSecret() {
|
|
63
|
+
return base32Encode(randomBytes(SECRET_BYTES))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 4226 HOTP — truncated HMAC over an 8-byte big-endian counter. */
|
|
67
|
+
function hotp(secretBuf, counter, digits = OTP_DIGITS) {
|
|
68
|
+
const msg = Buffer.alloc(8)
|
|
69
|
+
msg.writeBigUInt64BE(BigInt(counter))
|
|
70
|
+
const digest = createHmac('sha1', secretBuf).update(msg).digest()
|
|
71
|
+
const offset = digest[digest.length - 1] & 0xf
|
|
72
|
+
const binary =
|
|
73
|
+
((digest[offset] & 0x7f) << 24) |
|
|
74
|
+
(digest[offset + 1] << 16) |
|
|
75
|
+
(digest[offset + 2] << 8) |
|
|
76
|
+
digest[offset + 3]
|
|
77
|
+
return String(binary % 10 ** digits).padStart(digits, '0')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** RFC 6238 TOTP — HOTP with the time step as counter. */
|
|
81
|
+
function totpAt(secretBuf, unixSeconds, stepSeconds = OTP_STEP_SECONDS) {
|
|
82
|
+
return hotp(secretBuf, Math.floor(unixSeconds / stepSeconds))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify a user-supplied code. Tolerates surrounding whitespace, requires
|
|
87
|
+
* exactly `OTP_DIGITS` digits. Scans the ±OTP_WINDOW steps around `nowMs`,
|
|
88
|
+
* skipping any step ≤ `lastCounter` (replay / single-use guard). The
|
|
89
|
+
* constant-time comparison runs per candidate step; digit-length mismatch
|
|
90
|
+
* short-circuits before any HMAC work.
|
|
91
|
+
*
|
|
92
|
+
* @returns {{ ok: true, counter: number } | { ok: false }}
|
|
93
|
+
*/
|
|
94
|
+
function verifyTotp(secretBase32, code, { nowMs = Date.now(), lastCounter = 0 } = {}) {
|
|
95
|
+
const secret = base32Decode(secretBase32)
|
|
96
|
+
if (secret.length === 0) return { ok: false }
|
|
97
|
+
const normalized = String(code || '').replace(/\s+/g, '')
|
|
98
|
+
if (!new RegExp(`^\\d{${OTP_DIGITS}}$`).test(normalized)) return { ok: false }
|
|
99
|
+
const stepNow = Math.floor(nowMs / 1000 / OTP_STEP_SECONDS)
|
|
100
|
+
const given = Buffer.from(normalized, 'utf8')
|
|
101
|
+
for (let counter = stepNow + OTP_WINDOW; counter >= stepNow - OTP_WINDOW; counter -= 1) {
|
|
102
|
+
if (counter <= lastCounter) continue
|
|
103
|
+
const expected = Buffer.from(hotp(secret, counter), 'utf8')
|
|
104
|
+
if (given.length === expected.length && timingSafeEqual(given, expected)) {
|
|
105
|
+
return { ok: true, counter }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** otpauth:// enrollment URI — the payload both QR codes and manual entry carry. */
|
|
112
|
+
function otpauthUri({ username, secret, issuer = 'DSH' }) {
|
|
113
|
+
const label = encodeURIComponent(`${issuer}:${username}`)
|
|
114
|
+
const params = new URLSearchParams({
|
|
115
|
+
secret,
|
|
116
|
+
issuer,
|
|
117
|
+
algorithm: 'SHA1',
|
|
118
|
+
digits: String(OTP_DIGITS),
|
|
119
|
+
period: String(OTP_STEP_SECONDS),
|
|
120
|
+
})
|
|
121
|
+
return `otpauth://totp/${label}?${params.toString()}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
OTP_STEP_SECONDS,
|
|
126
|
+
OTP_DIGITS,
|
|
127
|
+
OTP_WINDOW,
|
|
128
|
+
SECRET_BYTES,
|
|
129
|
+
base32Encode,
|
|
130
|
+
base32Decode,
|
|
131
|
+
generateSecret,
|
|
132
|
+
hotp,
|
|
133
|
+
totpAt,
|
|
134
|
+
verifyTotp,
|
|
135
|
+
otpauthUri,
|
|
136
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trusted upstream identity.
|
|
5
|
+
*
|
|
6
|
+
* In the merged deployment dsh-passwords is the ONLY login door: it
|
|
7
|
+
* authenticates the user, then signs that identity onto every request it
|
|
8
|
+
* proxies here. This layer therefore stops demanding a login of its own and
|
|
9
|
+
* instead trusts a signed header pair — while still observing every request,
|
|
10
|
+
* which is the whole reason user-management stays in the path: its gate feeds
|
|
11
|
+
* the access and operation ledgers.
|
|
12
|
+
*
|
|
13
|
+
* Trust conditions (all must hold):
|
|
14
|
+
* - the TCP peer is loopback (the gateway listener binds 127.0.0.1, so only
|
|
15
|
+
* the co-located dsh-passwords gateway can reach it at all);
|
|
16
|
+
* - the timestamp is inside a narrow clock-skew window (replay guard);
|
|
17
|
+
* - the HMAC over username/role/ip/timestamp matches, compared in constant
|
|
18
|
+
* time.
|
|
19
|
+
*
|
|
20
|
+
* Cluster-free and cordis-free: pure functions over (req, secret) so the
|
|
21
|
+
* decision is testable without a listener — see test/trusted-identity.test.mjs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
/** Header names the signing gateway sets. Exported for tests and docs. */
|
|
27
|
+
const TRUSTED_HEADERS = {
|
|
28
|
+
user: 'x-dsh-identity',
|
|
29
|
+
role: 'x-dsh-identity-role',
|
|
30
|
+
ip: 'x-dsh-identity-ip',
|
|
31
|
+
ts: 'x-dsh-identity-ts',
|
|
32
|
+
sig: 'x-dsh-identity-sig',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Canonical loopback peers (IPv4, IPv6, and the IPv4-mapped IPv6 form). */
|
|
36
|
+
const LOOPBACK_PEERS = ['127.0.0.1', '::1', '::ffff:127.0.0.1']
|
|
37
|
+
|
|
38
|
+
/** Reject signatures older/newer than this — a captured header must expire. */
|
|
39
|
+
const MAX_SKEW_SECONDS = 60
|
|
40
|
+
|
|
41
|
+
/** Strip an IPv4-mapped prefix so ::ffff:127.0.0.1 and 127.0.0.1 compare equal. */
|
|
42
|
+
function normalizePeer(address) {
|
|
43
|
+
const value = String(address || '').trim().toLowerCase()
|
|
44
|
+
if (value.startsWith('::ffff:')) return value.slice('::ffff:'.length)
|
|
45
|
+
if (value.startsWith('[')) {
|
|
46
|
+
const end = value.indexOf(']')
|
|
47
|
+
return end === -1 ? value : value.slice(1, end)
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The signature both sides must agree on. One canonical string keeps the
|
|
54
|
+
* signing side (dsh-passwords) and this verifier in lockstep; the ip field is
|
|
55
|
+
* covered so a tampered ledger IP is a verification failure, not a silent
|
|
56
|
+
* rewrite of the audit trail.
|
|
57
|
+
*
|
|
58
|
+
* @param secret - shared HMAC key (dsh-passwords MCP_INTERNAL_SECRET).
|
|
59
|
+
* @param username - the authenticated user's name.
|
|
60
|
+
* @param role - 'admin' | 'user'.
|
|
61
|
+
* @param ip - client IP as seen by the signing gateway.
|
|
62
|
+
* @param ts - unix seconds, as a string.
|
|
63
|
+
* @returns hex HMAC-SHA256.
|
|
64
|
+
*/
|
|
65
|
+
function identitySignature(secret, username, role, ip, ts) {
|
|
66
|
+
return createHmac('sha256', String(secret))
|
|
67
|
+
.update(`${username}\n${role || ''}\n${ip || ''}\n${ts}`)
|
|
68
|
+
.digest('hex')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Constant-time hex compare; length mismatch short-circuits. */
|
|
72
|
+
function signatureMatches(expected, given) {
|
|
73
|
+
const a = Buffer.from(String(expected), 'utf8')
|
|
74
|
+
const b = Buffer.from(String(given), 'utf8')
|
|
75
|
+
if (a.length !== b.length) return false
|
|
76
|
+
try { return timingSafeEqual(a, b) } catch { return false }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the verifier. Returns null when no secret is configured — an unsigned
|
|
81
|
+
* deployment (user-management standing alone) keeps its own login and this
|
|
82
|
+
* module stays completely inert.
|
|
83
|
+
*
|
|
84
|
+
* @param options.secret - shared HMAC key; empty disables trust entirely.
|
|
85
|
+
* @param options.peers - accepted peer addresses; defaults to loopback only.
|
|
86
|
+
* @param options.maxSkewSeconds - timestamp window; defaults to 60.
|
|
87
|
+
* @param options.now - clock injection for tests (ms).
|
|
88
|
+
* @returns {(req: object) => ({ user: object } | null)}
|
|
89
|
+
*/
|
|
90
|
+
function createTrustedIdentity(options = {}) {
|
|
91
|
+
const secret = String(options.secret || '')
|
|
92
|
+
const peers = new Set(
|
|
93
|
+
(Array.isArray(options.peers) && options.peers.length ? options.peers : LOOPBACK_PEERS)
|
|
94
|
+
.map((entry) => normalizePeer(entry)),
|
|
95
|
+
)
|
|
96
|
+
const maxSkew = Number.isFinite(options.maxSkewSeconds) ? options.maxSkewSeconds : MAX_SKEW_SECONDS
|
|
97
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
98
|
+
|
|
99
|
+
return function resolveTrusted(req) {
|
|
100
|
+
if (secret === '') return null
|
|
101
|
+
const peer = normalizePeer(req && req.socket && req.socket.remoteAddress)
|
|
102
|
+
if (!peers.has(peer)) return null
|
|
103
|
+
|
|
104
|
+
const headers = (req && req.headers) || {}
|
|
105
|
+
const username = headers[TRUSTED_HEADERS.user]
|
|
106
|
+
const role = headers[TRUSTED_HEADERS.role]
|
|
107
|
+
const ip = headers[TRUSTED_HEADERS.ip]
|
|
108
|
+
const ts = headers[TRUSTED_HEADERS.ts]
|
|
109
|
+
const sig = headers[TRUSTED_HEADERS.sig]
|
|
110
|
+
if (typeof username !== 'string' || username === '') return null
|
|
111
|
+
if (typeof ts !== 'string' || ts === '' || typeof sig !== 'string' || sig === '') return null
|
|
112
|
+
|
|
113
|
+
const seconds = Number(ts)
|
|
114
|
+
if (!Number.isFinite(seconds)) return null
|
|
115
|
+
const skew = Math.abs(now() / 1000 - seconds)
|
|
116
|
+
if (skew > maxSkew) return null
|
|
117
|
+
|
|
118
|
+
const expected = identitySignature(secret, username, typeof role === 'string' ? role : '', typeof ip === 'string' ? ip : '', ts)
|
|
119
|
+
if (!signatureMatches(expected, sig)) return null
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
// Shape matches a store session so every downstream reader (gateway
|
|
123
|
+
// routing, handleApi, the ledger hooks, the client's /session call)
|
|
124
|
+
// works unchanged. The id is namespaced: it is NOT a store user id.
|
|
125
|
+
user: {
|
|
126
|
+
id: `upstream:${username}`,
|
|
127
|
+
username,
|
|
128
|
+
role: role === 'admin' ? 'admin' : 'user',
|
|
129
|
+
},
|
|
130
|
+
upstream: true,
|
|
131
|
+
clientIp: typeof ip === 'string' ? ip : '',
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export {
|
|
137
|
+
TRUSTED_HEADERS,
|
|
138
|
+
LOOPBACK_PEERS,
|
|
139
|
+
MAX_SKEW_SECONDS,
|
|
140
|
+
normalizePeer,
|
|
141
|
+
identitySignature,
|
|
142
|
+
createTrustedIdentity,
|
|
143
|
+
}
|