@svrnsec/pulse 0.7.0 → 0.9.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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -782
  3. package/SECURITY.md +27 -22
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6428 -6413
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6429 -6415
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +949 -846
  10. package/package.json +189 -184
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -393
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -804
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -391
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/errors.js +54 -0
  36. package/src/fingerprint.js +475 -475
  37. package/src/index.js +345 -342
  38. package/src/integrations/react-native.js +462 -459
  39. package/src/integrations/react.js +184 -185
  40. package/src/middleware/express.js +155 -155
  41. package/src/middleware/next.js +174 -175
  42. package/src/proof/challenge.js +249 -249
  43. package/src/proof/engagementToken.js +426 -394
  44. package/src/proof/fingerprint.js +268 -268
  45. package/src/proof/validator.js +82 -142
  46. package/src/registry/serializer.js +349 -349
  47. package/src/terminal.js +263 -263
  48. package/src/update-notifier.js +259 -264
  49. package/dist/pulse.cjs.js.map +0 -1
@@ -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 < 16) {
236
- throw new Error(
237
- '@svrnsec/pulse: secret must be a string of at least 16 characters. ' +
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
+ */