@svrnsec/pulse 0.7.0 → 0.8.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 +21 -21
- package/README.md +883 -782
- package/SECURITY.md +86 -86
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6378 -6419
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6379 -6420
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +895 -846
- package/package.json +185 -184
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -393
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -804
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -391
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/fingerprint.js +475 -475
- package/src/index.js +342 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +82 -142
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
package/src/proof/challenge.js
CHANGED
|
@@ -1,249 +1,249 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svrnsec/pulse — HMAC-Signed Challenge Protocol
|
|
3
|
-
*
|
|
4
|
-
* Hardens the nonce system against three attack vectors that a plain random
|
|
5
|
-
* nonce does not prevent:
|
|
6
|
-
*
|
|
7
|
-
* 1. Forged challenges
|
|
8
|
-
* Without a server secret, an attacker can generate their own nonces,
|
|
9
|
-
* pre-compute a fake proof, and submit it. HMAC signing ties the nonce
|
|
10
|
-
* to the server's secret — a nonce not signed by the server is rejected
|
|
11
|
-
* before the proof is even validated.
|
|
12
|
-
*
|
|
13
|
-
* 2. Replayed challenges
|
|
14
|
-
* A valid nonce captured from a legitimate session can be replayed with
|
|
15
|
-
* a cached proof. The challenge includes an expiry timestamp in the HMAC
|
|
16
|
-
* input — expired challenges are rejected even if the signature is valid.
|
|
17
|
-
*
|
|
18
|
-
* 3. Timestamp manipulation
|
|
19
|
-
* An attacker who intercepts a challenge cannot extend its validity by
|
|
20
|
-
* altering the expiry field because the timestamp is part of the HMAC
|
|
21
|
-
* input. Any modification breaks the signature.
|
|
22
|
-
*
|
|
23
|
-
* Wire format
|
|
24
|
-
* ───────────
|
|
25
|
-
* {
|
|
26
|
-
* nonce: "64-char hex" — random, server-generated
|
|
27
|
-
* issuedAt: 1711234567890 — Unix ms
|
|
28
|
-
* expiresAt: 1711234867890 — issuedAt + ttlMs
|
|
29
|
-
* sig: "64-char hex" — HMAC-SHA256(body, secret)
|
|
30
|
-
* }
|
|
31
|
-
*
|
|
32
|
-
* body = `${nonce}|${issuedAt}|${expiresAt}`
|
|
33
|
-
*
|
|
34
|
-
* Usage (server)
|
|
35
|
-
* ──────────────
|
|
36
|
-
* import { createChallenge, verifyChallenge } from '@svrnsec/pulse/challenge';
|
|
37
|
-
*
|
|
38
|
-
* // Challenge endpoint
|
|
39
|
-
* app.get('/api/challenge', (req, res) => {
|
|
40
|
-
* const challenge = createChallenge(process.env.PULSE_SECRET);
|
|
41
|
-
* await redis.set(`pulse:${challenge.nonce}`, '1', 'EX', 300);
|
|
42
|
-
* res.json(challenge);
|
|
43
|
-
* });
|
|
44
|
-
*
|
|
45
|
-
* // Verify endpoint
|
|
46
|
-
* app.post('/api/verify', async (req, res) => {
|
|
47
|
-
* const { payload, hash } = req.body;
|
|
48
|
-
* const challenge = { nonce: payload.nonce, ...req.body.challenge };
|
|
49
|
-
*
|
|
50
|
-
* const { valid, reason } = verifyChallenge(challenge, process.env.PULSE_SECRET, {
|
|
51
|
-
* checkNonce: async (n) => {
|
|
52
|
-
* const ok = await redis.del(`pulse:${n}`);
|
|
53
|
-
* return ok === 1; // consume on first use
|
|
54
|
-
* },
|
|
55
|
-
* });
|
|
56
|
-
* if (!valid) return res.status(400).json({ error: reason });
|
|
57
|
-
*
|
|
58
|
-
* const result = await validateProof(payload, hash);
|
|
59
|
-
* res.json(result);
|
|
60
|
-
* });
|
|
61
|
-
*
|
|
62
|
-
* Zero dependencies: uses Node.js built-in crypto module only.
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Constants
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
72
|
-
const NONCE_BYTES = 32; // 256 bits → 64-char hex
|
|
73
|
-
const SIG_ALGORITHM = 'sha256';
|
|
74
|
-
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// createChallenge
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Issue a new signed challenge. Call this in your GET /challenge endpoint.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} secret - your server secret (min 32 chars recommended)
|
|
83
|
-
* @param {object} [opts]
|
|
84
|
-
* @param {number} [opts.ttlMs=300000] - challenge validity window (ms)
|
|
85
|
-
* @param {string} [opts.nonce] - override nonce (testing only)
|
|
86
|
-
* @returns {SignedChallenge}
|
|
87
|
-
*/
|
|
88
|
-
export function createChallenge(secret, opts = {}) {
|
|
89
|
-
_assertSecret(secret);
|
|
90
|
-
|
|
91
|
-
const { ttlMs = DEFAULT_TTL_MS } = opts;
|
|
92
|
-
const nonce = opts.nonce ?? randomBytes(NONCE_BYTES).toString('hex');
|
|
93
|
-
const issuedAt = Date.now();
|
|
94
|
-
const expiresAt = issuedAt + ttlMs;
|
|
95
|
-
const sig = _sign(nonce, issuedAt, expiresAt, secret);
|
|
96
|
-
|
|
97
|
-
return { nonce, issuedAt, expiresAt, sig };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// verifyChallenge
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Verify an inbound challenge before processing the proof.
|
|
106
|
-
* Call this at the start of your POST /verify endpoint.
|
|
107
|
-
*
|
|
108
|
-
* @param {SignedChallenge} challenge
|
|
109
|
-
* @param {string} secret
|
|
110
|
-
* @param {object} [opts]
|
|
111
|
-
* @param {Function} [opts.checkNonce] async (nonce: string) => boolean
|
|
112
|
-
* Return true if the nonce is valid and consume it.
|
|
113
|
-
* Must be atomic (redis DEL returning 1, DB transaction, etc.)
|
|
114
|
-
* @returns {Promise<{ valid: boolean, reason?: string }>}
|
|
115
|
-
*/
|
|
116
|
-
export async function verifyChallenge(challenge, secret, opts = {}) {
|
|
117
|
-
_assertSecret(secret);
|
|
118
|
-
|
|
119
|
-
const { nonce, issuedAt, expiresAt, sig } = challenge ?? {};
|
|
120
|
-
|
|
121
|
-
// ── Structural checks ──────────────────────────────────────────────────────
|
|
122
|
-
if (!nonce || typeof nonce !== 'string' || !/^[0-9a-f]{64}$/i.test(nonce)) {
|
|
123
|
-
return { valid: false, reason: 'invalid_nonce_format' };
|
|
124
|
-
}
|
|
125
|
-
if (!issuedAt || !expiresAt || !sig) {
|
|
126
|
-
return { valid: false, reason: 'missing_challenge_fields' };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── Timestamp freshness ────────────────────────────────────────────────────
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
if (now > expiresAt) {
|
|
132
|
-
return { valid: false, reason: 'challenge_expired' };
|
|
133
|
-
}
|
|
134
|
-
if (issuedAt > now + 30_000) {
|
|
135
|
-
// Clock skew tolerance: reject challenges issued >30s in the future
|
|
136
|
-
return { valid: false, reason: 'challenge_issued_in_future' };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── HMAC signature verification (timing-safe) ──────────────────────────────
|
|
140
|
-
const expected = _sign(nonce, issuedAt, expiresAt, secret);
|
|
141
|
-
try {
|
|
142
|
-
const a = Buffer.from(expected, 'hex');
|
|
143
|
-
const b = Buffer.from(sig, 'hex');
|
|
144
|
-
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
145
|
-
return { valid: false, reason: 'invalid_signature' };
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
return { valid: false, reason: 'invalid_signature' };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Nonce consumption (replay prevention) ──────────────────────────────────
|
|
152
|
-
if (typeof opts.checkNonce === 'function') {
|
|
153
|
-
let consumed;
|
|
154
|
-
try {
|
|
155
|
-
consumed = await opts.checkNonce(nonce);
|
|
156
|
-
} catch (err) {
|
|
157
|
-
return { valid: false, reason: 'nonce_check_error' };
|
|
158
|
-
}
|
|
159
|
-
if (!consumed) {
|
|
160
|
-
return { valid: false, reason: 'nonce_already_used_or_unknown' };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { valid: true };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
// embedChallenge / extractChallenge
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Embed a signed challenge inside a ProofPayload's nonce field.
|
|
173
|
-
* The proof's nonce is set to `challenge.nonce`; the full challenge object is
|
|
174
|
-
* included as `challenge.meta` for server-side re-verification.
|
|
175
|
-
*
|
|
176
|
-
* This lets a single API call carry both the nonce for BLAKE3 commitment AND
|
|
177
|
-
* the full signed challenge for server authentication.
|
|
178
|
-
*
|
|
179
|
-
* @param {SignedChallenge} challenge
|
|
180
|
-
* @param {object} payload - ProofPayload (mutates in place)
|
|
181
|
-
* @returns {object} the mutated payload
|
|
182
|
-
*/
|
|
183
|
-
export function embedChallenge(challenge, payload) {
|
|
184
|
-
if (payload.nonce !== challenge.nonce) {
|
|
185
|
-
throw new Error('@svrnsec/pulse: proof nonce does not match challenge nonce');
|
|
186
|
-
}
|
|
187
|
-
payload._challenge = {
|
|
188
|
-
issuedAt: challenge.issuedAt,
|
|
189
|
-
expiresAt: challenge.expiresAt,
|
|
190
|
-
sig: challenge.sig,
|
|
191
|
-
};
|
|
192
|
-
return payload;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Extract a SignedChallenge from a ProofPayload that had embedChallenge() applied.
|
|
197
|
-
* @param {object} payload
|
|
198
|
-
* @returns {SignedChallenge}
|
|
199
|
-
*/
|
|
200
|
-
export function extractChallenge(payload) {
|
|
201
|
-
const meta = payload?._challenge;
|
|
202
|
-
if (!meta) throw new Error('@svrnsec/pulse: no embedded challenge in payload');
|
|
203
|
-
return {
|
|
204
|
-
nonce: payload.nonce,
|
|
205
|
-
issuedAt: meta.issuedAt,
|
|
206
|
-
expiresAt: meta.expiresAt,
|
|
207
|
-
sig: meta.sig,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
// generateSecret
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Generate a cryptographically secure server secret.
|
|
217
|
-
* Run once and store in your environment variables.
|
|
218
|
-
*
|
|
219
|
-
* @returns {string} 64-char hex string (256 bits)
|
|
220
|
-
*/
|
|
221
|
-
export function generateSecret() {
|
|
222
|
-
return randomBytes(32).toString('hex');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// Internal helpers
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
function _sign(nonce, issuedAt, expiresAt, secret) {
|
|
230
|
-
const body = `${nonce}|${issuedAt}|${expiresAt}`;
|
|
231
|
-
return createHmac(SIG_ALGORITHM, secret).update(body).digest('hex');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function _assertSecret(secret) {
|
|
235
|
-
if (!secret || typeof secret !== 'string' || secret.length <
|
|
236
|
-
throw new Error(
|
|
237
|
-
'@svrnsec/pulse: secret must be a string of at least
|
|
238
|
-
'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* @typedef {object} SignedChallenge
|
|
245
|
-
* @property {string} nonce 64-char hex nonce
|
|
246
|
-
* @property {number} issuedAt Unix ms timestamp
|
|
247
|
-
* @property {number} expiresAt Unix ms expiry
|
|
248
|
-
* @property {string} sig HMAC-SHA256 hex signature
|
|
249
|
-
*/
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — HMAC-Signed Challenge Protocol
|
|
3
|
+
*
|
|
4
|
+
* Hardens the nonce system against three attack vectors that a plain random
|
|
5
|
+
* nonce does not prevent:
|
|
6
|
+
*
|
|
7
|
+
* 1. Forged challenges
|
|
8
|
+
* Without a server secret, an attacker can generate their own nonces,
|
|
9
|
+
* pre-compute a fake proof, and submit it. HMAC signing ties the nonce
|
|
10
|
+
* to the server's secret — a nonce not signed by the server is rejected
|
|
11
|
+
* before the proof is even validated.
|
|
12
|
+
*
|
|
13
|
+
* 2. Replayed challenges
|
|
14
|
+
* A valid nonce captured from a legitimate session can be replayed with
|
|
15
|
+
* a cached proof. The challenge includes an expiry timestamp in the HMAC
|
|
16
|
+
* input — expired challenges are rejected even if the signature is valid.
|
|
17
|
+
*
|
|
18
|
+
* 3. Timestamp manipulation
|
|
19
|
+
* An attacker who intercepts a challenge cannot extend its validity by
|
|
20
|
+
* altering the expiry field because the timestamp is part of the HMAC
|
|
21
|
+
* input. Any modification breaks the signature.
|
|
22
|
+
*
|
|
23
|
+
* Wire format
|
|
24
|
+
* ───────────
|
|
25
|
+
* {
|
|
26
|
+
* nonce: "64-char hex" — random, server-generated
|
|
27
|
+
* issuedAt: 1711234567890 — Unix ms
|
|
28
|
+
* expiresAt: 1711234867890 — issuedAt + ttlMs
|
|
29
|
+
* sig: "64-char hex" — HMAC-SHA256(body, secret)
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* body = `${nonce}|${issuedAt}|${expiresAt}`
|
|
33
|
+
*
|
|
34
|
+
* Usage (server)
|
|
35
|
+
* ──────────────
|
|
36
|
+
* import { createChallenge, verifyChallenge } from '@svrnsec/pulse/challenge';
|
|
37
|
+
*
|
|
38
|
+
* // Challenge endpoint
|
|
39
|
+
* app.get('/api/challenge', (req, res) => {
|
|
40
|
+
* const challenge = createChallenge(process.env.PULSE_SECRET);
|
|
41
|
+
* await redis.set(`pulse:${challenge.nonce}`, '1', 'EX', 300);
|
|
42
|
+
* res.json(challenge);
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Verify endpoint
|
|
46
|
+
* app.post('/api/verify', async (req, res) => {
|
|
47
|
+
* const { payload, hash } = req.body;
|
|
48
|
+
* const challenge = { nonce: payload.nonce, ...req.body.challenge };
|
|
49
|
+
*
|
|
50
|
+
* const { valid, reason } = verifyChallenge(challenge, process.env.PULSE_SECRET, {
|
|
51
|
+
* checkNonce: async (n) => {
|
|
52
|
+
* const ok = await redis.del(`pulse:${n}`);
|
|
53
|
+
* return ok === 1; // consume on first use
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* if (!valid) return res.status(400).json({ error: reason });
|
|
57
|
+
*
|
|
58
|
+
* const result = await validateProof(payload, hash);
|
|
59
|
+
* res.json(result);
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* Zero dependencies: uses Node.js built-in crypto module only.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Constants
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
72
|
+
const NONCE_BYTES = 32; // 256 bits → 64-char hex
|
|
73
|
+
const SIG_ALGORITHM = 'sha256';
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// createChallenge
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Issue a new signed challenge. Call this in your GET /challenge endpoint.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} secret - your server secret (min 32 chars recommended)
|
|
83
|
+
* @param {object} [opts]
|
|
84
|
+
* @param {number} [opts.ttlMs=300000] - challenge validity window (ms)
|
|
85
|
+
* @param {string} [opts.nonce] - override nonce (testing only)
|
|
86
|
+
* @returns {SignedChallenge}
|
|
87
|
+
*/
|
|
88
|
+
export function createChallenge(secret, opts = {}) {
|
|
89
|
+
_assertSecret(secret);
|
|
90
|
+
|
|
91
|
+
const { ttlMs = DEFAULT_TTL_MS } = opts;
|
|
92
|
+
const nonce = opts.nonce ?? randomBytes(NONCE_BYTES).toString('hex');
|
|
93
|
+
const issuedAt = Date.now();
|
|
94
|
+
const expiresAt = issuedAt + ttlMs;
|
|
95
|
+
const sig = _sign(nonce, issuedAt, expiresAt, secret);
|
|
96
|
+
|
|
97
|
+
return { nonce, issuedAt, expiresAt, sig };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// verifyChallenge
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify an inbound challenge before processing the proof.
|
|
106
|
+
* Call this at the start of your POST /verify endpoint.
|
|
107
|
+
*
|
|
108
|
+
* @param {SignedChallenge} challenge
|
|
109
|
+
* @param {string} secret
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @param {Function} [opts.checkNonce] async (nonce: string) => boolean
|
|
112
|
+
* Return true if the nonce is valid and consume it.
|
|
113
|
+
* Must be atomic (redis DEL returning 1, DB transaction, etc.)
|
|
114
|
+
* @returns {Promise<{ valid: boolean, reason?: string }>}
|
|
115
|
+
*/
|
|
116
|
+
export async function verifyChallenge(challenge, secret, opts = {}) {
|
|
117
|
+
_assertSecret(secret);
|
|
118
|
+
|
|
119
|
+
const { nonce, issuedAt, expiresAt, sig } = challenge ?? {};
|
|
120
|
+
|
|
121
|
+
// ── Structural checks ──────────────────────────────────────────────────────
|
|
122
|
+
if (!nonce || typeof nonce !== 'string' || !/^[0-9a-f]{64}$/i.test(nonce)) {
|
|
123
|
+
return { valid: false, reason: 'invalid_nonce_format' };
|
|
124
|
+
}
|
|
125
|
+
if (!issuedAt || !expiresAt || !sig) {
|
|
126
|
+
return { valid: false, reason: 'missing_challenge_fields' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Timestamp freshness ────────────────────────────────────────────────────
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (now > expiresAt) {
|
|
132
|
+
return { valid: false, reason: 'challenge_expired' };
|
|
133
|
+
}
|
|
134
|
+
if (issuedAt > now + 30_000) {
|
|
135
|
+
// Clock skew tolerance: reject challenges issued >30s in the future
|
|
136
|
+
return { valid: false, reason: 'challenge_issued_in_future' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── HMAC signature verification (timing-safe) ──────────────────────────────
|
|
140
|
+
const expected = _sign(nonce, issuedAt, expiresAt, secret);
|
|
141
|
+
try {
|
|
142
|
+
const a = Buffer.from(expected, 'hex');
|
|
143
|
+
const b = Buffer.from(sig, 'hex');
|
|
144
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
145
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Nonce consumption (replay prevention) ──────────────────────────────────
|
|
152
|
+
if (typeof opts.checkNonce === 'function') {
|
|
153
|
+
let consumed;
|
|
154
|
+
try {
|
|
155
|
+
consumed = await opts.checkNonce(nonce);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { valid: false, reason: 'nonce_check_error' };
|
|
158
|
+
}
|
|
159
|
+
if (!consumed) {
|
|
160
|
+
return { valid: false, reason: 'nonce_already_used_or_unknown' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { valid: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// embedChallenge / extractChallenge
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Embed a signed challenge inside a ProofPayload's nonce field.
|
|
173
|
+
* The proof's nonce is set to `challenge.nonce`; the full challenge object is
|
|
174
|
+
* included as `challenge.meta` for server-side re-verification.
|
|
175
|
+
*
|
|
176
|
+
* This lets a single API call carry both the nonce for BLAKE3 commitment AND
|
|
177
|
+
* the full signed challenge for server authentication.
|
|
178
|
+
*
|
|
179
|
+
* @param {SignedChallenge} challenge
|
|
180
|
+
* @param {object} payload - ProofPayload (mutates in place)
|
|
181
|
+
* @returns {object} the mutated payload
|
|
182
|
+
*/
|
|
183
|
+
export function embedChallenge(challenge, payload) {
|
|
184
|
+
if (payload.nonce !== challenge.nonce) {
|
|
185
|
+
throw new Error('@svrnsec/pulse: proof nonce does not match challenge nonce');
|
|
186
|
+
}
|
|
187
|
+
payload._challenge = {
|
|
188
|
+
issuedAt: challenge.issuedAt,
|
|
189
|
+
expiresAt: challenge.expiresAt,
|
|
190
|
+
sig: challenge.sig,
|
|
191
|
+
};
|
|
192
|
+
return payload;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extract a SignedChallenge from a ProofPayload that had embedChallenge() applied.
|
|
197
|
+
* @param {object} payload
|
|
198
|
+
* @returns {SignedChallenge}
|
|
199
|
+
*/
|
|
200
|
+
export function extractChallenge(payload) {
|
|
201
|
+
const meta = payload?._challenge;
|
|
202
|
+
if (!meta) throw new Error('@svrnsec/pulse: no embedded challenge in payload');
|
|
203
|
+
return {
|
|
204
|
+
nonce: payload.nonce,
|
|
205
|
+
issuedAt: meta.issuedAt,
|
|
206
|
+
expiresAt: meta.expiresAt,
|
|
207
|
+
sig: meta.sig,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// generateSecret
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generate a cryptographically secure server secret.
|
|
217
|
+
* Run once and store in your environment variables.
|
|
218
|
+
*
|
|
219
|
+
* @returns {string} 64-char hex string (256 bits)
|
|
220
|
+
*/
|
|
221
|
+
export function generateSecret() {
|
|
222
|
+
return randomBytes(32).toString('hex');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Internal helpers
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function _sign(nonce, issuedAt, expiresAt, secret) {
|
|
230
|
+
const body = `${nonce}|${issuedAt}|${expiresAt}`;
|
|
231
|
+
return createHmac(SIG_ALGORITHM, secret).update(body).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _assertSecret(secret) {
|
|
235
|
+
if (!secret || typeof secret !== 'string' || secret.length < 32) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'@svrnsec/pulse: secret must be a string of at least 32 characters (256 bits). ' +
|
|
238
|
+
'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @typedef {object} SignedChallenge
|
|
245
|
+
* @property {string} nonce 64-char hex nonce
|
|
246
|
+
* @property {number} issuedAt Unix ms timestamp
|
|
247
|
+
* @property {number} expiresAt Unix ms expiry
|
|
248
|
+
* @property {string} sig HMAC-SHA256 hex signature
|
|
249
|
+
*/
|