Files
gods-eye-view/mobile/capture/provenance.js
T

385 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Witness provenance (DESIGN.md §6), the browser equivalent of a Secure
* Enclave + C2PA flow:
*
* media → sha256
* session { start, end, imu:[{t,α,β,γ,ax,ay,az}], gps:[{t,lat,lon,acc,alt}], heading }
* context { device, ua, screen, timezone, network-time-offset, … }
* prev hash of this device's previous bundle (hash chain)
* hash sha256(canonical(core)) core = everything above + seq, key id, public key, sealedAt
* signature ECDSA P-256 / SHA-256 over the hash bytes, with a device key that
* is generated non-extractable and kept in IndexedDB.
*
* None of this proves truth. It raises the cost of lying.
*/
import { get, put, putCapture, lastCapture, captureBySeq } from './db.js';
const subtle = globalThis.crypto?.subtle;
const enc = new TextEncoder();
const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
/** Deterministic JSON: sorted keys, no whitespace, JSON number semantics. */
export function canonical(value) {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
return JSON.stringify(value ?? null);
}
if (Array.isArray(value))
return `[${value.map((v) => (v === undefined ? 'null' : canonical(v))).join(',')}]`;
const keys = Object.keys(value)
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
const normalize = (v) => JSON.parse(canonical(v));
export const toHex = (buf) =>
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
const fromHex = (hex) =>
new Uint8Array(hex.match(/../g).map((b) => parseInt(b, 16)));
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const fromB64url = (s) => {
const b = atob(
s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4),
);
return Uint8Array.from(b, (c) => c.charCodeAt(0));
};
export async function sha256Hex(data) {
const bytes =
typeof data === 'string'
? enc.encode(data)
: data instanceof Blob
? await data.arrayBuffer()
: data;
return toHex(await subtle.digest('SHA-256', bytes));
}
function assertCrypto() {
if (!subtle)
throw new Error('WebCrypto needs a secure context (https or localhost)');
}
/** The device signing key: generated once, private half non-extractable. */
export async function deviceKey() {
assertCrypto();
const stored = await get('keys', 'device').catch(() => null);
if (stored?.privateKey && stored?.publicJwk) return stored;
const pair = await subtle.generateKey(ALG, false, ['sign', 'verify']);
const jwk = await subtle.exportKey('jwk', pair.publicKey);
const publicJwk = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
const keyId = (await sha256Hex(canonical(publicJwk))).slice(0, 16);
const rec = {
id: 'device',
privateKey: pair.privateKey,
publicKey: pair.publicKey,
publicJwk,
keyId,
createdAt: new Date().toISOString(),
extractable: pair.privateKey.extractable,
};
await put('keys', rec);
return rec;
}
/** Estimate the device clock's offset from the server's Date header (±1 s). */
export async function networkTimeOffset() {
try {
const t0 = Date.now();
const r = await fetch(location.pathname, {
method: 'HEAD',
cache: 'no-store',
});
const t1 = Date.now();
const d = Date.parse(r.headers.get('date') || '');
if (!Number.isFinite(d)) return null;
return Math.round(d + 500 - (t0 + t1) / 2);
} catch {
return null;
}
}
/**
* Seal a capture.
* @param {{ media: Blob, session: object, context: object }} input
* @returns {Promise<object>} the stored bundle
*/
export async function seal({ media, session, context }) {
assertCrypto();
const key = await deviceKey();
const mediaHash = await sha256Hex(media);
const last = await lastCapture().catch(() => null);
const seq = (last?.seq || 0) + 1;
const core = normalize({
v: 1,
seq,
media: {
sha256: mediaHash,
type: media.type || 'application/octet-stream',
bytes: media.size,
},
session,
context,
prev: last?.hash || null,
keyId: key.keyId,
publicKey: key.publicJwk,
sealedAt: new Date().toISOString(),
});
const hash = await sha256Hex(canonical(core));
const sig = await subtle.sign(SIGN, key.privateKey, fromHex(hash));
const bundle = {
...core,
id: `cap-${String(seq).padStart(4, '0')}-${hash.slice(0, 8)}`,
hash,
alg: 'ES256',
signature: b64url(sig),
};
await putCapture(bundle, media);
return bundle;
}
const median = (a) => {
if (!a.length) return NaN;
const s = [...a].sort((x, y) => x - y);
return s[Math.floor(s.length / 2)];
};
/** Core fields covered by the hash (everything but the outputs). */
function coreOf(bundle) {
const { id, hash, alg, signature, ...core } = bundle;
void id;
void hash;
void alg;
void signature;
return core;
}
/**
* Verify a bundle: hash, signature, chain, sensor plausibility.
* @returns {Promise<{ ok: boolean, checks: Array<{id,label,status,detail}> }>}
*/
export async function verify(bundle, mediaBlob) {
assertCrypto();
const checks = [];
const add = (id, label, status, detail) =>
checks.push({ id, label, status, detail });
// 1. Media hash
if (mediaBlob) {
const h = await sha256Hex(mediaBlob);
add(
'media',
'Media hash',
h === bundle.media?.sha256 ? 'pass' : 'fail',
h === bundle.media?.sha256
? `sha256 ${h.slice(0, 16)}… matches ${bundle.media.bytes.toLocaleString()} bytes`
: `recomputed ${h.slice(0, 16)}… ≠ sealed ${String(bundle.media?.sha256).slice(0, 16)}…`,
);
} else
add('media', 'Media hash', 'warn', 'Media not available on this device');
// 2. Bundle hash
const recomputed = await sha256Hex(canonical(coreOf(bundle)));
const hashOk = recomputed === bundle.hash;
add(
'hash',
'Bundle hash',
hashOk ? 'pass' : 'fail',
hashOk
? `sha256(canonical(bundle)) = ${recomputed.slice(0, 16)}…`
: 'Bundle was modified after sealing',
);
// 3. Signature
try {
const pub = await subtle.importKey(
'jwk',
{ ...bundle.publicKey, ext: true },
ALG,
true,
['verify'],
);
const ok = await subtle.verify(
SIGN,
pub,
fromB64url(bundle.signature),
fromHex(bundle.hash),
);
const mine = await deviceKey().catch(() => null);
const same = mine?.keyId === bundle.keyId;
add(
'signature',
'Signature',
ok ? (same ? 'pass' : 'warn') : 'fail',
ok
? `ECDSA P-256 valid · key ${bundle.keyId}${same ? ' (this device)' : ' (another device)'}`
: 'Signature does not verify against the embedded public key',
);
} catch (err) {
add('signature', 'Signature', 'fail', `Could not verify: ${err.message}`);
}
// 4. Chain
if (bundle.seq === 1)
add(
'chain',
'Hash chain',
bundle.prev === null ? 'pass' : 'fail',
bundle.prev === null
? 'Genesis capture for this device key'
: 'First capture claims a predecessor',
);
else {
const prev = await captureBySeq(bundle.seq - 1).catch(() => null);
if (!prev)
add(
'chain',
'Hash chain',
'warn',
`Capture #${bundle.seq - 1} is not on this device`,
);
else if (prev.hash === bundle.prev && prev.keyId === bundle.keyId)
add(
'chain',
'Hash chain',
'pass',
`#${bundle.seq} ← #${prev.seq} ${prev.hash.slice(0, 12)}…`,
);
else add('chain', 'Hash chain', 'fail', `Link to #${prev.seq} is broken`);
}
// 5. IMU plausibility
const imu = bundle.session?.imu || [];
if (imu.length < 5)
add(
'imu',
'Motion trace',
'warn',
imu.length
? `Only ${imu.length} samples`
: 'No motion sensors were available',
);
else {
const dur = (imu[imu.length - 1].t - imu[0].t) / 1000;
const rate = imu.length / Math.max(0.001, dur);
let mono = true;
for (let i = 1; i < imu.length; i += 1)
if (imu[i].t < imu[i - 1].t) mono = false;
const mags = imu
.filter((s) => Number.isFinite(s.ax))
.map((s) => Math.hypot(s.ax, s.ay, s.az));
const g = median(mags);
const mean = mags.reduce((a, b) => a + b, 0) / Math.max(1, mags.length);
const sd = Math.sqrt(
mags.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, mags.length),
);
const gravityOk = Number.isFinite(g) ? Math.abs(g - 9.81) < 1.6 : null;
const alive = sd > 0.004;
// Pitch from gravity should agree with the orientation β.
let tiltAgree = null;
const withBoth = imu.filter(
(s) =>
Number.isFinite(s.β) && Number.isFinite(s.ay) && Number.isFinite(s.az),
);
if (withBoth.length > 4) {
// iOS reports gravity with the opposite sign to the W3C spec; accept either.
const d = (a, b) => Math.abs(((a - b + 540) % 360) - 180);
const diffs = withBoth.map((s) => {
const pitchG = (Math.atan2(s.ay, s.az) * 180) / Math.PI;
const flipped = (Math.atan2(-s.ay, -s.az) * 180) / Math.PI;
return Math.min(d(pitchG, s.β), d(flipped, s.β));
});
tiltAgree = median(diffs) < 20;
}
const status =
!mono || gravityOk === false
? 'fail'
: alive && tiltAgree !== false
? 'pass'
: 'warn';
add(
'imu',
'Motion trace',
status,
`${imu.length} samples · ${rate.toFixed(0)} Hz · ${dur.toFixed(1)} s · |g| ${Number.isFinite(g) ? g.toFixed(2) : '—'} m/s²${alive ? ' · hand tremor present' : ' · suspiciously still'}${tiltAgree === null ? '' : tiltAgree ? ' · tilt agrees with gravity' : ' · tilt disagrees with gravity'}`,
);
}
// 6. GPS plausibility
const gps = bundle.session?.gps || [];
if (!gps.length) add('gps', 'Location', 'warn', 'No GPS fix during capture');
else {
const s0 = Date.parse(bundle.session.start);
const s1 = Date.parse(bundle.session.end);
const inWindow = gps.every((f) => f.t >= s0 - 120e3 && f.t <= s1 + 120e3);
let maxSpeed = 0;
for (let i = 1; i < gps.length; i += 1) {
const a = gps[i - 1];
const b = gps[i];
const dt = (b.t - a.t) / 1000;
if (dt > 0.5) {
const R = 6371e3;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
maxSpeed = Math.max(maxSpeed, (2 * R * Math.asin(Math.sqrt(x))) / dt);
}
}
const acc = median(gps.map((f) => f.acc).filter(Number.isFinite));
const last = gps[gps.length - 1];
// The clock's timezone should be roughly where the fix says we are.
const tzH = -(bundle.context?.tzOffsetMin ?? 0) / 60;
const solarH = last.lon / 15;
const tzOk = Math.abs(((tzH - solarH + 36) % 24) - 12) < 3.5;
const speedOk = maxSpeed < 300;
const status =
!inWindow || !speedOk ? 'fail' : tzOk && acc < 500 ? 'pass' : 'warn';
add(
'gps',
'Location',
status,
`${gps.length} fix${gps.length > 1 ? 'es' : ''} · ±${Number.isFinite(acc) ? Math.round(acc) : '—'} m · max ${maxSpeed.toFixed(1)} m/s${inWindow ? ' · within session' : ' · outside session window'}${tzOk ? ' · clock zone agrees with longitude' : ' · clock zone disagrees with longitude'}`,
);
}
// 7. Heading
const hd = bundle.session?.heading;
if (hd && Number.isFinite(hd.deg))
add(
'heading',
'Heading',
hd.source === 'compass' || hd.source === 'absolute' ? 'pass' : 'warn',
`${Math.round(hd.deg)}° (${hd.source})${Number.isFinite(hd.pitch) ? ` · camera pitch ${hd.pitch.toFixed(0)}°` : ''}`,
);
else add('heading', 'Heading', 'warn', 'No compass heading recorded');
// 8. Clock
const off = bundle.context?.networkTimeOffsetMs;
const sealedVsEnd =
Date.parse(bundle.sealedAt) - Date.parse(bundle.session?.end);
const clockOk = Number.isFinite(off) ? Math.abs(off) < 5000 : null;
add(
'clock',
'Clock',
clockOk === false || sealedVsEnd > 60e3 || sealedVsEnd < -2000
? 'warn'
: clockOk
? 'pass'
: 'warn',
`${Number.isFinite(off) ? `device vs network ${off >= 0 ? '+' : ''}${(off / 1000).toFixed(1)} s` : 'network time unavailable'} · sealed ${(sealedVsEnd / 1000).toFixed(1)} s after capture`,
);
return { ok: !checks.some((c) => c.status === 'fail'), checks };
}