@iam-protocol/pulse-sdk 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 +59 -0
- package/dist/index.d.mts +376 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +14316 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +14238 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/src/challenge/lissajous.ts +56 -0
- package/src/challenge/phrase.ts +29 -0
- package/src/config.ts +40 -0
- package/src/extraction/kinematic.ts +101 -0
- package/src/extraction/mfcc.ts +93 -0
- package/src/extraction/statistics.ts +59 -0
- package/src/extraction/types.ts +17 -0
- package/src/hashing/poseidon.ts +92 -0
- package/src/hashing/simhash.ts +87 -0
- package/src/hashing/types.ts +16 -0
- package/src/identity/anchor.ts +75 -0
- package/src/identity/types.ts +18 -0
- package/src/index.ts +43 -0
- package/src/proof/prover.ts +87 -0
- package/src/proof/serializer.ts +79 -0
- package/src/proof/types.ts +31 -0
- package/src/pulse.ts +397 -0
- package/src/sensor/audio.ts +94 -0
- package/src/sensor/motion.ts +83 -0
- package/src/sensor/touch.ts +65 -0
- package/src/sensor/types.ts +55 -0
- package/src/submit/relayer.ts +58 -0
- package/src/submit/types.ts +15 -0
- package/src/submit/wallet.ts +167 -0
- package/src/types.d.ts +14 -0
- package/test/integration.test.ts +102 -0
- package/test/poseidon.test.ts +81 -0
- package/test/serializer.test.ts +86 -0
- package/test/simhash.test.ts +57 -0
- package/test/statistics.test.ts +51 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iam-protocol/pulse-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client-side SDK for IAM Protocol — sensor capture, TBH generation, ZK proof construction",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"circomlibjs": "^0.1.7",
|
|
23
|
+
"meyda": "^5.6.3",
|
|
24
|
+
"snarkjs": "^0.7.6"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@coral-xyz/anchor": "^0.32.1",
|
|
28
|
+
"@solana/wallet-adapter-base": "^0.9.0",
|
|
29
|
+
"@solana/web3.js": "^1.98.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@coral-xyz/anchor": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@solana/web3.js": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"@solana/wallet-adapter-base": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@coral-xyz/anchor": "^0.32.1",
|
|
44
|
+
"@solana/spl-token": "^0.4.14",
|
|
45
|
+
"@solana/web3.js": "^1.98.0",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"tsup": "^8.0.0",
|
|
48
|
+
"typescript": "^5.7.0",
|
|
49
|
+
"vitest": "^3.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate Lissajous curve points for the touch tracing challenge.
|
|
3
|
+
* The user traces this shape on screen while speaking the phrase.
|
|
4
|
+
*
|
|
5
|
+
* x(t) = A * sin(a*t + delta)
|
|
6
|
+
* y(t) = B * sin(b*t)
|
|
7
|
+
*/
|
|
8
|
+
export interface LissajousParams {
|
|
9
|
+
a: number;
|
|
10
|
+
b: number;
|
|
11
|
+
delta: number;
|
|
12
|
+
points: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Point2D {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate random Lissajous parameters for a challenge.
|
|
22
|
+
*/
|
|
23
|
+
export function randomLissajousParams(): LissajousParams {
|
|
24
|
+
const ratios = [
|
|
25
|
+
[1, 2],
|
|
26
|
+
[2, 3],
|
|
27
|
+
[3, 4],
|
|
28
|
+
[3, 5],
|
|
29
|
+
[4, 5],
|
|
30
|
+
];
|
|
31
|
+
const pair = ratios[Math.floor(Math.random() * ratios.length)]!;
|
|
32
|
+
return {
|
|
33
|
+
a: pair[0]!,
|
|
34
|
+
b: pair[1]!,
|
|
35
|
+
delta: Math.PI * (0.25 + Math.random() * 0.5),
|
|
36
|
+
points: 200,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate Lissajous curve points normalized to [0, 1] range.
|
|
42
|
+
*/
|
|
43
|
+
export function generateLissajousPoints(params: LissajousParams): Point2D[] {
|
|
44
|
+
const { a, b, delta, points } = params;
|
|
45
|
+
const result: Point2D[] = [];
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < points; i++) {
|
|
48
|
+
const t = (i / points) * 2 * Math.PI;
|
|
49
|
+
result.push({
|
|
50
|
+
x: (Math.sin(a * t + delta) + 1) / 2,
|
|
51
|
+
y: (Math.sin(b * t) + 1) / 2,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Phonetically-balanced nonsense syllables for the voice challenge.
|
|
2
|
+
// Designed to elicit diverse vocal patterns while preventing dictionary-based deepfake attacks.
|
|
3
|
+
const SYLLABLES = [
|
|
4
|
+
"ba", "da", "fa", "ga", "ha", "ja", "ka", "la", "ma", "na",
|
|
5
|
+
"pa", "ra", "sa", "ta", "wa", "za", "be", "de", "fe", "ge",
|
|
6
|
+
"ke", "le", "me", "ne", "pe", "re", "se", "te", "we", "ze",
|
|
7
|
+
"bi", "di", "fi", "gi", "ki", "li", "mi", "ni", "pi", "ri",
|
|
8
|
+
"si", "ti", "wi", "zi", "bo", "do", "fo", "go", "ko", "lo",
|
|
9
|
+
"mo", "no", "po", "ro", "so", "to", "wo", "zo", "bu", "du",
|
|
10
|
+
"fu", "gu", "ku", "lu", "mu", "nu", "pu", "ru", "su", "tu",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a random phonetically-balanced phrase for the voice challenge.
|
|
15
|
+
* Each phrase is 5-6 syllable pairs, forming nonsensical but speakable words.
|
|
16
|
+
*/
|
|
17
|
+
export function generatePhrase(wordCount: number = 5): string {
|
|
18
|
+
const words: string[] = [];
|
|
19
|
+
for (let w = 0; w < wordCount; w++) {
|
|
20
|
+
const syllableCount = 2 + Math.floor(Math.random() * 2); // 2-3 syllables per word
|
|
21
|
+
let word = "";
|
|
22
|
+
for (let s = 0; s < syllableCount; s++) {
|
|
23
|
+
const idx = Math.floor(Math.random() * SYLLABLES.length);
|
|
24
|
+
word += SYLLABLES[idx];
|
|
25
|
+
}
|
|
26
|
+
words.push(word);
|
|
27
|
+
}
|
|
28
|
+
return words.join(" ");
|
|
29
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// BN254 base field prime (for G1 point negation in proof_a)
|
|
2
|
+
export const BN254_BASE_FIELD = BigInt(
|
|
3
|
+
"21888242871839275222246405745257275088696311157297823662689037894645226208583"
|
|
4
|
+
);
|
|
5
|
+
|
|
6
|
+
// BN254 scalar field prime (for salt generation, field element bounds)
|
|
7
|
+
export const BN254_SCALAR_FIELD = BigInt(
|
|
8
|
+
"21888242871839275222246405745257275088548364400416034343698204186575808495617"
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export const FINGERPRINT_BITS = 256;
|
|
12
|
+
export const DEFAULT_THRESHOLD = 30;
|
|
13
|
+
export const NUM_PUBLIC_INPUTS = 3;
|
|
14
|
+
|
|
15
|
+
export const PROOF_A_SIZE = 64;
|
|
16
|
+
export const PROOF_B_SIZE = 128;
|
|
17
|
+
export const PROOF_C_SIZE = 64;
|
|
18
|
+
export const TOTAL_PROOF_SIZE = 256;
|
|
19
|
+
|
|
20
|
+
export const SIMHASH_SEED = "IAM-PROTOCOL-SIMHASH-V1";
|
|
21
|
+
|
|
22
|
+
// Capture duration bounds (ms)
|
|
23
|
+
export const MIN_CAPTURE_MS = 2000;
|
|
24
|
+
export const MAX_CAPTURE_MS = 60000;
|
|
25
|
+
export const DEFAULT_CAPTURE_MS = 7000;
|
|
26
|
+
|
|
27
|
+
export const PROGRAM_IDS = {
|
|
28
|
+
iamAnchor: "GZYwTp2ozeuRA5Gof9vs4ya961aANcJBdUzB7LN6q4b2",
|
|
29
|
+
iamVerifier: "4F97jNoxQzT2qRbkWpW3ztC3Nz2TtKj3rnKG8ExgnrfV",
|
|
30
|
+
iamRegistry: "6VBs3zr9KrfFPGd6j7aGBPQWwZa5tajVfA7HN6MMV9VW",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export interface PulseConfig {
|
|
34
|
+
cluster: "devnet" | "mainnet-beta" | "localnet";
|
|
35
|
+
rpcEndpoint?: string;
|
|
36
|
+
relayerUrl?: string;
|
|
37
|
+
zkeyUrl?: string;
|
|
38
|
+
wasmUrl?: string;
|
|
39
|
+
threshold?: number;
|
|
40
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { MotionSample, TouchSample } from "../sensor/types";
|
|
2
|
+
import { condense } from "./statistics";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract kinematic features from motion (IMU) data.
|
|
6
|
+
* Computes jerk (3rd derivative) and jounce (4th derivative) of acceleration,
|
|
7
|
+
* then condenses each axis into statistics.
|
|
8
|
+
*
|
|
9
|
+
* Returns: ~48 values (6 axes × 2 derivatives × 4 stats)
|
|
10
|
+
*/
|
|
11
|
+
export function extractMotionFeatures(samples: MotionSample[]): number[] {
|
|
12
|
+
if (samples.length < 5) return new Array(48).fill(0);
|
|
13
|
+
|
|
14
|
+
// Extract acceleration and rotation time series
|
|
15
|
+
const axes = {
|
|
16
|
+
ax: samples.map((s) => s.ax),
|
|
17
|
+
ay: samples.map((s) => s.ay),
|
|
18
|
+
az: samples.map((s) => s.az),
|
|
19
|
+
gx: samples.map((s) => s.gx),
|
|
20
|
+
gy: samples.map((s) => s.gy),
|
|
21
|
+
gz: samples.map((s) => s.gz),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const features: number[] = [];
|
|
25
|
+
|
|
26
|
+
for (const values of Object.values(axes)) {
|
|
27
|
+
// Jerk = 3rd derivative of position = 1st derivative of acceleration
|
|
28
|
+
const jerk = derivative(values);
|
|
29
|
+
// Jounce = 4th derivative of position = 2nd derivative of acceleration
|
|
30
|
+
const jounce = derivative(jerk);
|
|
31
|
+
|
|
32
|
+
const jerkStats = condense(jerk);
|
|
33
|
+
const jounceStats = condense(jounce);
|
|
34
|
+
|
|
35
|
+
features.push(
|
|
36
|
+
jerkStats.mean,
|
|
37
|
+
jerkStats.variance,
|
|
38
|
+
jerkStats.skewness,
|
|
39
|
+
jerkStats.kurtosis,
|
|
40
|
+
jounceStats.mean,
|
|
41
|
+
jounceStats.variance,
|
|
42
|
+
jounceStats.skewness,
|
|
43
|
+
jounceStats.kurtosis
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return features;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract kinematic features from touch data.
|
|
52
|
+
* Computes velocity and acceleration of touch coordinates,
|
|
53
|
+
* plus pressure and area statistics.
|
|
54
|
+
*
|
|
55
|
+
* Returns: ~32 values
|
|
56
|
+
*/
|
|
57
|
+
export function extractTouchFeatures(samples: TouchSample[]): number[] {
|
|
58
|
+
if (samples.length < 5) return new Array(32).fill(0);
|
|
59
|
+
|
|
60
|
+
const x = samples.map((s) => s.x);
|
|
61
|
+
const y = samples.map((s) => s.y);
|
|
62
|
+
const pressure = samples.map((s) => s.pressure);
|
|
63
|
+
const area = samples.map((s) => s.width * s.height);
|
|
64
|
+
|
|
65
|
+
const features: number[] = [];
|
|
66
|
+
|
|
67
|
+
// X velocity and acceleration
|
|
68
|
+
const vx = derivative(x);
|
|
69
|
+
const accX = derivative(vx);
|
|
70
|
+
features.push(...Object.values(condense(vx)));
|
|
71
|
+
features.push(...Object.values(condense(accX)));
|
|
72
|
+
|
|
73
|
+
// Y velocity and acceleration
|
|
74
|
+
const vy = derivative(y);
|
|
75
|
+
const accY = derivative(vy);
|
|
76
|
+
features.push(...Object.values(condense(vy)));
|
|
77
|
+
features.push(...Object.values(condense(accY)));
|
|
78
|
+
|
|
79
|
+
// Pressure statistics
|
|
80
|
+
features.push(...Object.values(condense(pressure)));
|
|
81
|
+
|
|
82
|
+
// Contact area statistics
|
|
83
|
+
features.push(...Object.values(condense(area)));
|
|
84
|
+
|
|
85
|
+
// Jerk of touch path
|
|
86
|
+
const jerkX = derivative(accX);
|
|
87
|
+
const jerkY = derivative(accY);
|
|
88
|
+
features.push(...Object.values(condense(jerkX)));
|
|
89
|
+
features.push(...Object.values(condense(jerkY)));
|
|
90
|
+
|
|
91
|
+
return features;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Compute discrete derivative (differences between consecutive values) */
|
|
95
|
+
function derivative(values: number[]): number[] {
|
|
96
|
+
const d: number[] = [];
|
|
97
|
+
for (let i = 1; i < values.length; i++) {
|
|
98
|
+
d.push((values[i] ?? 0) - (values[i - 1] ?? 0));
|
|
99
|
+
}
|
|
100
|
+
return d;
|
|
101
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AudioCapture } from "../sensor/types";
|
|
2
|
+
import { condense } from "./statistics";
|
|
3
|
+
|
|
4
|
+
// Frame parameters matching the research paper spec
|
|
5
|
+
const FRAME_SIZE = 400; // 25ms at 16kHz
|
|
6
|
+
const HOP_SIZE = 160; // 10ms hop
|
|
7
|
+
const NUM_MFCC = 13;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract MFCC features from audio data.
|
|
11
|
+
* Computes 13 MFCCs per frame, plus delta and delta-delta coefficients,
|
|
12
|
+
* then condenses each coefficient's time series into 4 statistics.
|
|
13
|
+
*
|
|
14
|
+
* Returns: 13 coefficients × 3 (raw + delta + delta-delta) × 4 stats = 156 values
|
|
15
|
+
*/
|
|
16
|
+
export function extractMFCC(audio: AudioCapture): number[] {
|
|
17
|
+
const { samples, sampleRate } = audio;
|
|
18
|
+
|
|
19
|
+
// Lazy import of Meyda (browser/Node compatible)
|
|
20
|
+
let Meyda: any;
|
|
21
|
+
try {
|
|
22
|
+
Meyda = require("meyda");
|
|
23
|
+
} catch {
|
|
24
|
+
// Meyda not available — return zeros (fallback for environments without it)
|
|
25
|
+
return new Array(NUM_MFCC * 3 * 4).fill(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Extract MFCCs per frame
|
|
29
|
+
const numFrames = Math.floor((samples.length - FRAME_SIZE) / HOP_SIZE) + 1;
|
|
30
|
+
if (numFrames < 3) return new Array(NUM_MFCC * 3 * 4).fill(0);
|
|
31
|
+
|
|
32
|
+
const mfccFrames: number[][] = [];
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < numFrames; i++) {
|
|
35
|
+
const start = i * HOP_SIZE;
|
|
36
|
+
const frame = samples.slice(start, start + FRAME_SIZE);
|
|
37
|
+
|
|
38
|
+
// Pad if frame is shorter than expected
|
|
39
|
+
const paddedFrame = new Float32Array(FRAME_SIZE);
|
|
40
|
+
paddedFrame.set(frame);
|
|
41
|
+
|
|
42
|
+
const features = Meyda.extract(["mfcc"], paddedFrame, {
|
|
43
|
+
sampleRate,
|
|
44
|
+
bufferSize: FRAME_SIZE,
|
|
45
|
+
numberOfMFCCCoefficients: NUM_MFCC,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (features?.mfcc) {
|
|
49
|
+
mfccFrames.push(features.mfcc);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (mfccFrames.length < 3) return new Array(NUM_MFCC * 3 * 4).fill(0);
|
|
54
|
+
|
|
55
|
+
// Compute delta (1st derivative) and delta-delta (2nd derivative)
|
|
56
|
+
const deltaFrames = computeDeltas(mfccFrames);
|
|
57
|
+
const deltaDeltaFrames = computeDeltas(deltaFrames);
|
|
58
|
+
|
|
59
|
+
// Condense each coefficient across all frames into 4 statistics
|
|
60
|
+
const features: number[] = [];
|
|
61
|
+
|
|
62
|
+
for (let c = 0; c < NUM_MFCC; c++) {
|
|
63
|
+
// Raw MFCC coefficient c across all frames
|
|
64
|
+
const raw = mfccFrames.map((f) => f[c] ?? 0);
|
|
65
|
+
const stats = condense(raw);
|
|
66
|
+
features.push(stats.mean, stats.variance, stats.skewness, stats.kurtosis);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (let c = 0; c < NUM_MFCC; c++) {
|
|
70
|
+
const delta = deltaFrames.map((f) => f[c] ?? 0);
|
|
71
|
+
const stats = condense(delta);
|
|
72
|
+
features.push(stats.mean, stats.variance, stats.skewness, stats.kurtosis);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (let c = 0; c < NUM_MFCC; c++) {
|
|
76
|
+
const dd = deltaDeltaFrames.map((f) => f[c] ?? 0);
|
|
77
|
+
const stats = condense(dd);
|
|
78
|
+
features.push(stats.mean, stats.variance, stats.skewness, stats.kurtosis);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return features;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compute delta coefficients (frame-to-frame differences) */
|
|
85
|
+
function computeDeltas(frames: number[][]): number[][] {
|
|
86
|
+
const deltas: number[][] = [];
|
|
87
|
+
for (let i = 1; i < frames.length; i++) {
|
|
88
|
+
const prev = frames[i - 1]!;
|
|
89
|
+
const curr = frames[i]!;
|
|
90
|
+
deltas.push(curr.map((v, j) => v - (prev[j] ?? 0)));
|
|
91
|
+
}
|
|
92
|
+
return deltas;
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { StatsSummary } from "./types";
|
|
2
|
+
|
|
3
|
+
export function mean(values: number[]): number {
|
|
4
|
+
if (values.length === 0) return 0;
|
|
5
|
+
let sum = 0;
|
|
6
|
+
for (const v of values) sum += v;
|
|
7
|
+
return sum / values.length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function variance(values: number[], mu?: number): number {
|
|
11
|
+
if (values.length < 2) return 0;
|
|
12
|
+
const m = mu ?? mean(values);
|
|
13
|
+
let sum = 0;
|
|
14
|
+
for (const v of values) sum += (v - m) ** 2;
|
|
15
|
+
return sum / (values.length - 1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function skewness(values: number[]): number {
|
|
19
|
+
if (values.length < 3) return 0;
|
|
20
|
+
const n = values.length;
|
|
21
|
+
const m = mean(values);
|
|
22
|
+
const s = Math.sqrt(variance(values, m));
|
|
23
|
+
if (s === 0) return 0;
|
|
24
|
+
let sum = 0;
|
|
25
|
+
for (const v of values) sum += ((v - m) / s) ** 3;
|
|
26
|
+
return (n / ((n - 1) * (n - 2))) * sum;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function kurtosis(values: number[]): number {
|
|
30
|
+
if (values.length < 4) return 0;
|
|
31
|
+
const n = values.length;
|
|
32
|
+
const m = mean(values);
|
|
33
|
+
const s2 = variance(values, m);
|
|
34
|
+
if (s2 === 0) return 0;
|
|
35
|
+
let sum = 0;
|
|
36
|
+
for (const v of values) sum += ((v - m) ** 4) / s2 ** 2;
|
|
37
|
+
const k =
|
|
38
|
+
((n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3))) * sum -
|
|
39
|
+
(3 * (n - 1) ** 2) / ((n - 2) * (n - 3));
|
|
40
|
+
return k;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function condense(values: number[]): StatsSummary {
|
|
44
|
+
const m = mean(values);
|
|
45
|
+
return {
|
|
46
|
+
mean: m,
|
|
47
|
+
variance: variance(values, m),
|
|
48
|
+
skewness: skewness(values),
|
|
49
|
+
kurtosis: kurtosis(values),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fuseFeatures(
|
|
54
|
+
audio: number[],
|
|
55
|
+
motion: number[],
|
|
56
|
+
touch: number[]
|
|
57
|
+
): number[] {
|
|
58
|
+
return [...audio, ...motion, ...touch];
|
|
59
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Statistical summary of a time series */
|
|
2
|
+
export interface StatsSummary {
|
|
3
|
+
mean: number;
|
|
4
|
+
variance: number;
|
|
5
|
+
skewness: number;
|
|
6
|
+
kurtosis: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Feature vector from all sensor modalities */
|
|
10
|
+
export interface FeatureVector {
|
|
11
|
+
audio: number[];
|
|
12
|
+
motion: number[];
|
|
13
|
+
touch: number[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Concatenated feature vector for SimHash input */
|
|
17
|
+
export type FusedFeatureVector = number[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { BN254_SCALAR_FIELD, FINGERPRINT_BITS } from "../config";
|
|
2
|
+
import type { PackedFingerprint, TBH, TemporalFingerprint } from "./types";
|
|
3
|
+
|
|
4
|
+
// Lazy-initialized Poseidon instance
|
|
5
|
+
let poseidonInstance: any = null;
|
|
6
|
+
|
|
7
|
+
async function getPoseidon(): Promise<any> {
|
|
8
|
+
if (!poseidonInstance) {
|
|
9
|
+
const circomlibjs = await import("circomlibjs");
|
|
10
|
+
poseidonInstance = await (circomlibjs as any).buildPoseidon();
|
|
11
|
+
}
|
|
12
|
+
return poseidonInstance;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pack 256-bit fingerprint into two 128-bit field elements.
|
|
17
|
+
* Little-endian bit ordering within each chunk (matches circuit's Bits2Num).
|
|
18
|
+
*/
|
|
19
|
+
export function packBits(fingerprint: TemporalFingerprint): PackedFingerprint {
|
|
20
|
+
let lo = BigInt(0);
|
|
21
|
+
for (let i = 0; i < 128; i++) {
|
|
22
|
+
if (fingerprint[i] === 1) {
|
|
23
|
+
lo += BigInt(1) << BigInt(i);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let hi = BigInt(0);
|
|
28
|
+
for (let i = 0; i < 128; i++) {
|
|
29
|
+
if (fingerprint[128 + i] === 1) {
|
|
30
|
+
hi += BigInt(1) << BigInt(i);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { lo, hi };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute Poseidon commitment: Poseidon(pack_lo, pack_hi, salt).
|
|
39
|
+
* Matches the circuit's CommitmentCheck template exactly.
|
|
40
|
+
*/
|
|
41
|
+
export async function computeCommitment(
|
|
42
|
+
fingerprint: TemporalFingerprint,
|
|
43
|
+
salt: bigint
|
|
44
|
+
): Promise<bigint> {
|
|
45
|
+
const poseidon = await getPoseidon();
|
|
46
|
+
const { lo, hi } = packBits(fingerprint);
|
|
47
|
+
const hash = poseidon([lo, hi, salt]);
|
|
48
|
+
return poseidon.F.toObject(hash) as bigint;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a random salt within the BN254 scalar field.
|
|
53
|
+
*/
|
|
54
|
+
export function generateSalt(): bigint {
|
|
55
|
+
const bytes = new Uint8Array(31);
|
|
56
|
+
crypto.getRandomValues(bytes);
|
|
57
|
+
let val = BigInt(0);
|
|
58
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
59
|
+
val = (val << BigInt(8)) + BigInt(bytes[i] ?? 0);
|
|
60
|
+
}
|
|
61
|
+
return val % BN254_SCALAR_FIELD;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert a BigInt to a 32-byte big-endian Uint8Array.
|
|
66
|
+
*/
|
|
67
|
+
export function bigintToBytes32(n: bigint): Uint8Array {
|
|
68
|
+
const bytes = new Uint8Array(32);
|
|
69
|
+
let val = n;
|
|
70
|
+
for (let i = 31; i >= 0; i--) {
|
|
71
|
+
bytes[i] = Number(val & BigInt(0xff));
|
|
72
|
+
val >>= BigInt(8);
|
|
73
|
+
}
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate a complete TBH from a fingerprint.
|
|
79
|
+
*/
|
|
80
|
+
export async function generateTBH(
|
|
81
|
+
fingerprint: TemporalFingerprint,
|
|
82
|
+
salt?: bigint
|
|
83
|
+
): Promise<TBH> {
|
|
84
|
+
const s = salt ?? generateSalt();
|
|
85
|
+
const commitment = await computeCommitment(fingerprint, s);
|
|
86
|
+
return {
|
|
87
|
+
fingerprint,
|
|
88
|
+
salt: s,
|
|
89
|
+
commitment,
|
|
90
|
+
commitmentBytes: bigintToBytes32(commitment),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { FINGERPRINT_BITS, SIMHASH_SEED } from "../config";
|
|
2
|
+
import type { TemporalFingerprint } from "./types";
|
|
3
|
+
|
|
4
|
+
// Mulberry32 PRNG: deterministic, fast, good distribution
|
|
5
|
+
function mulberry32(seed: number): () => number {
|
|
6
|
+
let state = seed | 0;
|
|
7
|
+
return () => {
|
|
8
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
9
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
10
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
11
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Derive a numeric seed from the protocol seed string
|
|
16
|
+
function deriveSeed(seedStr: string): number {
|
|
17
|
+
let hash = 0;
|
|
18
|
+
for (let i = 0; i < seedStr.length; i++) {
|
|
19
|
+
const ch = seedStr.charCodeAt(i);
|
|
20
|
+
hash = ((hash << 5) - hash + ch) | 0;
|
|
21
|
+
}
|
|
22
|
+
return hash;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cachedHyperplanes: number[][] | null = null;
|
|
26
|
+
let cachedDimension = 0;
|
|
27
|
+
|
|
28
|
+
function getHyperplanes(dimension: number): number[][] {
|
|
29
|
+
if (cachedHyperplanes && cachedDimension === dimension) {
|
|
30
|
+
return cachedHyperplanes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rng = mulberry32(deriveSeed(SIMHASH_SEED));
|
|
34
|
+
const planes: number[][] = [];
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < FINGERPRINT_BITS; i++) {
|
|
37
|
+
const plane: number[] = [];
|
|
38
|
+
for (let j = 0; j < dimension; j++) {
|
|
39
|
+
// Random value in [-1, 1]
|
|
40
|
+
plane.push(rng() * 2 - 1);
|
|
41
|
+
}
|
|
42
|
+
planes.push(plane);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cachedHyperplanes = planes;
|
|
46
|
+
cachedDimension = dimension;
|
|
47
|
+
return planes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute a 256-bit SimHash fingerprint from a feature vector.
|
|
52
|
+
* Uses deterministic random hyperplanes seeded from the protocol constant.
|
|
53
|
+
* Similar feature vectors produce fingerprints with low Hamming distance.
|
|
54
|
+
*/
|
|
55
|
+
export function simhash(features: number[]): TemporalFingerprint {
|
|
56
|
+
if (features.length === 0) {
|
|
57
|
+
return new Array(FINGERPRINT_BITS).fill(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const planes = getHyperplanes(features.length);
|
|
61
|
+
const fingerprint: TemporalFingerprint = [];
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < FINGERPRINT_BITS; i++) {
|
|
64
|
+
const plane = planes[i];
|
|
65
|
+
let dot = 0;
|
|
66
|
+
for (let j = 0; j < features.length; j++) {
|
|
67
|
+
dot += (features[j] ?? 0) * (plane?.[j] ?? 0);
|
|
68
|
+
}
|
|
69
|
+
fingerprint.push(dot >= 0 ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return fingerprint;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute Hamming distance between two fingerprints.
|
|
77
|
+
*/
|
|
78
|
+
export function hammingDistance(
|
|
79
|
+
a: TemporalFingerprint,
|
|
80
|
+
b: TemporalFingerprint
|
|
81
|
+
): number {
|
|
82
|
+
let distance = 0;
|
|
83
|
+
for (let i = 0; i < a.length; i++) {
|
|
84
|
+
if (a[i] !== b[i]) distance++;
|
|
85
|
+
}
|
|
86
|
+
return distance;
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** 256-bit Temporal Fingerprint as an array of 0/1 values */
|
|
2
|
+
export type TemporalFingerprint = number[];
|
|
3
|
+
|
|
4
|
+
/** Temporal-Biometric Hash: commitment + data needed for re-verification */
|
|
5
|
+
export interface TBH {
|
|
6
|
+
fingerprint: TemporalFingerprint;
|
|
7
|
+
salt: bigint;
|
|
8
|
+
commitment: bigint;
|
|
9
|
+
commitmentBytes: Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Packed field elements from bit packing (2 × 128-bit) */
|
|
13
|
+
export interface PackedFingerprint {
|
|
14
|
+
lo: bigint;
|
|
15
|
+
hi: bigint;
|
|
16
|
+
}
|