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/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
+ }