@svrnsec/pulse 0.1.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 -0
- package/README.md +554 -0
- package/SECURITY.md +86 -0
- package/dist/pulse.cjs.js +4906 -0
- package/dist/pulse.cjs.js.map +1 -0
- package/dist/pulse.esm.js +4898 -0
- package/dist/pulse.esm.js.map +1 -0
- package/index.d.ts +588 -0
- package/package.json +93 -0
- package/pkg/pulse_core.js +173 -0
- package/src/integrations/react.js +185 -0
- package/src/middleware/express.js +155 -0
- package/src/middleware/next.js +175 -0
- package/src/proof/fingerprint.js +212 -0
- package/src/proof/validator.js +461 -0
- package/src/registry/serializer.js +349 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pulse_core — pure-JavaScript probe engine
|
|
3
|
+
*
|
|
4
|
+
* This module ships the entropy probe as portable JS so the package works
|
|
5
|
+
* out-of-the-box without a Rust toolchain. When a compiled .wasm binary is
|
|
6
|
+
* present (dropped in via `build.sh`) this file is replaced by the wasm-pack
|
|
7
|
+
* output and the native engine runs instead.
|
|
8
|
+
*
|
|
9
|
+
* Physics model
|
|
10
|
+
* ─────────────
|
|
11
|
+
* Real silicon: DRAM refresh cycles, branch-predictor misses, and L3-cache
|
|
12
|
+
* evictions inject sub-microsecond noise into any tight compute loop.
|
|
13
|
+
* Hypervisors virtualise the TSC and smooth those interrupts out, leaving
|
|
14
|
+
* a near-flat timing distribution that our QE/EJR checks catch.
|
|
15
|
+
*
|
|
16
|
+
* The JS loop below is a faithful port of the Rust matrix-multiply probe:
|
|
17
|
+
* same work unit (N×N DGEMM-style loop), same checksum accumulation to
|
|
18
|
+
* prevent dead-code elimination, same resolution micro-probe.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/* ─── clock ─────────────────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
const _now = (typeof performance !== 'undefined' && typeof performance.now === 'function')
|
|
24
|
+
? () => performance.now()
|
|
25
|
+
: (() => {
|
|
26
|
+
// Node.js fallback: process.hrtime.bigint() → milliseconds
|
|
27
|
+
const _hr = process.hrtime.bigint;
|
|
28
|
+
return () => Number(_hr()) / 1_000_000;
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
/* ─── init (no-op for the JS engine) ───────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialise the engine. When a real .wasm binary is supplied the wasm-pack
|
|
35
|
+
* glue calls WebAssembly.instantiateStreaming here. The JS engine is already
|
|
36
|
+
* "compiled", so we return immediately.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|URL|Request|BufferSource|WebAssembly.Module} [_source]
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
export default async function init(_source) {
|
|
42
|
+
// JS engine is ready synchronously — nothing to stream or compile.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ─── run_entropy_probe ─────────────────────────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run N iterations of a matrix-multiply work unit and record wall-clock time
|
|
49
|
+
* per iteration. The distribution of those times is what the heuristic
|
|
50
|
+
* engine analyses.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} iterations – number of timing samples to collect
|
|
53
|
+
* @param {number} matrixSize – N for the N×N multiply (default 64)
|
|
54
|
+
* @returns {{ timings: Float64Array, checksum: number, resolution_probe: Float64Array }}
|
|
55
|
+
*/
|
|
56
|
+
export function run_entropy_probe(iterations, matrixSize = 64) {
|
|
57
|
+
const N = matrixSize | 0;
|
|
58
|
+
|
|
59
|
+
// Persistent working matrices — allocated once per probe to avoid GC noise.
|
|
60
|
+
const A = new Float64Array(N * N);
|
|
61
|
+
const B = new Float64Array(N * N);
|
|
62
|
+
const C = new Float64Array(N * N);
|
|
63
|
+
|
|
64
|
+
// Seed matrices with pseudo-random data (deterministic per call for
|
|
65
|
+
// reproducibility, but different each run due to xorshift seeding from time).
|
|
66
|
+
let seed = (_now() * 1e6) | 0 || 0xdeadbeef;
|
|
67
|
+
const xr = () => { seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; return (seed >>> 0) / 4294967296; };
|
|
68
|
+
for (let i = 0; i < N * N; i++) { A[i] = xr(); B[i] = xr(); }
|
|
69
|
+
|
|
70
|
+
const timings = new Float64Array(iterations);
|
|
71
|
+
const resolution_probe = new Float64Array(32);
|
|
72
|
+
let checksum = 0;
|
|
73
|
+
|
|
74
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
75
|
+
// Zero accumulator each round (realistic cache pressure).
|
|
76
|
+
C.fill(0);
|
|
77
|
+
|
|
78
|
+
const t0 = _now();
|
|
79
|
+
|
|
80
|
+
// N×N matrix multiply: C = A · B (ikj loop order for cache friendliness)
|
|
81
|
+
for (let i = 0; i < N; i++) {
|
|
82
|
+
const rowA = i * N;
|
|
83
|
+
const rowC = i * N;
|
|
84
|
+
for (let k = 0; k < N; k++) {
|
|
85
|
+
const aik = A[rowA + k];
|
|
86
|
+
const rowBk = k * N;
|
|
87
|
+
for (let j = 0; j < N; j++) {
|
|
88
|
+
C[rowC + j] += aik * B[rowBk + j];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const t1 = _now();
|
|
94
|
+
timings[iter] = t1 - t0;
|
|
95
|
+
|
|
96
|
+
// Accumulate one element so the compiler cannot eliminate the work.
|
|
97
|
+
checksum += C[0];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolution micro-probe: fire 32 back-to-back timestamps.
|
|
101
|
+
// The minimum non-zero delta reveals timer granularity.
|
|
102
|
+
for (let i = 0; i < resolution_probe.length; i++) {
|
|
103
|
+
resolution_probe[i] = _now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { timings, checksum, resolution_probe };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ─── run_memory_probe ──────────────────────────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sequential read/write bandwidth probe over a large buffer.
|
|
113
|
+
* Memory latency variance is a secondary signal (NUMA, DRAM refresh).
|
|
114
|
+
*
|
|
115
|
+
* @param {number} memSizeKb – buffer size in kibibytes
|
|
116
|
+
* @param {number} memIterations
|
|
117
|
+
* @returns {{ timings: Float64Array, checksum: number }}
|
|
118
|
+
*/
|
|
119
|
+
export function run_memory_probe(memSizeKb = 512, memIterations = 50) {
|
|
120
|
+
const len = (memSizeKb * 1024 / 8) | 0; // 64-bit elements
|
|
121
|
+
const buf = new Float64Array(len);
|
|
122
|
+
const timings = new Float64Array(memIterations);
|
|
123
|
+
let checksum = 0;
|
|
124
|
+
|
|
125
|
+
// Warm-up pass (fills TLB, avoids first-access bias)
|
|
126
|
+
for (let i = 0; i < len; i++) buf[i] = i;
|
|
127
|
+
|
|
128
|
+
for (let iter = 0; iter < memIterations; iter++) {
|
|
129
|
+
const t0 = _now();
|
|
130
|
+
// Sequential read-modify-write
|
|
131
|
+
for (let i = 0; i < len; i++) buf[i] = buf[i] * 1.0000001;
|
|
132
|
+
const t1 = _now();
|
|
133
|
+
|
|
134
|
+
timings[iter] = t1 - t0;
|
|
135
|
+
checksum += buf[0];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { timings, checksum };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ─── compute_autocorrelation ───────────────────────────────────────────── */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Pearson autocorrelation for lags 1..maxLag.
|
|
145
|
+
* O(n·maxLag) — kept cheap by the adaptive early-exit cap.
|
|
146
|
+
*
|
|
147
|
+
* @param {ArrayLike<number>} data
|
|
148
|
+
* @param {number} maxLag
|
|
149
|
+
* @returns {Float64Array} length = maxLag, index 0 = lag-1
|
|
150
|
+
*/
|
|
151
|
+
export function compute_autocorrelation(data, maxLag) {
|
|
152
|
+
const n = data.length;
|
|
153
|
+
let mean = 0;
|
|
154
|
+
for (let i = 0; i < n; i++) mean += data[i];
|
|
155
|
+
mean /= n;
|
|
156
|
+
|
|
157
|
+
let variance = 0;
|
|
158
|
+
for (let i = 0; i < n; i++) variance += (data[i] - mean) ** 2;
|
|
159
|
+
variance /= n;
|
|
160
|
+
|
|
161
|
+
const result = new Float64Array(maxLag);
|
|
162
|
+
if (variance < 1e-14) return result; // degenerate — all identical
|
|
163
|
+
|
|
164
|
+
for (let lag = 1; lag <= maxLag; lag++) {
|
|
165
|
+
let cov = 0;
|
|
166
|
+
for (let i = 0; i < n - lag; i++) {
|
|
167
|
+
cov += (data[i] - mean) * (data[i + lag] - mean);
|
|
168
|
+
}
|
|
169
|
+
result[lag - 1] = cov / ((n - lag) * variance);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — React Hook
|
|
3
|
+
*
|
|
4
|
+
* import { usePulse } from '@sovereign/pulse/react';
|
|
5
|
+
*
|
|
6
|
+
* const {
|
|
7
|
+
* run, reset,
|
|
8
|
+
* stage, pct, vmConf, hwConf, earlyVerdict,
|
|
9
|
+
* proof, result,
|
|
10
|
+
* isRunning, isReady, error,
|
|
11
|
+
* } = usePulse({ apiKey: 'sk_live_...' });
|
|
12
|
+
*
|
|
13
|
+
* // Or self-hosted:
|
|
14
|
+
* const { run, proof, result } = usePulse({
|
|
15
|
+
* challengeUrl: '/api/pulse/challenge',
|
|
16
|
+
* verifyUrl: '/api/pulse/verify',
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, useCallback, useRef } from 'react';
|
|
21
|
+
|
|
22
|
+
// Lazy import — only loaded in browser, allows tree-shaking in SSR builds
|
|
23
|
+
let _pulseModule = null;
|
|
24
|
+
async function getPulse() {
|
|
25
|
+
if (!_pulseModule) {
|
|
26
|
+
_pulseModule = await import('../index.js');
|
|
27
|
+
}
|
|
28
|
+
return _pulseModule.pulse;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {string} [opts.apiKey] - hosted API key (zero-config)
|
|
34
|
+
* @param {string} [opts.apiUrl] - hosted API base URL (default: https://api.sovereign.dev)
|
|
35
|
+
* @param {string} [opts.challengeUrl] - self-hosted challenge endpoint
|
|
36
|
+
* @param {string} [opts.verifyUrl] - self-hosted verify endpoint
|
|
37
|
+
* @param {number} [opts.iterations=200]
|
|
38
|
+
* @param {number} [opts.bioWindowMs=3000]
|
|
39
|
+
* @param {boolean} [opts.adaptive=true]
|
|
40
|
+
* @param {boolean} [opts.autoRun=false] - run immediately on mount
|
|
41
|
+
* @param {Function} [opts.onResult] - callback when result is ready
|
|
42
|
+
* @param {Function} [opts.onError] - callback on error
|
|
43
|
+
*/
|
|
44
|
+
export function usePulse(opts = {}) {
|
|
45
|
+
const {
|
|
46
|
+
apiKey,
|
|
47
|
+
apiUrl = 'https://api.sovereign.dev',
|
|
48
|
+
challengeUrl,
|
|
49
|
+
verifyUrl,
|
|
50
|
+
iterations = 200,
|
|
51
|
+
bioWindowMs = 3000,
|
|
52
|
+
adaptive = true,
|
|
53
|
+
autoRun = false,
|
|
54
|
+
onResult,
|
|
55
|
+
onError,
|
|
56
|
+
} = opts;
|
|
57
|
+
|
|
58
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
59
|
+
const [stage, setStage] = useState(null);
|
|
60
|
+
const [pct, setPct] = useState(0);
|
|
61
|
+
const [vmConf, setVmConf] = useState(0);
|
|
62
|
+
const [hwConf, setHwConf] = useState(0);
|
|
63
|
+
const [earlyVerdict, setEarlyVerdict]= useState(null);
|
|
64
|
+
const [proof, setProof] = useState(null);
|
|
65
|
+
const [result, setResult] = useState(null);
|
|
66
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
67
|
+
const [error, setError] = useState(null);
|
|
68
|
+
|
|
69
|
+
const abortRef = useRef(null);
|
|
70
|
+
const hasAutoRun = useRef(false);
|
|
71
|
+
|
|
72
|
+
// ── run() ────────────────────────────────────────────────────────────────
|
|
73
|
+
const run = useCallback(async () => {
|
|
74
|
+
if (isRunning) return;
|
|
75
|
+
|
|
76
|
+
// Reset
|
|
77
|
+
setStage(null); setPct(0); setVmConf(0); setHwConf(0);
|
|
78
|
+
setEarlyVerdict(null); setProof(null); setResult(null);
|
|
79
|
+
setError(null); setIsRunning(true);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// 1. Resolve nonce
|
|
83
|
+
let nonce;
|
|
84
|
+
if (apiKey) {
|
|
85
|
+
const res = await fetch(`${apiUrl}/v1/challenge`, {
|
|
86
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) throw new Error(`Challenge failed: ${res.status}`);
|
|
89
|
+
({ nonce } = await res.json());
|
|
90
|
+
} else if (challengeUrl) {
|
|
91
|
+
const res = await fetch(challengeUrl);
|
|
92
|
+
if (!res.ok) throw new Error(`Challenge failed: ${res.status}`);
|
|
93
|
+
({ nonce } = await res.json());
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'usePulse requires either apiKey or challengeUrl. ' +
|
|
97
|
+
'Pass apiKey for the hosted API, or challengeUrl + verifyUrl for self-hosted.'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Run the probe
|
|
102
|
+
const pulse = await getPulse();
|
|
103
|
+
const commitment = await pulse({
|
|
104
|
+
nonce,
|
|
105
|
+
iterations,
|
|
106
|
+
bioWindowMs,
|
|
107
|
+
adaptive,
|
|
108
|
+
onProgress: (s, meta = {}) => {
|
|
109
|
+
setStage(s);
|
|
110
|
+
if (s === 'entropy_batch' && meta) {
|
|
111
|
+
if (meta.pct != null) setPct(meta.pct);
|
|
112
|
+
if (meta.vmConf != null) setVmConf(meta.vmConf);
|
|
113
|
+
if (meta.hwConf != null) setHwConf(meta.hwConf);
|
|
114
|
+
if (meta.earlyVerdict != null) setEarlyVerdict(meta.earlyVerdict);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
setProof(commitment);
|
|
120
|
+
setPct(100);
|
|
121
|
+
|
|
122
|
+
// 3. Verify (hosted or self-hosted)
|
|
123
|
+
if (apiKey || verifyUrl) {
|
|
124
|
+
const url = apiKey ? `${apiUrl}/v1/verify` : verifyUrl;
|
|
125
|
+
const headers = {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const res = await fetch(url, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers,
|
|
133
|
+
body: JSON.stringify({ payload: commitment.payload, hash: commitment.hash }),
|
|
134
|
+
});
|
|
135
|
+
const verifyResult = await res.json();
|
|
136
|
+
setResult(verifyResult);
|
|
137
|
+
onResult?.(verifyResult, commitment);
|
|
138
|
+
} else {
|
|
139
|
+
onResult?.(null, commitment);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
} catch (err) {
|
|
143
|
+
setError(err);
|
|
144
|
+
onError?.(err);
|
|
145
|
+
} finally {
|
|
146
|
+
setIsRunning(false);
|
|
147
|
+
}
|
|
148
|
+
}, [isRunning, apiKey, apiUrl, challengeUrl, verifyUrl, iterations, bioWindowMs, adaptive, onResult, onError]);
|
|
149
|
+
|
|
150
|
+
// ── reset() ──────────────────────────────────────────────────────────────
|
|
151
|
+
const reset = useCallback(() => {
|
|
152
|
+
setStage(null); setPct(0); setVmConf(0); setHwConf(0);
|
|
153
|
+
setEarlyVerdict(null); setProof(null); setResult(null);
|
|
154
|
+
setIsRunning(false); setError(null);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
// ── autoRun on mount ──────────────────────────────────────────────────────
|
|
158
|
+
// Note: We use a ref to avoid triggering on every render.
|
|
159
|
+
// Consumers should wrap in useEffect if they need SSR safety:
|
|
160
|
+
// useEffect(() => { if (autoRun) run(); }, []);
|
|
161
|
+
if (autoRun && !hasAutoRun.current && typeof window !== 'undefined') {
|
|
162
|
+
hasAutoRun.current = true;
|
|
163
|
+
// Defer to next microtask so hook state is initialised
|
|
164
|
+
Promise.resolve().then(run);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
// Actions
|
|
169
|
+
run,
|
|
170
|
+
reset,
|
|
171
|
+
// Live probe state
|
|
172
|
+
stage,
|
|
173
|
+
pct,
|
|
174
|
+
vmConf,
|
|
175
|
+
hwConf,
|
|
176
|
+
earlyVerdict,
|
|
177
|
+
// Results
|
|
178
|
+
proof,
|
|
179
|
+
result,
|
|
180
|
+
// Status
|
|
181
|
+
isRunning,
|
|
182
|
+
isReady: !isRunning && (proof != null || error != null),
|
|
183
|
+
error,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — Express Middleware
|
|
3
|
+
*
|
|
4
|
+
* Drop-in middleware for Express / Fastify / Hono.
|
|
5
|
+
* Handles the full challenge → verify flow in two lines of code.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* import { createPulseMiddleware } from '@sovereign/pulse/middleware/express';
|
|
10
|
+
*
|
|
11
|
+
* const pulse = createPulseMiddleware({ threshold: 0.6 });
|
|
12
|
+
*
|
|
13
|
+
* app.get('/api/pulse/challenge', pulse.challenge);
|
|
14
|
+
* app.post('/checkout', pulse.verify, checkoutHandler);
|
|
15
|
+
*
|
|
16
|
+
* With Redis (recommended for production):
|
|
17
|
+
*
|
|
18
|
+
* import Redis from 'ioredis';
|
|
19
|
+
* const redis = new Redis(process.env.REDIS_URL);
|
|
20
|
+
*
|
|
21
|
+
* const pulse = createPulseMiddleware({
|
|
22
|
+
* threshold: 0.6,
|
|
23
|
+
* store: {
|
|
24
|
+
* set: (k, ttl) => redis.set(k, '1', 'EX', ttl),
|
|
25
|
+
* consume: (k) => redis.del(k).then(n => n === 1),
|
|
26
|
+
* },
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { validateProof, generateNonce } from '../proof/validator.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// In-memory nonce store (single-process / development only)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function createMemoryStore(ttlMs = 300_000) {
|
|
37
|
+
const store = new Map();
|
|
38
|
+
return {
|
|
39
|
+
set(key) {
|
|
40
|
+
store.set(key, Date.now() + ttlMs);
|
|
41
|
+
// Lazy cleanup — don't leak memory in long-running processes
|
|
42
|
+
if (store.size > 10_000) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
for (const [k, exp] of store) {
|
|
45
|
+
if (exp < now) store.delete(k);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
consume(key) {
|
|
50
|
+
const exp = store.get(key);
|
|
51
|
+
if (!exp || Date.now() > exp) return false;
|
|
52
|
+
store.delete(key);
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// createPulseMiddleware
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {number} [opts.threshold=0.55] - minimum jitter score (0–1)
|
|
65
|
+
* @param {number} [opts.nonceTTL=300] - nonce lifetime in seconds
|
|
66
|
+
* @param {boolean} [opts.requireBio=false] - reject if no mouse/keyboard activity
|
|
67
|
+
* @param {boolean} [opts.blockSoftwareRenderer=true]
|
|
68
|
+
* @param {object} [opts.store] - custom nonce store (see above)
|
|
69
|
+
* @param {string} [opts.proofHeader='x-pulse-proof'] - request header name
|
|
70
|
+
* @param {string} [opts.hashHeader='x-pulse-hash']
|
|
71
|
+
* @param {Function} [opts.onReject] - custom rejection handler
|
|
72
|
+
* @param {Function} [opts.onError] - custom error handler
|
|
73
|
+
* @returns {{ challenge: Function, verify: Function }}
|
|
74
|
+
*/
|
|
75
|
+
export function createPulseMiddleware(opts = {}) {
|
|
76
|
+
const {
|
|
77
|
+
threshold = 0.55,
|
|
78
|
+
nonceTTL = 300,
|
|
79
|
+
requireBio = false,
|
|
80
|
+
blockSoftwareRenderer = true,
|
|
81
|
+
proofHeader = 'x-pulse-proof',
|
|
82
|
+
hashHeader = 'x-pulse-hash',
|
|
83
|
+
onReject,
|
|
84
|
+
onError,
|
|
85
|
+
} = opts;
|
|
86
|
+
|
|
87
|
+
// Allow external store (Redis, etc.) or default to in-memory
|
|
88
|
+
const store = opts.store ?? createMemoryStore(nonceTTL * 1000);
|
|
89
|
+
|
|
90
|
+
// ── challenge — GET /api/pulse/challenge ──────────────────────────────────
|
|
91
|
+
async function challenge(req, res) {
|
|
92
|
+
try {
|
|
93
|
+
const nonce = generateNonce();
|
|
94
|
+
await store.set(`pulse:${nonce}`);
|
|
95
|
+
res.json({ nonce, expiresIn: nonceTTL });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (onError) return onError(err, req, res);
|
|
98
|
+
res.status(500).json({ error: 'Failed to generate challenge' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── verify — middleware for protected routes ───────────────────────────────
|
|
103
|
+
async function verify(req, res, next) {
|
|
104
|
+
try {
|
|
105
|
+
// Support both header and body delivery
|
|
106
|
+
let payload, hash;
|
|
107
|
+
if (req.headers[proofHeader]) {
|
|
108
|
+
try { payload = JSON.parse(req.headers[proofHeader]); }
|
|
109
|
+
catch { return _reject(res, 400, 'MALFORMED_PROOF_HEADER', 'Could not parse x-pulse-proof header as JSON', onReject, req); }
|
|
110
|
+
hash = req.headers[hashHeader];
|
|
111
|
+
} else if (req.body?.pulsePayload) {
|
|
112
|
+
payload = req.body.pulsePayload;
|
|
113
|
+
hash = req.body.pulseHash;
|
|
114
|
+
} else {
|
|
115
|
+
return _reject(res, 401, 'MISSING_PROOF', 'No pulse proof found in headers or body', onReject, req);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!hash) {
|
|
119
|
+
return _reject(res, 401, 'MISSING_HASH', 'No pulse hash provided', onReject, req);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = await validateProof(payload, hash, {
|
|
123
|
+
minJitterScore: threshold,
|
|
124
|
+
requireBio,
|
|
125
|
+
blockSoftwareRenderer,
|
|
126
|
+
checkNonce: async (n) => store.consume(`pulse:${n}`),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!result.valid) {
|
|
130
|
+
return _reject(res, 403, 'PROOF_INVALID', result.reasons.join('; '), onReject, req, result);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Attach result to request for downstream handlers
|
|
134
|
+
req.pulse = result;
|
|
135
|
+
next();
|
|
136
|
+
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (onError) return onError(err, req, res, next);
|
|
139
|
+
res.status(500).json({ error: 'Pulse verification error' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { challenge, verify };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Internal helpers
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function _reject(res, status, code, message, customHandler, req, result = {}) {
|
|
151
|
+
if (customHandler) {
|
|
152
|
+
return customHandler(req, res, { code, message, ...result });
|
|
153
|
+
}
|
|
154
|
+
res.status(status).json({ error: code, message, ...result });
|
|
155
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — Next.js App Router Middleware
|
|
3
|
+
*
|
|
4
|
+
* Works with Next.js App Router (13+) and Edge Runtime.
|
|
5
|
+
*
|
|
6
|
+
* ── Route Handler wrapper ──────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* // app/api/checkout/route.js
|
|
9
|
+
* import { withPulse } from '@sovereign/pulse/middleware/next';
|
|
10
|
+
*
|
|
11
|
+
* export const POST = withPulse({ threshold: 0.6 })(
|
|
12
|
+
* async (req) => {
|
|
13
|
+
* const { score, provider } = req.pulse;
|
|
14
|
+
* return Response.json({ ok: true, score });
|
|
15
|
+
* }
|
|
16
|
+
* );
|
|
17
|
+
*
|
|
18
|
+
* ── Challenge endpoint (copy-paste ready) ─────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* // app/api/pulse/challenge/route.js
|
|
21
|
+
* import { pulseChallenge } from '@sovereign/pulse/middleware/next';
|
|
22
|
+
* export const GET = pulseChallenge();
|
|
23
|
+
*
|
|
24
|
+
* ── Edge-compatible nonce store ────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* // Uses in-memory by default. For multi-instance deployments, provide
|
|
27
|
+
* // a KV store (Vercel KV, Cloudflare KV, Redis via fetch):
|
|
28
|
+
*
|
|
29
|
+
* import { kv } from '@vercel/kv';
|
|
30
|
+
* export const POST = withPulse({
|
|
31
|
+
* threshold: 0.6,
|
|
32
|
+
* store: {
|
|
33
|
+
* set: (k, ttl) => kv.set(k, '1', { ex: ttl }),
|
|
34
|
+
* consume: (k) => kv.del(k).then(n => n === 1),
|
|
35
|
+
* },
|
|
36
|
+
* })(handler);
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { validateProof, generateNonce } from '../proof/validator.js';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Shared in-memory nonce store (single instance / dev only)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const _memStore = new Map();
|
|
46
|
+
|
|
47
|
+
function memoryStore(ttlSec) {
|
|
48
|
+
return {
|
|
49
|
+
set(key) {
|
|
50
|
+
_memStore.set(key, Date.now() + ttlSec * 1000);
|
|
51
|
+
},
|
|
52
|
+
consume(key) {
|
|
53
|
+
const exp = _memStore.get(key);
|
|
54
|
+
if (!exp || Date.now() > exp) return false;
|
|
55
|
+
_memStore.delete(key);
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// pulseChallenge — GET /api/pulse/challenge
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {number} [opts.ttl=300] - nonce TTL in seconds
|
|
68
|
+
* @param {object} [opts.store] - custom nonce store
|
|
69
|
+
*/
|
|
70
|
+
export function pulseChallenge(opts = {}) {
|
|
71
|
+
const { ttl = 300, store } = opts;
|
|
72
|
+
const _store = store ?? memoryStore(ttl);
|
|
73
|
+
|
|
74
|
+
return async function GET() {
|
|
75
|
+
const nonce = generateNonce();
|
|
76
|
+
await _store.set(`pulse:${nonce}`);
|
|
77
|
+
return Response.json({ nonce, expiresIn: ttl });
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// withPulse — wraps a Next.js route handler
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {number} [opts.threshold=0.55]
|
|
88
|
+
* @param {number} [opts.ttl=300] - nonce TTL
|
|
89
|
+
* @param {boolean} [opts.requireBio=false]
|
|
90
|
+
* @param {boolean} [opts.blockSoftwareRenderer=true]
|
|
91
|
+
* @param {object} [opts.store] - custom nonce store
|
|
92
|
+
* @returns {(handler: Function) => Function} - HOC
|
|
93
|
+
*/
|
|
94
|
+
export function withPulse(opts = {}) {
|
|
95
|
+
const {
|
|
96
|
+
threshold = 0.55,
|
|
97
|
+
ttl = 300,
|
|
98
|
+
requireBio = false,
|
|
99
|
+
blockSoftwareRenderer = true,
|
|
100
|
+
store,
|
|
101
|
+
} = opts;
|
|
102
|
+
|
|
103
|
+
const _store = store ?? memoryStore(ttl);
|
|
104
|
+
|
|
105
|
+
return function wrap(handler) {
|
|
106
|
+
return async function wrappedHandler(req, ...args) {
|
|
107
|
+
// ── Read proof from headers (preferred) or body ──────────────────────
|
|
108
|
+
const proofHeader = req.headers.get('x-pulse-proof');
|
|
109
|
+
const hashHeader = req.headers.get('x-pulse-hash');
|
|
110
|
+
|
|
111
|
+
let payload, hash;
|
|
112
|
+
|
|
113
|
+
if (proofHeader) {
|
|
114
|
+
try { payload = JSON.parse(proofHeader); }
|
|
115
|
+
catch { return _err(400, 'MALFORMED_PROOF', 'Could not parse x-pulse-proof header'); }
|
|
116
|
+
hash = hashHeader;
|
|
117
|
+
} else {
|
|
118
|
+
// Attempt to read from JSON body (non-streaming)
|
|
119
|
+
try {
|
|
120
|
+
const body = await req.clone().json();
|
|
121
|
+
payload = body.pulsePayload;
|
|
122
|
+
hash = body.pulseHash;
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!payload || !hash) {
|
|
127
|
+
return _err(401, 'MISSING_PROOF',
|
|
128
|
+
'Provide pulse proof via x-pulse-proof + x-pulse-hash headers, ' +
|
|
129
|
+
'or pulsePayload + pulseHash in the request body.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Validate ───────────────────────────────────────────────────────────
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await validateProof(payload, hash, {
|
|
137
|
+
minJitterScore: threshold,
|
|
138
|
+
requireBio,
|
|
139
|
+
blockSoftwareRenderer,
|
|
140
|
+
checkNonce: async (n) => _store.consume(`pulse:${n}`),
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error('[pulse] validateProof error:', err);
|
|
144
|
+
return _err(500, 'VALIDATION_ERROR', 'Internal error during proof validation');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!result.valid) {
|
|
148
|
+
return Response.json(
|
|
149
|
+
{ error: 'PULSE_REJECTED', reasons: result.reasons, riskFlags: result.riskFlags },
|
|
150
|
+
{ status: 403 }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Attach pulse result and call the real handler ──────────────────────
|
|
155
|
+
// Next.js Request is immutable so we inject via a lightweight proxy
|
|
156
|
+
const enriched = new Proxy(req, {
|
|
157
|
+
get(target, prop) {
|
|
158
|
+
if (prop === 'pulse') return result;
|
|
159
|
+
const val = target[prop];
|
|
160
|
+
return typeof val === 'function' ? val.bind(target) : val;
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return handler(enriched, ...args);
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Internal helpers
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function _err(status, code, message) {
|
|
174
|
+
return Response.json({ error: code, message }, { status });
|
|
175
|
+
}
|