mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-30 23:47:27 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
|
||||
export interface PrivacyProfileSnapshot {
|
||||
profile?: string;
|
||||
wormhole_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RnsStatusSnapshot {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
configured_peers: number;
|
||||
active_peers: number;
|
||||
}
|
||||
|
||||
export interface InfonetBootstrapSnapshot {
|
||||
node_mode?: string;
|
||||
manifest_loaded?: boolean;
|
||||
manifest_signer_id?: string;
|
||||
manifest_valid_until?: number;
|
||||
bootstrap_peer_count?: number;
|
||||
sync_peer_count?: number;
|
||||
push_peer_count?: number;
|
||||
operator_peer_count?: number;
|
||||
last_bootstrap_error?: string;
|
||||
}
|
||||
|
||||
export interface InfonetSyncRuntimeSnapshot {
|
||||
last_sync_started_at?: number;
|
||||
last_sync_finished_at?: number;
|
||||
last_sync_ok_at?: number;
|
||||
next_sync_due_at?: number;
|
||||
last_peer_url?: string;
|
||||
last_error?: string;
|
||||
last_outcome?: string;
|
||||
current_head?: string;
|
||||
fork_detected?: boolean;
|
||||
consecutive_failures?: number;
|
||||
}
|
||||
|
||||
export interface InfonetPushResultSnapshot {
|
||||
peer_url?: string;
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
transport?: string;
|
||||
}
|
||||
|
||||
export interface InfonetPushRuntimeSnapshot {
|
||||
last_event_id?: string;
|
||||
last_push_ok_at?: number;
|
||||
last_push_error?: string;
|
||||
last_results?: InfonetPushResultSnapshot[];
|
||||
}
|
||||
|
||||
export interface InfonetNodeStatusSnapshot {
|
||||
network_id?: string;
|
||||
total_events?: number;
|
||||
active_events?: number;
|
||||
known_nodes?: number;
|
||||
chain_size_kb?: number;
|
||||
head_hash?: string;
|
||||
unsigned_events?: number;
|
||||
valid?: boolean;
|
||||
validation?: string;
|
||||
event_types?: Record<string, number>;
|
||||
node_mode?: string;
|
||||
node_enabled?: boolean;
|
||||
bootstrap?: InfonetBootstrapSnapshot;
|
||||
sync_runtime?: InfonetSyncRuntimeSnapshot;
|
||||
push_runtime?: InfonetPushRuntimeSnapshot;
|
||||
private_lane_tier?: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 15000;
|
||||
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
inflight: Promise<T> | null;
|
||||
} | null;
|
||||
|
||||
let privacyProfileCache: CacheEntry<PrivacyProfileSnapshot> = null;
|
||||
let rnsStatusCache: CacheEntry<RnsStatusSnapshot> = null;
|
||||
let infonetNodeStatusCache: CacheEntry<InfonetNodeStatusSnapshot> = null;
|
||||
|
||||
function loadPrivacyProfile(): Promise<PrivacyProfileSnapshot> {
|
||||
return controlPlaneJson<PrivacyProfileSnapshot>('/api/settings/privacy-profile', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRnsStatus(): Promise<RnsStatusSnapshot> {
|
||||
const data = await controlPlaneJson<Partial<RnsStatusSnapshot>>('/api/mesh/rns/status', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
return {
|
||||
enabled: Boolean(data?.enabled),
|
||||
ready: Boolean(data?.ready),
|
||||
configured_peers: Number(data?.configured_peers || 0),
|
||||
active_peers: Number(data?.active_peers || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function loadInfonetNodeStatus(): Promise<InfonetNodeStatusSnapshot> {
|
||||
return controlPlaneJson<InfonetNodeStatusSnapshot>('/api/mesh/infonet/status', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveCached<T>(
|
||||
cache: CacheEntry<T>,
|
||||
loader: () => Promise<T>,
|
||||
setCache: (value: CacheEntry<T>) => void,
|
||||
force: boolean,
|
||||
): Promise<T> {
|
||||
const now = Date.now();
|
||||
if (!force && cache?.value && cache.expiresAt > now) {
|
||||
return cache.value;
|
||||
}
|
||||
if (!force && cache?.inflight) {
|
||||
return cache.inflight;
|
||||
}
|
||||
const inflight = loader()
|
||||
.then((value) => {
|
||||
setCache({
|
||||
value,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
});
|
||||
return value;
|
||||
})
|
||||
.catch((error) => {
|
||||
setCache(cache ? { ...cache, inflight: null } : null);
|
||||
throw error;
|
||||
});
|
||||
setCache({
|
||||
value: cache?.value as T,
|
||||
expiresAt: 0,
|
||||
inflight,
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function invalidatePrivacyProfileCache(): void {
|
||||
privacyProfileCache = null;
|
||||
}
|
||||
|
||||
export function invalidateRnsStatusCache(): void {
|
||||
rnsStatusCache = null;
|
||||
}
|
||||
|
||||
export function invalidateInfonetNodeStatusCache(): void {
|
||||
infonetNodeStatusCache = null;
|
||||
}
|
||||
|
||||
export async function fetchPrivacyProfileSnapshot(
|
||||
force: boolean = false,
|
||||
): Promise<PrivacyProfileSnapshot> {
|
||||
return resolveCached(
|
||||
privacyProfileCache,
|
||||
loadPrivacyProfile,
|
||||
(value) => {
|
||||
privacyProfileCache = value;
|
||||
},
|
||||
force,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRnsStatusSnapshot(force: boolean = false): Promise<RnsStatusSnapshot> {
|
||||
return resolveCached(
|
||||
rnsStatusCache,
|
||||
loadRnsStatus,
|
||||
(value) => {
|
||||
rnsStatusCache = value;
|
||||
},
|
||||
force,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchInfonetNodeStatusSnapshot(
|
||||
force: boolean = false,
|
||||
): Promise<InfonetNodeStatusSnapshot> {
|
||||
return resolveCached(
|
||||
infonetNodeStatusCache,
|
||||
loadInfonetNodeStatus,
|
||||
(value) => {
|
||||
infonetNodeStatusCache = value;
|
||||
},
|
||||
force,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface GateEnvelopeMessageLike {
|
||||
event_type?: string;
|
||||
gate?: string;
|
||||
message?: string;
|
||||
ciphertext?: string;
|
||||
epoch?: number;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
ciphertext?: string;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GateEnvelopeState = 'plaintext' | 'decrypted' | 'locked';
|
||||
|
||||
function _field(message: GateEnvelopeMessageLike, key: 'gate' | 'ciphertext' | 'nonce' | 'sender_ref' | 'format'): string {
|
||||
const payload = message.payload;
|
||||
const nested = payload && typeof payload === 'object' ? payload[key] : '';
|
||||
const direct = message[key];
|
||||
return String((direct ?? nested ?? '') || '');
|
||||
}
|
||||
|
||||
export function isEncryptedGateEnvelope(message: GateEnvelopeMessageLike): boolean {
|
||||
return (
|
||||
String(message.event_type ?? '') === 'gate_message' &&
|
||||
!!_field(message, 'ciphertext').trim() &&
|
||||
(_field(message, 'format') || 'mls1') === 'mls1'
|
||||
);
|
||||
}
|
||||
|
||||
export function gateEnvelopeDisplayText(message: GateEnvelopeMessageLike): string {
|
||||
if (!isEncryptedGateEnvelope(message)) {
|
||||
return String(message.message ?? '');
|
||||
}
|
||||
const decrypted = String(message.decrypted_message ?? '').trim();
|
||||
return decrypted || 'ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE';
|
||||
}
|
||||
|
||||
export function gateEnvelopeState(message: GateEnvelopeMessageLike): GateEnvelopeState {
|
||||
if (!isEncryptedGateEnvelope(message)) {
|
||||
return 'plaintext';
|
||||
}
|
||||
return String(message.decrypted_message ?? '').trim() ? 'decrypted' : 'locked';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { currentMailboxEpoch } from '@/mesh/meshMailbox';
|
||||
import { allDmPeerIds } from '@/mesh/meshDmConsent';
|
||||
import { deriveSharedSecret, getStoredNodeDescriptor, type Contact } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
deriveWormholeDeadDropTokenPair,
|
||||
deriveWormholeDeadDropTokens,
|
||||
isWormholeReady,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
function bufToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function hmacSha256(keyBytes: ArrayBuffer, message: string): Promise<ArrayBuffer> {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign',
|
||||
]);
|
||||
const data = new TextEncoder().encode(message);
|
||||
return crypto.subtle.sign('HMAC', key, data);
|
||||
}
|
||||
|
||||
function contactContext(peerId: string): string | null {
|
||||
const identity = getStoredNodeDescriptor();
|
||||
if (!identity) return null;
|
||||
const ids = [identity.nodeId, peerId].sort().join('|');
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function deadDropToken(peerId: string, peerDhPub: string, epoch?: number): Promise<string> {
|
||||
if (await isWormholeReady()) {
|
||||
const pair = await deriveWormholeDeadDropTokenPair(peerId, peerDhPub).catch(() => null);
|
||||
if (pair?.ok) {
|
||||
return epoch === pair.epoch - 1 ? pair.previous : pair.current;
|
||||
}
|
||||
}
|
||||
const ctx = contactContext(peerId);
|
||||
if (!ctx) return '';
|
||||
const bucket = typeof epoch === 'number' ? epoch : currentMailboxEpoch();
|
||||
const secret = await deriveSharedSecret(peerDhPub);
|
||||
const digest = await hmacSha256(secret, `sb_dd|v1|${bucket}|${ctx}`);
|
||||
return bufToHex(digest);
|
||||
}
|
||||
|
||||
export async function deadDropTokenPair(
|
||||
peerId: string,
|
||||
peerDhPub: string,
|
||||
): Promise<{ current: string; previous: string; epoch: number }> {
|
||||
const epoch = currentMailboxEpoch();
|
||||
const current = await deadDropToken(peerId, peerDhPub, epoch);
|
||||
const previous = await deadDropToken(peerId, peerDhPub, epoch - 1);
|
||||
return { current, previous, epoch };
|
||||
}
|
||||
|
||||
export async function deadDropTokensForContacts(
|
||||
contacts: Record<string, Contact>,
|
||||
limit: number = 24,
|
||||
): Promise<string[]> {
|
||||
if (await isWormholeReady()) {
|
||||
const items = Object.entries(contacts)
|
||||
.filter(([_, contact]) => Boolean(contact?.dhPubKey) && !contact.blocked)
|
||||
.flatMap(([peerId, contact]) =>
|
||||
allDmPeerIds(peerId, contact).map((candidateId) => ({
|
||||
peer_id: candidateId,
|
||||
peer_dh_pub: String(contact?.dhPubKey || ''),
|
||||
})),
|
||||
)
|
||||
.slice(0, limit);
|
||||
if (items.length > 0) {
|
||||
const batch = await deriveWormholeDeadDropTokens(items, limit).catch(() => null);
|
||||
if (batch?.ok && Array.isArray(batch.tokens)) {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const item of batch.tokens) {
|
||||
for (const token of [String(item.current || ''), String(item.previous || '')]) {
|
||||
if (!token || seen.has(token)) continue;
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
if (unique.length >= limit) return unique;
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
for (const [peerId, contact] of Object.entries(contacts)) {
|
||||
if (!contact?.dhPubKey || contact.blocked) continue;
|
||||
for (const candidateId of allDmPeerIds(peerId, contact)) {
|
||||
try {
|
||||
const pair = await deadDropTokenPair(candidateId, contact.dhPubKey);
|
||||
if (pair.current) tokens.push(pair.current);
|
||||
if (pair.previous) tokens.push(pair.previous);
|
||||
if (tokens.length >= limit) break;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (tokens.length >= limit) break;
|
||||
}
|
||||
// dedupe while preserving order
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const token of tokens) {
|
||||
if (seen.has(token)) continue;
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
if (unique.length >= limit) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import {
|
||||
readWorkerRatchetStates,
|
||||
writeWorkerRatchetStates,
|
||||
type WorkerRatchetState as RatchetState,
|
||||
} from './meshDmWorkerVault';
|
||||
|
||||
const MAX_SKIP = 32;
|
||||
const PAD_BUCKET = 1024;
|
||||
const PAD_STEP = 512;
|
||||
const PAD_MAX = 4096;
|
||||
const PAD_MAGIC = 'SBP1';
|
||||
|
||||
const KEYSTORE_DB = 'sb_mesh_keystore';
|
||||
const KEYSTORE_STORE = 'keys';
|
||||
const KEY_DH_PRIV_IDB = 'sb_mesh_dh_priv';
|
||||
|
||||
let stateCache: Record<string, RatchetState> | null = null;
|
||||
let stateLoadPromise: Promise<Record<string, RatchetState>> | null = null;
|
||||
let lastOp: Promise<void> = Promise.resolve();
|
||||
|
||||
type WorkerRequest = {
|
||||
id: string;
|
||||
action: 'encrypt' | 'decrypt' | 'reset';
|
||||
peerId?: string;
|
||||
peerDhPub?: string;
|
||||
plaintext?: string;
|
||||
ciphertext?: string;
|
||||
dhAlgo?: string;
|
||||
};
|
||||
|
||||
function bufToBase64(buf: ArrayBufferLike): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function base64ToBuf(b64: string): ArrayBuffer {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function utf8ToBuf(text: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(text).buffer;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function ensureArrayBuf(value?: string): ArrayBuffer {
|
||||
if (!value) return new Uint8Array(32).buffer;
|
||||
return base64ToBuf(value);
|
||||
}
|
||||
|
||||
function headerAad(header: Record<string, unknown>): Uint8Array {
|
||||
return new TextEncoder().encode(stableStringify(header));
|
||||
}
|
||||
|
||||
function buildPaddedPayload(plaintext: string): Uint8Array {
|
||||
const data = new TextEncoder().encode(plaintext);
|
||||
const len = data.length;
|
||||
let target = PAD_BUCKET;
|
||||
if (len + 6 > target) {
|
||||
target = Math.ceil((len + 6) / PAD_STEP) * PAD_STEP;
|
||||
}
|
||||
if (target > PAD_MAX) {
|
||||
const raw = new Uint8Array(len);
|
||||
raw.set(data, 0);
|
||||
return raw;
|
||||
}
|
||||
const out = new Uint8Array(target);
|
||||
out.set(new TextEncoder().encode(PAD_MAGIC), 0);
|
||||
out[4] = (len >> 8) & 0xff;
|
||||
out[5] = len & 0xff;
|
||||
out.set(data, 6);
|
||||
if (target > len + 6) {
|
||||
crypto.getRandomValues(out.subarray(6 + len));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function unpadPayload(data: Uint8Array): string {
|
||||
if (data.length < 6) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
const magic = new TextDecoder().decode(data.slice(0, 4));
|
||||
if (magic !== PAD_MAGIC) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
const len = (data[4] << 8) + data[5];
|
||||
if (len <= 0 || 6 + len > data.length) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
return new TextDecoder().decode(data.slice(6, 6 + len));
|
||||
}
|
||||
|
||||
function openDb(name: string, store: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(name, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAllStates(): Promise<Record<string, RatchetState>> {
|
||||
if (stateCache) return stateCache;
|
||||
if (!stateLoadPromise) {
|
||||
stateLoadPromise = (async () => {
|
||||
try {
|
||||
stateCache = (await readWorkerRatchetStates()) || {};
|
||||
return stateCache;
|
||||
} catch {
|
||||
stateCache = {};
|
||||
return stateCache;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return stateLoadPromise;
|
||||
}
|
||||
|
||||
async function saveAllStates(states: Record<string, RatchetState>): Promise<void> {
|
||||
stateCache = states;
|
||||
stateLoadPromise = Promise.resolve(states);
|
||||
try {
|
||||
await writeWorkerRatchetStates(states);
|
||||
} catch (err) {
|
||||
console.warn('[mesh] worker ratchet vault unavailable — state kept in memory only, not persisted', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getState(peerId: string): Promise<RatchetState | null> {
|
||||
const all = await loadAllStates();
|
||||
return all[peerId] || null;
|
||||
}
|
||||
|
||||
async function setState(peerId: string, state: RatchetState): Promise<void> {
|
||||
const all = await loadAllStates();
|
||||
all[peerId] = state;
|
||||
await saveAllStates(all);
|
||||
}
|
||||
|
||||
async function clearState(peerId?: string): Promise<void> {
|
||||
if (!peerId) {
|
||||
await saveAllStates({});
|
||||
return;
|
||||
}
|
||||
const all = await loadAllStates();
|
||||
if (all[peerId]) {
|
||||
delete all[peerId];
|
||||
await saveAllStates(all);
|
||||
}
|
||||
}
|
||||
|
||||
async function getLongTermDhPrivKey(): Promise<CryptoKey | null> {
|
||||
try {
|
||||
const db = await openDb(KEYSTORE_DB, KEYSTORE_STORE);
|
||||
const tx = db.transaction(KEYSTORE_STORE, 'readonly');
|
||||
const store = tx.objectStore(KEYSTORE_STORE);
|
||||
const req = store.get(KEY_DH_PRIV_IDB);
|
||||
const key = await new Promise<CryptoKey | null>((resolve) => {
|
||||
req.onsuccess = () => resolve((req.result as CryptoKey) || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
db.close();
|
||||
return key;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRatchetKeyPair(algoHint?: string): Promise<{ pub: string; priv: string; algo: string }> {
|
||||
let keyPair: CryptoKeyPair;
|
||||
let algo = (algoHint || '').toUpperCase();
|
||||
try {
|
||||
keyPair = (await crypto.subtle.generateKey('X25519', true, ['deriveBits'])) as CryptoKeyPair;
|
||||
algo = 'X25519';
|
||||
} catch {
|
||||
keyPair = (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
|
||||
'deriveBits',
|
||||
])) as CryptoKeyPair;
|
||||
algo = 'ECDH';
|
||||
}
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
return { pub: bufToBase64(pubRaw), priv: JSON.stringify(privJwk), algo };
|
||||
}
|
||||
|
||||
async function deriveDhSecret(
|
||||
algo: string,
|
||||
privJwkStr: string,
|
||||
theirPubB64: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const algoNorm = (algo || '').toUpperCase();
|
||||
const privJwk = JSON.parse(privJwkStr || '{}');
|
||||
const theirPubRaw = base64ToBuf(theirPubB64);
|
||||
if (algoNorm === 'X25519') {
|
||||
const privKey = await crypto.subtle.importKey('jwk', privJwk, 'X25519', false, ['deriveBits']);
|
||||
const pubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []);
|
||||
return crypto.subtle.deriveBits({ name: 'X25519', public: pubKey }, privKey, 256);
|
||||
}
|
||||
const privKey = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
privJwk,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
theirPubRaw,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
return crypto.subtle.deriveBits({ name: 'ECDH', public: pubKey }, privKey, 256);
|
||||
}
|
||||
|
||||
async function deriveDhSecretWithKey(
|
||||
algo: string,
|
||||
privKey: CryptoKey,
|
||||
theirPubB64: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const algoNorm = (algo || '').toUpperCase();
|
||||
const theirPubRaw = base64ToBuf(theirPubB64);
|
||||
if (algoNorm === 'X25519') {
|
||||
const pubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []);
|
||||
return crypto.subtle.deriveBits({ name: 'X25519', public: pubKey }, privKey, 256);
|
||||
}
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
theirPubRaw,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
return crypto.subtle.deriveBits({ name: 'ECDH', public: pubKey }, privKey, 256);
|
||||
}
|
||||
|
||||
async function hkdf(ikm: ArrayBuffer, salt: ArrayBuffer, info: string, length: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt, info: utf8ToBuf(info) },
|
||||
key,
|
||||
length * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function kdfRK(rk: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rk: Uint8Array; ck: Uint8Array }> {
|
||||
const salt = rk && rk.byteLength ? rk : new Uint8Array(32).buffer;
|
||||
const out = await hkdf(dhOut, salt, 'SB-DR-RK', 64);
|
||||
return { rk: out.slice(0, 32), ck: out.slice(32, 64) };
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: ArrayBufferLike, data: Uint8Array): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes as ArrayBuffer, { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign',
|
||||
]);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, data as BufferSource);
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
async function kdfCK(ck: ArrayBuffer): Promise<{ ck: Uint8Array; mk: Uint8Array }> {
|
||||
const mk = await hmacSha256(ck, new Uint8Array([1]));
|
||||
const next = await hmacSha256(ck, new Uint8Array([2]));
|
||||
return { ck: next, mk };
|
||||
}
|
||||
|
||||
async function aesGcmEncrypt(
|
||||
mk: ArrayBufferLike,
|
||||
plaintext: string,
|
||||
aad?: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', mk as ArrayBuffer, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = buildPaddedPayload(plaintext);
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, additionalData: aad as BufferSource | undefined },
|
||||
key,
|
||||
encoded as BufferSource,
|
||||
);
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return bufToBase64(combined.buffer);
|
||||
}
|
||||
|
||||
async function aesGcmDecrypt(
|
||||
mk: ArrayBufferLike,
|
||||
ciphertextB64: string,
|
||||
aad?: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', mk as ArrayBuffer, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const combined = new Uint8Array(base64ToBuf(ciphertextB64));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const plainBuf = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv, additionalData: aad as BufferSource | undefined },
|
||||
key,
|
||||
ciphertext as BufferSource,
|
||||
);
|
||||
return unpadPayload(new Uint8Array(plainBuf));
|
||||
}
|
||||
|
||||
async function skipMessageKeys(state: RatchetState, until: number): Promise<void> {
|
||||
if (!state.ckr) return;
|
||||
const skipped = state.skipped || {};
|
||||
while (state.nr < until) {
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.ckr));
|
||||
const keyId = `${state.dhRemote}:${state.nr}`;
|
||||
if (Object.keys(skipped).length < MAX_SKIP) {
|
||||
skipped[keyId] = bufToBase64(mk.buffer);
|
||||
}
|
||||
state.ckr = bufToBase64(ck.buffer);
|
||||
state.nr += 1;
|
||||
}
|
||||
state.skipped = skipped;
|
||||
}
|
||||
|
||||
async function dhRatchet(state: RatchetState, remoteDh: string, pn: number): Promise<RatchetState> {
|
||||
await skipMessageKeys(state, pn);
|
||||
state.pn = state.ns;
|
||||
state.ns = 0;
|
||||
state.nr = 0;
|
||||
state.dhRemote = remoteDh;
|
||||
|
||||
const rkBytes = ensureArrayBuf(state.rk);
|
||||
const dhOut1 = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const out1 = await kdfRK(rkBytes, dhOut1);
|
||||
state.rk = bufToBase64(out1.rk.buffer);
|
||||
state.ckr = bufToBase64(out1.ck.buffer);
|
||||
|
||||
const fresh = await generateRatchetKeyPair(state.algo);
|
||||
state.dhSelfPub = fresh.pub;
|
||||
state.dhSelfPriv = fresh.priv;
|
||||
const dhOut2 = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const out2 = await kdfRK(ensureArrayBuf(state.rk), dhOut2);
|
||||
state.rk = bufToBase64(out2.rk.buffer);
|
||||
state.cks = bufToBase64(out2.ck.buffer);
|
||||
state.updated = Date.now();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function initSenderState(peerId: string, theirDhPub: string, algoHint: string): Promise<RatchetState> {
|
||||
const { pub, priv, algo } = await generateRatchetKeyPair(algoHint);
|
||||
const dhOut = await deriveDhSecret(algo, priv, theirDhPub);
|
||||
const { rk, ck } = await kdfRK(new Uint8Array(32).buffer, dhOut);
|
||||
return {
|
||||
algo,
|
||||
rk: bufToBase64(rk.buffer),
|
||||
cks: bufToBase64(ck.buffer),
|
||||
ckr: undefined,
|
||||
dhSelfPub: pub,
|
||||
dhSelfPriv: priv,
|
||||
dhRemote: theirDhPub,
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
skipped: {},
|
||||
updated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function initReceiverState(
|
||||
peerId: string,
|
||||
senderDhPub: string,
|
||||
algoHint: string,
|
||||
): Promise<RatchetState> {
|
||||
const algo = (algoHint || '').toUpperCase() === 'ECDH' ? 'ECDH' : 'X25519';
|
||||
const longTermKey = await getLongTermDhPrivKey();
|
||||
if (!longTermKey) {
|
||||
throw new Error('missing_long_term_key');
|
||||
}
|
||||
const dhOut = await deriveDhSecretWithKey(algo, longTermKey, senderDhPub);
|
||||
const { rk, ck } = await kdfRK(new Uint8Array(32).buffer, dhOut);
|
||||
const { pub, priv } = await generateRatchetKeyPair(algo);
|
||||
return {
|
||||
algo,
|
||||
rk: bufToBase64(rk.buffer),
|
||||
cks: undefined,
|
||||
ckr: bufToBase64(ck.buffer),
|
||||
dhSelfPub: pub,
|
||||
dhSelfPriv: priv,
|
||||
dhRemote: senderDhPub,
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
skipped: {},
|
||||
updated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSendChain(state: RatchetState): Promise<RatchetState> {
|
||||
if (state.cks) return state;
|
||||
const rkBytes = ensureArrayBuf(state.rk);
|
||||
const dhOut = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const { rk, ck } = await kdfRK(rkBytes, dhOut);
|
||||
state.rk = bufToBase64(rk.buffer);
|
||||
state.cks = bufToBase64(ck.buffer);
|
||||
state.updated = Date.now();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function ratchetEncrypt(peerId: string, theirDhPub: string, plaintext: string, algoHint: string): Promise<string> {
|
||||
let state = await getState(peerId);
|
||||
if (!state) {
|
||||
state = await initSenderState(peerId, theirDhPub, algoHint);
|
||||
}
|
||||
state = await ensureSendChain(state);
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.cks!));
|
||||
const n = state.ns;
|
||||
state.ns += 1;
|
||||
state.cks = bufToBase64(ck.buffer);
|
||||
const header = { v: 2, dh: state.dhSelfPub, pn: state.pn, n, alg: state.algo };
|
||||
const ct = await aesGcmEncrypt(mk.buffer, plaintext, headerAad(header));
|
||||
const payload = { h: header, ct };
|
||||
const wrapped = bufToBase64(utf8ToBuf(JSON.stringify(payload)));
|
||||
state.updated = Date.now();
|
||||
await setState(peerId, state);
|
||||
return `dr2:${wrapped}`;
|
||||
}
|
||||
|
||||
async function ratchetDecrypt(peerId: string, ciphertext: string, algoHint: string): Promise<string> {
|
||||
if (!ciphertext.startsWith('dr2:')) {
|
||||
throw new Error('legacy');
|
||||
}
|
||||
const raw = ciphertext.slice(4);
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64ToBuf(raw)));
|
||||
const header = payload.h || {};
|
||||
const ct = String(payload.ct || '');
|
||||
const remoteDh = String(header.dh || '');
|
||||
const pn = Number(header.pn || 0);
|
||||
const n = Number(header.n || 0);
|
||||
const alg = String(header.alg || algoHint || 'X25519');
|
||||
|
||||
let state = await getState(peerId);
|
||||
if (!state) {
|
||||
state = await initReceiverState(peerId, remoteDh, alg);
|
||||
}
|
||||
|
||||
if (remoteDh && remoteDh !== state.dhRemote) {
|
||||
state = await dhRatchet(state, remoteDh, pn);
|
||||
}
|
||||
|
||||
const skipped = state.skipped || {};
|
||||
const skipKey = `${remoteDh}:${n}`;
|
||||
if (skipped[skipKey]) {
|
||||
const mk = base64ToBuf(skipped[skipKey]);
|
||||
delete skipped[skipKey];
|
||||
state.skipped = skipped;
|
||||
await setState(peerId, state);
|
||||
return aesGcmDecrypt(mk, ct, headerAad(header));
|
||||
}
|
||||
|
||||
await skipMessageKeys(state, n);
|
||||
if (!state.ckr) throw new Error('no_receive_chain');
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.ckr));
|
||||
state.ckr = bufToBase64(ck.buffer);
|
||||
state.nr += 1;
|
||||
state.updated = Date.now();
|
||||
await setState(peerId, state);
|
||||
return aesGcmDecrypt(mk.buffer, ct, headerAad(header));
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
const data = event.data as WorkerRequest;
|
||||
const { id, action } = data || {};
|
||||
if (!id || !action) return;
|
||||
lastOp = lastOp.then(async () => {
|
||||
try {
|
||||
if (action === 'encrypt') {
|
||||
const result = await ratchetEncrypt(
|
||||
String(data.peerId || ''),
|
||||
String(data.peerDhPub || ''),
|
||||
String(data.plaintext || ''),
|
||||
String(data.dhAlgo || 'X25519'),
|
||||
);
|
||||
postMessage({ id, ok: true, result });
|
||||
return;
|
||||
}
|
||||
if (action === 'decrypt') {
|
||||
const result = await ratchetDecrypt(
|
||||
String(data.peerId || ''),
|
||||
String(data.ciphertext || ''),
|
||||
String(data.dhAlgo || 'X25519'),
|
||||
);
|
||||
postMessage({ id, ok: true, result });
|
||||
return;
|
||||
}
|
||||
if (action === 'reset') {
|
||||
const peerId = String(data.peerId || '');
|
||||
await clearState(peerId || undefined);
|
||||
postMessage({ id, ok: true, result: '' });
|
||||
return;
|
||||
}
|
||||
postMessage({ id, ok: false, error: 'unsupported_action' });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String((err as { message?: string })?.message || err);
|
||||
postMessage({ id, ok: false, error: message || 'worker_error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.onmessage = handleMessage;
|
||||
@@ -0,0 +1,670 @@
|
||||
import { deadDropToken, deadDropTokensForContacts } from '@/mesh/meshDeadDrop';
|
||||
import { mailboxClaimToken, mailboxDecoySharedToken } from '@/mesh/meshMailbox';
|
||||
import {
|
||||
deriveSenderSealKey,
|
||||
ensureDhKeysFresh,
|
||||
deriveSharedKey,
|
||||
encryptDM,
|
||||
getDHAlgo,
|
||||
getNodeIdentity,
|
||||
getPublicKeyAlgo,
|
||||
nextSequence,
|
||||
verifyNodeIdBindingFromPublicKey,
|
||||
type Contact,
|
||||
type NodeIdentity,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import {
|
||||
buildWormholeSenderSeal,
|
||||
getActiveSigningContext,
|
||||
isWormholeSecureRequired,
|
||||
issueWormholeDmSenderToken,
|
||||
issueWormholeDmSenderTokens,
|
||||
registerWormholeDmKey,
|
||||
signRawMeshMessage,
|
||||
signMeshEvent,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { PROTOCOL_VERSION, type JsonValue } from '@/mesh/meshProtocol';
|
||||
import {
|
||||
ensureCanonicalRequestV2SenderSeal,
|
||||
requiresCanonicalRequestV2SenderSeal,
|
||||
} from '@/mesh/requestSenderSealPolicy';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
export type MailboxClaim = { type: 'self' | 'requests' | 'shared'; token?: string };
|
||||
|
||||
export type DmPublicKeyBundle = {
|
||||
ok: boolean;
|
||||
agent_id: string;
|
||||
dh_pub_key: string;
|
||||
dh_algo?: string;
|
||||
timestamp?: number;
|
||||
signature?: string;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
protocol_version?: string;
|
||||
sequence?: number;
|
||||
bundle_fingerprint?: string;
|
||||
};
|
||||
|
||||
export type DmMessageEnvelope = {
|
||||
sender_id: string;
|
||||
ciphertext: string;
|
||||
timestamp: number;
|
||||
msg_id: string;
|
||||
delivery_class?: 'request' | 'shared';
|
||||
sender_seal?: string;
|
||||
transport?: 'reticulum' | 'relay';
|
||||
request_contract_version?: 'request-v2-reduced-v3';
|
||||
sender_recovery_required?: boolean;
|
||||
sender_recovery_state?: 'pending' | 'verified' | 'failed';
|
||||
};
|
||||
|
||||
export type DmPollResponse = {
|
||||
ok: boolean;
|
||||
messages: DmMessageEnvelope[];
|
||||
count: number;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type DmCountResponse = {
|
||||
ok: boolean;
|
||||
count: number;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type DmSendResponse = {
|
||||
ok: boolean;
|
||||
msg_id?: string;
|
||||
detail?: string;
|
||||
transport?: 'reticulum' | 'relay';
|
||||
};
|
||||
|
||||
export type DmSendRequest = {
|
||||
apiBase: string;
|
||||
identity: NodeIdentity;
|
||||
recipientId: string;
|
||||
recipientDhPub?: string;
|
||||
ciphertext: string;
|
||||
msgId: string;
|
||||
timestamp: number;
|
||||
deliveryClass: 'request' | 'shared';
|
||||
recipientToken?: string;
|
||||
useSealedSender?: boolean;
|
||||
format?: 'mls1' | 'dm1';
|
||||
sessionWelcome?: string;
|
||||
};
|
||||
|
||||
const KEY_DM_BUNDLE_FINGERPRINT = 'sb_dm_bundle_fingerprint';
|
||||
const KEY_DM_BUNDLE_SEQUENCE = 'sb_dm_bundle_sequence';
|
||||
const MIN_SHARED_MAILBOX_CLAIMS = 3;
|
||||
const MAX_TOTAL_MAILBOX_CLAIMS = 32;
|
||||
const FIXED_MAILBOX_CLAIMS = 2;
|
||||
const MAX_SHARED_MAILBOX_CLAIMS = MAX_TOTAL_MAILBOX_CLAIMS - FIXED_MAILBOX_CLAIMS;
|
||||
const MAILBOX_SHARED_CLAIM_BUCKETS = [3, 6, 12, 24, 30] as const;
|
||||
const MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED =
|
||||
process.env.NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE === '1';
|
||||
export const MAILBOX_SHARED_CLAIM_SHAPE_VERSION = MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED
|
||||
? 'rfc2a-bucketed-v1'
|
||||
: 'legacy-floor-v1';
|
||||
const senderTokenCache = new Map<string, Array<{ sender_token: string; expires_at: number }>>();
|
||||
let bundleFingerprintCache = '';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
||||
localStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
||||
sessionStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
||||
sessionStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function randomHex(bytes: number = 12): string {
|
||||
const arr = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return Array.from(arr)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
async function createSenderSealV3(
|
||||
recipientId: string,
|
||||
recipientDhPub: string,
|
||||
msgId: string,
|
||||
timestamp: number,
|
||||
): Promise<string> {
|
||||
const ephemeral = (await crypto.subtle.generateKey('X25519', true, ['deriveBits'])) as CryptoKeyPair;
|
||||
const recipientPub = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
Uint8Array.from(atob(recipientDhPub), (ch) => ch.charCodeAt(0)),
|
||||
'X25519',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const secret = await crypto.subtle.deriveBits(
|
||||
{ name: 'X25519', public: recipientPub },
|
||||
ephemeral.privateKey,
|
||||
256,
|
||||
);
|
||||
const ephemeralPubRaw = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
||||
const ephemeralPub = bufToBase64(ephemeralPubRaw);
|
||||
const salt = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(
|
||||
`SB-SEAL-SALT|${recipientId}|${msgId}|${PROTOCOL_VERSION}|${ephemeralPub}`,
|
||||
),
|
||||
);
|
||||
const hkdfKey = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey']);
|
||||
const sealKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt,
|
||||
info: new TextEncoder().encode('SB-SENDER-SEAL-V3'),
|
||||
},
|
||||
hkdfKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const sealMessage = `seal|v3|${msgId}|${timestamp}|${recipientId}|${ephemeralPub}`;
|
||||
const signed = await signRawMeshMessage(sealMessage);
|
||||
const isBound = await verifyNodeIdBindingFromPublicKey(
|
||||
signed.context.publicKey,
|
||||
signed.context.nodeId,
|
||||
);
|
||||
if (!isBound) {
|
||||
throw new Error('Sender seal node binding failed');
|
||||
}
|
||||
const encrypted = await encryptDM(
|
||||
JSON.stringify({
|
||||
seal_version: 'v3',
|
||||
ephemeral_pub_key: ephemeralPub,
|
||||
sender_id: signed.context.nodeId,
|
||||
public_key: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
msg_id: msgId,
|
||||
timestamp,
|
||||
signature: signed.signature,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
sealKey,
|
||||
);
|
||||
return `v3:${ephemeralPub}:${encrypted}`;
|
||||
}
|
||||
|
||||
function setStoredBundleFingerprint(fingerprint: string, sequence: number): void {
|
||||
bundleFingerprintCache = String(fingerprint || '');
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
||||
localStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
||||
sessionStorage.removeItem(KEY_DM_BUNDLE_FINGERPRINT);
|
||||
sessionStorage.removeItem(KEY_DM_BUNDLE_SEQUENCE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredBundleFingerprint(): string {
|
||||
return bundleFingerprintCache;
|
||||
}
|
||||
|
||||
function senderTokenCacheKey(
|
||||
recipientId: string,
|
||||
deliveryClass: 'request' | 'shared',
|
||||
recipientToken?: string,
|
||||
): string {
|
||||
return `${deliveryClass}|${recipientId}|${recipientToken || ''}`;
|
||||
}
|
||||
|
||||
function takeCachedSenderToken(
|
||||
recipientId: string,
|
||||
deliveryClass: 'request' | 'shared',
|
||||
recipientToken?: string,
|
||||
): string {
|
||||
const key = senderTokenCacheKey(recipientId, deliveryClass, recipientToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const existing = (senderTokenCache.get(key) || []).filter((item) => item.expires_at > now + 10);
|
||||
senderTokenCache.set(key, existing);
|
||||
const next = existing.shift();
|
||||
senderTokenCache.set(key, existing);
|
||||
return String(next?.sender_token || '');
|
||||
}
|
||||
|
||||
async function replenishSenderTokenCache(
|
||||
recipientId: string,
|
||||
deliveryClass: 'request' | 'shared',
|
||||
recipientToken?: string,
|
||||
): Promise<string> {
|
||||
const key = senderTokenCacheKey(recipientId, deliveryClass, recipientToken);
|
||||
try {
|
||||
const batch = await issueWormholeDmSenderTokens(
|
||||
recipientId,
|
||||
deliveryClass,
|
||||
recipientToken,
|
||||
3,
|
||||
);
|
||||
const tokens = Array.isArray(batch.tokens) ? batch.tokens : [];
|
||||
senderTokenCache.set(
|
||||
key,
|
||||
tokens
|
||||
.map((item) => ({
|
||||
sender_token: String(item.sender_token || ''),
|
||||
expires_at: Number(item.expires_at || 0),
|
||||
}))
|
||||
.filter((item) => item.sender_token && item.expires_at > 0),
|
||||
);
|
||||
} catch {
|
||||
senderTokenCache.delete(key);
|
||||
}
|
||||
return takeCachedSenderToken(recipientId, deliveryClass, recipientToken);
|
||||
}
|
||||
|
||||
async function localBundleFingerprint(identity: NodeIdentity, dhPubKey: string, dhAlgo: string): Promise<string> {
|
||||
const raw = [
|
||||
dhPubKey,
|
||||
dhAlgo,
|
||||
identity.publicKey,
|
||||
getPublicKeyAlgo(),
|
||||
PROTOCOL_VERSION,
|
||||
].join('|');
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function ensureRegisteredDmKey(
|
||||
apiBase: string,
|
||||
identity: NodeIdentity,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<{ ok: boolean; dhPubKey?: string; dhAlgo?: string; acceptedSequence?: number; bundleFingerprint?: string; detail?: string }> {
|
||||
const signingContext = await getActiveSigningContext();
|
||||
if (signingContext?.source === 'wormhole') {
|
||||
try {
|
||||
const data = await registerWormholeDmKey();
|
||||
if (data.ok) {
|
||||
if (data.bundle_fingerprint && data.bundle_sequence) {
|
||||
setStoredBundleFingerprint(data.bundle_fingerprint, data.bundle_sequence);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
dhPubKey: data.dh_pub_key,
|
||||
dhAlgo: data.dh_algo,
|
||||
acceptedSequence: Number(data.bundle_sequence || data.sequence || 0),
|
||||
bundleFingerprint: data.bundle_fingerprint,
|
||||
};
|
||||
}
|
||||
return { ok: false, detail: data.detail || 'Failed to register Wormhole DM key' };
|
||||
} catch {
|
||||
if (await isWormholeSecureRequired()) {
|
||||
return { ok: false, detail: 'Wormhole DM key registration required in secure mode' };
|
||||
}
|
||||
const localIdentity = getNodeIdentity();
|
||||
if (!localIdentity) {
|
||||
return { ok: false, detail: 'Wormhole DM key registration failed' };
|
||||
}
|
||||
identity = localIdentity;
|
||||
}
|
||||
}
|
||||
|
||||
const { pub: dhPubKey, rotated } = await ensureDhKeysFresh();
|
||||
if (!dhPubKey) return { ok: false, detail: 'Missing DH public key' };
|
||||
const dhAlgo = getDHAlgo();
|
||||
const fingerprint = await localBundleFingerprint(identity, dhPubKey, dhAlgo);
|
||||
if (!opts?.force && !rotated && fingerprint === getStoredBundleFingerprint()) {
|
||||
return { ok: true, dhPubKey, dhAlgo };
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const payload = { dh_pub_key: dhPubKey, dh_algo: dhAlgo, timestamp };
|
||||
const valid = validateEventPayload('dm_key', payload as Record<string, JsonValue>);
|
||||
if (!valid.ok) return { ok: false, detail: valid.reason };
|
||||
const sequence = nextSequence();
|
||||
const signed = await signMeshEvent('dm_key', payload, sequence);
|
||||
const res = await fetch(`${apiBase}/api/mesh/dm/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_id: signed.context.nodeId,
|
||||
dh_pub_key: dhPubKey,
|
||||
dh_algo: dhAlgo,
|
||||
timestamp,
|
||||
public_key: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
signature: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setStoredBundleFingerprint(data.bundle_fingerprint || fingerprint, data.accepted_sequence || sequence);
|
||||
}
|
||||
return {
|
||||
ok: Boolean(data.ok),
|
||||
dhPubKey,
|
||||
dhAlgo,
|
||||
acceptedSequence: Number(data.accepted_sequence || sequence),
|
||||
bundleFingerprint: String(data.bundle_fingerprint || fingerprint),
|
||||
detail: data.detail,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDmPublicKey(
|
||||
apiBase: string,
|
||||
agentId: string,
|
||||
): Promise<DmPublicKeyBundle | null> {
|
||||
const res = await fetch(`${apiBase}/api/mesh/dm/pubkey?agent_id=${encodeURIComponent(agentId)}`);
|
||||
const data = await res.json();
|
||||
return data.ok ? data : null;
|
||||
}
|
||||
|
||||
function spreadClaimPositions(totalClaims: number, spreadClaims: number): Set<number> {
|
||||
if (spreadClaims <= 0) return new Set();
|
||||
const positions = new Set<number>();
|
||||
for (let index = 0; index < spreadClaims; index += 1) {
|
||||
const position = Math.floor(((index + 0.5) * totalClaims) / spreadClaims);
|
||||
positions.add(Math.min(totalClaims - 1, position));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function interleaveSharedClaims(realTokens: string[], decoyTokens: string[]): MailboxClaim[] {
|
||||
const totalClaims = realTokens.length + decoyTokens.length;
|
||||
if (!totalClaims) return [];
|
||||
const spreadRealTokens = realTokens.length > 0 && realTokens.length <= decoyTokens.length;
|
||||
const spreadTokens = spreadRealTokens ? realTokens : decoyTokens;
|
||||
const fillTokens = spreadRealTokens ? decoyTokens : realTokens;
|
||||
const spreadTokenPositions = spreadClaimPositions(totalClaims, spreadTokens.length);
|
||||
const claims: MailboxClaim[] = [];
|
||||
let spreadIndex = 0;
|
||||
let fillIndex = 0;
|
||||
for (let slot = 0; slot < totalClaims; slot += 1) {
|
||||
if (spreadTokenPositions.has(slot) && spreadIndex < spreadTokens.length) {
|
||||
claims.push({ type: 'shared', token: spreadTokens[spreadIndex] });
|
||||
spreadIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (fillIndex < fillTokens.length) {
|
||||
claims.push({ type: 'shared', token: fillTokens[fillIndex] });
|
||||
fillIndex += 1;
|
||||
continue;
|
||||
}
|
||||
claims.push({ type: 'shared', token: spreadTokens[spreadIndex] });
|
||||
spreadIndex += 1;
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function buildLegacySharedMailboxClaims(sharedTokens: string[]): Promise<MailboxClaim[]> {
|
||||
const claims = sharedTokens.map((token) => ({ type: 'shared' as const, token }));
|
||||
for (let index = sharedTokens.length; index < MIN_SHARED_MAILBOX_CLAIMS; index += 1) {
|
||||
claims.push({ type: 'shared', token: await mailboxDecoySharedToken(index) });
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
function sharedClaimBucketSize(realSharedClaims: number): number | null {
|
||||
if (!MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
if (realSharedClaims > MAX_SHARED_MAILBOX_CLAIMS) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
MAILBOX_SHARED_CLAIM_BUCKETS.find((bucketSize) => realSharedClaims <= bucketSize) ??
|
||||
MAX_SHARED_MAILBOX_CLAIMS
|
||||
);
|
||||
}
|
||||
|
||||
async function buildBucketedSharedMailboxClaims(sharedTokens: string[]): Promise<MailboxClaim[]> {
|
||||
const bucketSize = sharedClaimBucketSize(sharedTokens.length);
|
||||
if (bucketSize == null) {
|
||||
return buildLegacySharedMailboxClaims(sharedTokens);
|
||||
}
|
||||
const decoyTokens: string[] = [];
|
||||
for (let index = 0; index < bucketSize - sharedTokens.length; index += 1) {
|
||||
decoyTokens.push(await mailboxDecoySharedToken(index));
|
||||
}
|
||||
return interleaveSharedClaims(sharedTokens, decoyTokens);
|
||||
}
|
||||
|
||||
export async function buildMailboxClaims(contacts: Record<string, Contact>): Promise<MailboxClaim[]> {
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity?.nodeId) {
|
||||
throw new Error('No local identity available for mailbox claims');
|
||||
}
|
||||
const claims: MailboxClaim[] = [
|
||||
{
|
||||
type: 'self',
|
||||
token: await mailboxClaimToken('self', identity.nodeId),
|
||||
},
|
||||
{
|
||||
type: 'requests',
|
||||
token: await mailboxClaimToken('requests', identity.nodeId),
|
||||
},
|
||||
];
|
||||
const sharedTokens = Array.from(new Set((await deadDropTokensForContacts(contacts)).filter(Boolean)));
|
||||
claims.push(...(await buildBucketedSharedMailboxClaims(sharedTokens)));
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function signedMailboxRequest(
|
||||
apiBase: string,
|
||||
identity: NodeIdentity,
|
||||
eventType: 'dm_poll' | 'dm_count',
|
||||
mailboxClaims: MailboxClaim[],
|
||||
) {
|
||||
const payload = {
|
||||
mailbox_claims: mailboxClaims.map((claim) => ({
|
||||
type: claim.type,
|
||||
token: claim.token || '',
|
||||
})),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
nonce: randomHex(16),
|
||||
};
|
||||
const valid = validateEventPayload(eventType, payload as Record<string, JsonValue>);
|
||||
if (!valid.ok) {
|
||||
throw new Error(valid.reason);
|
||||
}
|
||||
const sequence = nextSequence();
|
||||
const signed = await signMeshEvent(eventType, payload, sequence);
|
||||
const res = await fetch(`${apiBase}/api/mesh/dm/${eventType === 'dm_poll' ? 'poll' : 'count'}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_id: signed.context.nodeId,
|
||||
mailbox_claims: payload.mailbox_claims,
|
||||
timestamp: payload.timestamp,
|
||||
nonce: payload.nonce,
|
||||
public_key: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
signature: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function pollDmMailboxes(
|
||||
apiBase: string,
|
||||
identity: NodeIdentity,
|
||||
mailboxClaims: MailboxClaim[],
|
||||
): Promise<DmPollResponse> {
|
||||
return signedMailboxRequest(apiBase, identity, 'dm_poll', mailboxClaims);
|
||||
}
|
||||
|
||||
export async function countDmMailboxes(
|
||||
apiBase: string,
|
||||
identity: NodeIdentity,
|
||||
mailboxClaims: MailboxClaim[],
|
||||
): Promise<DmCountResponse> {
|
||||
return signedMailboxRequest(apiBase, identity, 'dm_count', mailboxClaims);
|
||||
}
|
||||
|
||||
export async function buildSenderSeal(
|
||||
recipientId: string,
|
||||
recipientDhPub: string,
|
||||
msgId: string,
|
||||
timestamp: number,
|
||||
): Promise<string> {
|
||||
const signingContext = await getActiveSigningContext();
|
||||
if (signingContext?.source === 'wormhole') {
|
||||
const built = await buildWormholeSenderSeal(recipientId, recipientDhPub, msgId, timestamp);
|
||||
if (!built?.ok || !built.sender_seal) {
|
||||
throw new Error('wormhole_sender_seal_failed');
|
||||
}
|
||||
return String(built.sender_seal || '');
|
||||
}
|
||||
try {
|
||||
return await createSenderSealV3(recipientId, recipientDhPub, msgId, timestamp);
|
||||
} catch {
|
||||
const sealMessage = `seal|${msgId}|${timestamp}|${recipientId}`;
|
||||
const signed = await signRawMeshMessage(sealMessage);
|
||||
const isBound = await verifyNodeIdBindingFromPublicKey(
|
||||
signed.context.publicKey,
|
||||
signed.context.nodeId,
|
||||
);
|
||||
if (!isBound) {
|
||||
throw new Error('Sender seal node binding failed');
|
||||
}
|
||||
const sealKey = await deriveSenderSealKey(recipientDhPub, recipientId, msgId);
|
||||
const encrypted = await encryptDM(
|
||||
JSON.stringify({
|
||||
seal_version: 'v2',
|
||||
sender_id: signed.context.nodeId,
|
||||
public_key: signed.context.publicKey,
|
||||
public_key_algo: signed.context.publicKeyAlgo,
|
||||
msg_id: msgId,
|
||||
timestamp,
|
||||
signature: signed.signature,
|
||||
protocol_version: signed.protocolVersion,
|
||||
}),
|
||||
sealKey,
|
||||
);
|
||||
return `v2:${encrypted}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDmMessage(request: DmSendRequest): Promise<DmSendResponse> {
|
||||
const payloadFormat = request.format || 'mls1';
|
||||
let senderSeal = '';
|
||||
let senderToken = '';
|
||||
let relaySalt = '';
|
||||
const requireCanonicalV3Seal = requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: request.deliveryClass,
|
||||
useSealedSender: request.useSealedSender,
|
||||
});
|
||||
if (request.useSealedSender && request.recipientDhPub) {
|
||||
senderSeal = await buildSenderSeal(
|
||||
request.recipientId,
|
||||
request.recipientDhPub,
|
||||
request.msgId,
|
||||
request.timestamp,
|
||||
);
|
||||
if (requireCanonicalV3Seal) {
|
||||
senderSeal = ensureCanonicalRequestV2SenderSeal(senderSeal);
|
||||
}
|
||||
relaySalt = randomHex(16);
|
||||
}
|
||||
|
||||
const dmPayload: Record<string, unknown> = {
|
||||
recipient_id: request.recipientId,
|
||||
delivery_class: request.deliveryClass,
|
||||
recipient_token: request.recipientToken || '',
|
||||
ciphertext: request.ciphertext,
|
||||
msg_id: request.msgId,
|
||||
timestamp: request.timestamp,
|
||||
format: payloadFormat,
|
||||
};
|
||||
if (request.sessionWelcome) {
|
||||
dmPayload.session_welcome = request.sessionWelcome;
|
||||
}
|
||||
if (senderSeal) {
|
||||
dmPayload.sender_seal = senderSeal;
|
||||
}
|
||||
if (relaySalt) {
|
||||
dmPayload.relay_salt = relaySalt;
|
||||
}
|
||||
const valid = validateEventPayload('dm_message', dmPayload as Record<string, JsonValue>);
|
||||
if (!valid.ok) {
|
||||
throw new Error(valid.reason);
|
||||
}
|
||||
const sequence = nextSequence();
|
||||
const signed = await signMeshEvent('dm_message', dmPayload, sequence);
|
||||
if (senderSeal && signed.context.source === 'wormhole') {
|
||||
try {
|
||||
senderToken = takeCachedSenderToken(
|
||||
request.recipientId,
|
||||
request.deliveryClass,
|
||||
request.recipientToken || '',
|
||||
);
|
||||
if (!senderToken) {
|
||||
senderToken = await replenishSenderTokenCache(
|
||||
request.recipientId,
|
||||
request.deliveryClass,
|
||||
request.recipientToken || '',
|
||||
);
|
||||
}
|
||||
if (!senderToken) {
|
||||
const issued = await issueWormholeDmSenderToken(
|
||||
request.recipientId,
|
||||
request.deliveryClass,
|
||||
request.recipientToken || '',
|
||||
);
|
||||
senderToken = String(issued.sender_token || '');
|
||||
}
|
||||
} catch {
|
||||
senderToken = '';
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${request.apiBase}/api/mesh/dm/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sender_id: senderSeal && senderToken ? '' : signed.context.nodeId,
|
||||
sender_token: senderToken,
|
||||
recipient_id: senderSeal && senderToken ? '' : request.recipientId,
|
||||
delivery_class: request.deliveryClass,
|
||||
recipient_token: request.recipientToken || '',
|
||||
ciphertext: request.ciphertext,
|
||||
format: payloadFormat,
|
||||
session_welcome: request.sessionWelcome || '',
|
||||
sender_seal: senderSeal,
|
||||
relay_salt: relaySalt,
|
||||
msg_id: request.msgId,
|
||||
timestamp: request.timestamp,
|
||||
public_key: senderSeal && senderToken ? '' : signed.context.publicKey,
|
||||
public_key_algo: senderSeal && senderToken ? '' : signed.context.publicKeyAlgo,
|
||||
signature: signed.signature,
|
||||
sequence: signed.sequence,
|
||||
protocol_version: senderSeal && senderToken ? '' : signed.protocolVersion,
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendOffLedgerConsentMessage(
|
||||
request: Omit<DmSendRequest, 'deliveryClass' | 'useSealedSender'>,
|
||||
): Promise<DmSendResponse> {
|
||||
return sendDmMessage({
|
||||
...request,
|
||||
deliveryClass: 'request',
|
||||
useSealedSender: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sharedMailboxToken(peerId: string, peerDhPub: string): Promise<string> {
|
||||
return deadDropToken(peerId, peerDhPub);
|
||||
}
|
||||
|
||||
export function currentIdentity(): NodeIdentity | null {
|
||||
return getNodeIdentity();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
type ContactAliasLike = {
|
||||
sharedAlias?: string;
|
||||
previousSharedAliases?: string[];
|
||||
pendingSharedAlias?: string;
|
||||
sharedAliasGraceUntil?: number;
|
||||
};
|
||||
|
||||
export type DmConsentKind = 'contact_offer' | 'contact_accept' | 'contact_deny';
|
||||
|
||||
export type DmConsentMessage =
|
||||
| {
|
||||
kind: 'contact_offer';
|
||||
dh_pub_key?: string;
|
||||
dh_algo?: string;
|
||||
geo_hint?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'contact_accept';
|
||||
shared_alias?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'contact_deny';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const DM_CONSENT_PREFIX = 'DM_CONSENT:';
|
||||
|
||||
export function generateSharedAlias(): string {
|
||||
const bytes = new Uint8Array(12);
|
||||
crypto.getRandomValues(bytes);
|
||||
const suffix = Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `dmx_${suffix}`;
|
||||
}
|
||||
|
||||
export function buildContactOfferMessage(
|
||||
dhPubKey: string,
|
||||
dhAlgo: string,
|
||||
geoHint?: string,
|
||||
): string {
|
||||
return `${DM_CONSENT_PREFIX}${JSON.stringify({
|
||||
kind: 'contact_offer',
|
||||
dh_pub_key: dhPubKey,
|
||||
dh_algo: dhAlgo,
|
||||
geo_hint: geoHint || '',
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function buildContactAcceptMessage(sharedAlias: string): string {
|
||||
return `${DM_CONSENT_PREFIX}${JSON.stringify({
|
||||
kind: 'contact_accept',
|
||||
shared_alias: sharedAlias,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function buildContactDenyMessage(reason: string = ''): string {
|
||||
return `${DM_CONSENT_PREFIX}${JSON.stringify({
|
||||
kind: 'contact_deny',
|
||||
reason,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function parseDmConsentMessage(text: string): DmConsentMessage | null {
|
||||
try {
|
||||
if (text.startsWith(DM_CONSENT_PREFIX)) {
|
||||
const payload = JSON.parse(text.slice(DM_CONSENT_PREFIX.length));
|
||||
const kind = String(payload?.kind || '').trim() as DmConsentKind;
|
||||
if (kind === 'contact_offer') {
|
||||
const dhPubKey = String(payload?.dh_pub_key || '').trim();
|
||||
const dhAlgo = String(payload?.dh_algo || '').trim();
|
||||
const geoHint = String(payload?.geo_hint || '').trim();
|
||||
if (!dhPubKey) return null;
|
||||
return {
|
||||
kind,
|
||||
dh_pub_key: dhPubKey,
|
||||
dh_algo: dhAlgo || undefined,
|
||||
geo_hint: geoHint || undefined,
|
||||
};
|
||||
}
|
||||
if (kind === 'contact_accept') {
|
||||
const sharedAlias = String(payload?.shared_alias || '').trim();
|
||||
if (!sharedAlias) return null;
|
||||
return { kind, shared_alias: sharedAlias };
|
||||
}
|
||||
if (kind === 'contact_deny') {
|
||||
const reason = String(payload?.reason || '').trim();
|
||||
return { kind, reason: reason || undefined };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (text.startsWith('ACCESS_REQUEST:')) {
|
||||
const payload = text.slice('ACCESS_REQUEST:'.length);
|
||||
const [base, ...metaParts] = payload.split('|');
|
||||
const parts = base.split(':');
|
||||
let geoHint: string | undefined;
|
||||
for (const part of metaParts) {
|
||||
if (part.startsWith('geo=')) {
|
||||
geoHint = part.slice(4);
|
||||
}
|
||||
}
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
kind: 'contact_offer',
|
||||
dh_algo: parts[0] || undefined,
|
||||
dh_pub_key: parts.slice(1).join(':') || undefined,
|
||||
geo_hint: geoHint,
|
||||
};
|
||||
}
|
||||
return { kind: 'contact_offer', dh_pub_key: base || undefined, geo_hint: geoHint };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (text.startsWith('ACCESS_GRANTED:')) {
|
||||
const payload = JSON.parse(text.slice('ACCESS_GRANTED:'.length));
|
||||
const sharedAlias = String(payload?.shared_alias || '').trim();
|
||||
if (!sharedAlias) return null;
|
||||
return { kind: 'contact_accept', shared_alias: sharedAlias };
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAccessGrantedMessage(sharedAlias: string): string {
|
||||
return buildContactAcceptMessage(sharedAlias);
|
||||
}
|
||||
|
||||
export function parseAccessGrantedMessage(
|
||||
text: string,
|
||||
): { shared_alias?: string } | null {
|
||||
const consent = parseDmConsentMessage(text);
|
||||
if (consent?.kind !== 'contact_accept' || !consent.shared_alias) return null;
|
||||
return { shared_alias: consent.shared_alias };
|
||||
}
|
||||
|
||||
export function buildAliasRotateMessage(sharedAlias: string): string {
|
||||
return `ALIAS_ROTATE:${JSON.stringify({ shared_alias: sharedAlias })}`;
|
||||
}
|
||||
|
||||
export function parseAliasRotateMessage(
|
||||
text: string,
|
||||
): { shared_alias?: string } | null {
|
||||
try {
|
||||
if (!text.startsWith('ALIAS_ROTATE:')) return null;
|
||||
const payload = JSON.parse(text.slice('ALIAS_ROTATE:'.length));
|
||||
const sharedAlias = String(payload?.shared_alias || '').trim();
|
||||
if (!sharedAlias) return null;
|
||||
return { shared_alias: sharedAlias };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeAliasHistory(
|
||||
aliases: Array<string | undefined | null>,
|
||||
limit: number = 3,
|
||||
): string[] {
|
||||
const unique = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const alias of aliases) {
|
||||
const value = String(alias || '').trim();
|
||||
if (!value || unique.has(value)) continue;
|
||||
unique.add(value);
|
||||
ordered.push(value);
|
||||
if (ordered.length >= limit) break;
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function preferredDmPeerId(
|
||||
peerId: string,
|
||||
contact?: ContactAliasLike | null,
|
||||
): string {
|
||||
const pendingAlias = String(contact?.pendingSharedAlias || '').trim();
|
||||
const graceUntil = Number(contact?.sharedAliasGraceUntil || 0);
|
||||
if (pendingAlias && graceUntil > 0 && Date.now() >= graceUntil) {
|
||||
return pendingAlias;
|
||||
}
|
||||
const sharedAlias = String(contact?.sharedAlias || '').trim();
|
||||
return sharedAlias || peerId;
|
||||
}
|
||||
|
||||
export function allDmPeerIds(
|
||||
peerId: string,
|
||||
contact?: ContactAliasLike | null,
|
||||
): string[] {
|
||||
const unique = new Set<string>();
|
||||
const sharedAlias = String(contact?.sharedAlias || '').trim();
|
||||
const pendingAlias = String(contact?.pendingSharedAlias || '').trim();
|
||||
if (pendingAlias) unique.add(pendingAlias);
|
||||
if (sharedAlias) unique.add(sharedAlias);
|
||||
for (const alias of contact?.previousSharedAliases || []) {
|
||||
const value = String(alias || '').trim();
|
||||
if (value) unique.add(value);
|
||||
}
|
||||
if (peerId) unique.add(peerId);
|
||||
return Array.from(unique);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* Double Ratchet for Dead Drop DMs (client-side only).
|
||||
*
|
||||
* This is a pragmatic, lightweight ratchet using:
|
||||
* - X25519 (preferred) or ECDH P-256 (fallback) for DH
|
||||
* - HKDF-SHA256 for root key evolution
|
||||
* - HMAC-SHA256 for chain/message keys
|
||||
* - AES-256-GCM for message encryption
|
||||
*
|
||||
* Ciphertext format:
|
||||
* "dr2:" + base64(JSON({ h: { v, dh, pn, n, alg }, ct }))
|
||||
* where ct is base64(iv || ciphertext)
|
||||
*/
|
||||
|
||||
import { getKey } from '@/mesh/meshKeyStore';
|
||||
|
||||
const KEY_SESSION_MODE = 'sb_mesh_session_mode';
|
||||
const KEY_DH_PUBKEY = 'sb_mesh_dh_pubkey';
|
||||
const KEY_DH_ALGO = 'sb_mesh_dh_algo';
|
||||
const KEY_DH_PRIV_IDB = 'sb_mesh_dh_priv';
|
||||
const KEY_RATCHET = 'sb_mesh_dm_ratchet';
|
||||
const KEY_RATCHET_TELEMETRY = 'sb_mesh_ratchet_telemetry';
|
||||
// Ratchet state lives in IndexedDB alongside a non-extractable wrap key so raw
|
||||
// private state never needs to persist in Web Storage.
|
||||
const RATCHET_CRYPTO_DB = 'sb_mesh_ratchet_crypto';
|
||||
const RATCHET_CRYPTO_DB_VERSION = 2;
|
||||
const RATCHET_CRYPTO_STORE = 'keys';
|
||||
const RATCHET_STATE_STORE = 'state';
|
||||
const RATCHET_WRAP_KEY_ID = 'ratchet_wrap_key';
|
||||
const RATCHET_STATE_ID = 'ratchet_state';
|
||||
|
||||
const MAX_SKIP = 32;
|
||||
const PAD_BUCKET = 1024;
|
||||
const PAD_STEP = 512;
|
||||
const PAD_MAX = 4096;
|
||||
const PAD_MAGIC = 'SBP1';
|
||||
|
||||
type RatchetState = {
|
||||
algo: string;
|
||||
rk: string;
|
||||
cks?: string;
|
||||
ckr?: string;
|
||||
dhSelfPub: string;
|
||||
dhSelfPriv: string;
|
||||
dhRemote: string;
|
||||
ns: number;
|
||||
nr: number;
|
||||
pn: number;
|
||||
skipped?: Record<string, string>;
|
||||
updated: number;
|
||||
};
|
||||
|
||||
let stateCache: Record<string, RatchetState> | null = null;
|
||||
let stateLoadPromise: Promise<Record<string, RatchetState>> | null = null;
|
||||
const ratchetTelemetry: Record<string, number> = {};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(KEY_RATCHET_TELEMETRY);
|
||||
sessionStorage.removeItem(KEY_RATCHET_TELEMETRY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionMode(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(KEY_SESSION_MODE) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getStore(): Storage | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return isSessionMode() ? sessionStorage : localStorage;
|
||||
} catch {
|
||||
return sessionStorage;
|
||||
}
|
||||
}
|
||||
|
||||
function getAlternateStore(store: Storage | null): Storage | null {
|
||||
if (typeof window === 'undefined' || !store) return null;
|
||||
return store === sessionStorage ? localStorage : sessionStorage;
|
||||
}
|
||||
|
||||
function storageGet(key: string): string | null {
|
||||
const store = getStore();
|
||||
if (!store) return null;
|
||||
const primaryValue = store.getItem(key);
|
||||
if (primaryValue !== null) return primaryValue;
|
||||
const alternate = getAlternateStore(store);
|
||||
if (!alternate) return null;
|
||||
const migratedValue = alternate.getItem(key);
|
||||
if (migratedValue !== null) {
|
||||
store.setItem(key, migratedValue);
|
||||
alternate.removeItem(key);
|
||||
}
|
||||
return migratedValue;
|
||||
}
|
||||
|
||||
function storageSet(key: string, value: string): void {
|
||||
const store = getStore();
|
||||
if (!store) return;
|
||||
store.setItem(key, value);
|
||||
const alternate = getAlternateStore(store);
|
||||
if (alternate) alternate.removeItem(key);
|
||||
}
|
||||
|
||||
function storageRemove(key: string): void {
|
||||
for (const store of [localStorage, sessionStorage]) {
|
||||
try {
|
||||
store.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openRatchetCryptoDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open(RATCHET_CRYPTO_DB, RATCHET_CRYPTO_DB_VERSION);
|
||||
open.onupgradeneeded = () => {
|
||||
const db = open.result;
|
||||
if (!db.objectStoreNames.contains(RATCHET_CRYPTO_STORE)) {
|
||||
db.createObjectStore(RATCHET_CRYPTO_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(RATCHET_STATE_STORE)) {
|
||||
db.createObjectStore(RATCHET_STATE_STORE);
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrCreateWrapKey(): Promise<CryptoKey> {
|
||||
const db = await openRatchetCryptoDb();
|
||||
try {
|
||||
const existing = await new Promise<CryptoKey | null>((resolve, reject) => {
|
||||
const tx = db.transaction(RATCHET_CRYPTO_STORE, 'readonly');
|
||||
const store = tx.objectStore(RATCHET_CRYPTO_STORE);
|
||||
const req = store.get(RATCHET_WRAP_KEY_ID);
|
||||
req.onsuccess = () => resolve((req.result?.key as CryptoKey | undefined) || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(RATCHET_CRYPTO_STORE, 'readwrite');
|
||||
tx.objectStore(RATCHET_CRYPTO_STORE).put({ id: RATCHET_WRAP_KEY_ID, key });
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
return key;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptRatchetState(plaintext: string): Promise<string> {
|
||||
const key = await getOrCreateWrapKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
async function decryptRatchetState(encrypted: string): Promise<string> {
|
||||
const key = await getOrCreateWrapKey();
|
||||
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
async function readPersistedRatchetState(): Promise<string | null> {
|
||||
const db = await openRatchetCryptoDb();
|
||||
try {
|
||||
return await new Promise<string | null>((resolve, reject) => {
|
||||
const tx = db.transaction(RATCHET_STATE_STORE, 'readonly');
|
||||
const store = tx.objectStore(RATCHET_STATE_STORE);
|
||||
const req = store.get(RATCHET_STATE_ID);
|
||||
req.onsuccess = () => resolve((req.result as string | undefined) || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writePersistedRatchetState(value: string): Promise<void> {
|
||||
const db = await openRatchetCryptoDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(RATCHET_STATE_STORE, 'readwrite');
|
||||
tx.objectStore(RATCHET_STATE_STORE).put(value, RATCHET_STATE_ID);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function recordTelemetry(event: string): void {
|
||||
ratchetTelemetry[event] = (ratchetTelemetry[event] || 0) + 1;
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBufferLike): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
function base64ToBuf(b64: string): ArrayBuffer {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function utf8ToBuf(text: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(text).buffer;
|
||||
}
|
||||
|
||||
function ensureArrayBuf(value?: string): ArrayBuffer {
|
||||
if (!value) return new Uint8Array(32).buffer;
|
||||
return base64ToBuf(value);
|
||||
}
|
||||
|
||||
async function loadAllStates(): Promise<Record<string, RatchetState>> {
|
||||
if (stateCache) return stateCache;
|
||||
if (stateLoadPromise) return stateLoadPromise;
|
||||
stateLoadPromise = (async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
stateCache = {};
|
||||
return stateCache;
|
||||
}
|
||||
try {
|
||||
const persisted = await readPersistedRatchetState();
|
||||
if (persisted) {
|
||||
const decrypted = await decryptRatchetState(persisted);
|
||||
stateCache = JSON.parse(decrypted) as Record<string, RatchetState>;
|
||||
return stateCache;
|
||||
}
|
||||
const legacy = storageGet(KEY_RATCHET) || '';
|
||||
if (!legacy) {
|
||||
stateCache = {};
|
||||
return stateCache;
|
||||
}
|
||||
try {
|
||||
stateCache = JSON.parse(legacy) as Record<string, RatchetState>;
|
||||
} catch {
|
||||
const decrypted = await decryptRatchetState(legacy);
|
||||
stateCache = JSON.parse(decrypted) as Record<string, RatchetState>;
|
||||
}
|
||||
await saveAllStates(stateCache || {});
|
||||
storageRemove(KEY_RATCHET);
|
||||
return stateCache || {};
|
||||
} catch {
|
||||
stateCache = {};
|
||||
return stateCache;
|
||||
}
|
||||
})();
|
||||
return stateLoadPromise;
|
||||
}
|
||||
|
||||
async function saveAllStates(states: Record<string, RatchetState>): Promise<void> {
|
||||
stateCache = states;
|
||||
stateLoadPromise = Promise.resolve(states);
|
||||
try {
|
||||
const encrypted = await encryptRatchetState(JSON.stringify(states));
|
||||
await writePersistedRatchetState(encrypted);
|
||||
storageRemove(KEY_RATCHET);
|
||||
} catch (err) {
|
||||
console.warn('[mesh] ratchet encryption unavailable — state kept in memory only, not persisted', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getState(peerId: string): Promise<RatchetState | null> {
|
||||
const all = await loadAllStates();
|
||||
return all[peerId] || null;
|
||||
}
|
||||
|
||||
async function setState(peerId: string, state: RatchetState): Promise<void> {
|
||||
const all = await loadAllStates();
|
||||
all[peerId] = state;
|
||||
await saveAllStates(all);
|
||||
}
|
||||
|
||||
function getLongTermDhAlgo(): string {
|
||||
return storageGet(KEY_DH_ALGO) || 'X25519';
|
||||
}
|
||||
|
||||
function getLongTermDhPub(): string {
|
||||
return storageGet(KEY_DH_PUBKEY) || '';
|
||||
}
|
||||
|
||||
async function generateRatchetKeyPair(algoHint?: string): Promise<{ pub: string; priv: string; algo: string }> {
|
||||
let keyPair: CryptoKeyPair;
|
||||
let algo = (algoHint || '').toUpperCase();
|
||||
try {
|
||||
keyPair = (await crypto.subtle.generateKey('X25519', true, ['deriveBits'])) as CryptoKeyPair;
|
||||
algo = 'X25519';
|
||||
} catch {
|
||||
keyPair = (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
|
||||
'deriveBits',
|
||||
])) as CryptoKeyPair;
|
||||
algo = 'ECDH';
|
||||
}
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
return { pub: bufToBase64(pubRaw), priv: JSON.stringify(privJwk), algo };
|
||||
}
|
||||
|
||||
async function deriveDhSecret(
|
||||
algo: string,
|
||||
privJwkStr: string,
|
||||
theirPubB64: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const algoNorm = (algo || '').toUpperCase();
|
||||
const privJwk = JSON.parse(privJwkStr || '{}');
|
||||
const theirPubRaw = base64ToBuf(theirPubB64);
|
||||
if (algoNorm === 'X25519') {
|
||||
const privKey = await crypto.subtle.importKey('jwk', privJwk, 'X25519', false, ['deriveBits']);
|
||||
const pubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []);
|
||||
return crypto.subtle.deriveBits({ name: 'X25519', public: pubKey }, privKey, 256);
|
||||
}
|
||||
const privKey = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
privJwk,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
theirPubRaw,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
return crypto.subtle.deriveBits({ name: 'ECDH', public: pubKey }, privKey, 256);
|
||||
}
|
||||
|
||||
async function deriveDhSecretWithKey(
|
||||
algo: string,
|
||||
privKey: CryptoKey,
|
||||
theirPubB64: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const algoNorm = (algo || '').toUpperCase();
|
||||
const theirPubRaw = base64ToBuf(theirPubB64);
|
||||
if (algoNorm === 'X25519') {
|
||||
const pubKey = await crypto.subtle.importKey('raw', theirPubRaw, 'X25519', false, []);
|
||||
return crypto.subtle.deriveBits({ name: 'X25519', public: pubKey }, privKey, 256);
|
||||
}
|
||||
const pubKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
theirPubRaw,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
return crypto.subtle.deriveBits({ name: 'ECDH', public: pubKey }, privKey, 256);
|
||||
}
|
||||
|
||||
async function getLongTermDhPrivateKey(): Promise<CryptoKey> {
|
||||
const key = await getKey(KEY_DH_PRIV_IDB);
|
||||
if (!key) {
|
||||
throw new Error('DM encryption unavailable: missing long-term DH private key');
|
||||
}
|
||||
return key as CryptoKey;
|
||||
}
|
||||
|
||||
async function hkdf(ikm: ArrayBuffer, salt: ArrayBuffer, info: string, length: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt, info: utf8ToBuf(info) },
|
||||
key,
|
||||
length * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function kdfRK(rk: ArrayBuffer, dhOut: ArrayBuffer): Promise<{ rk: Uint8Array; ck: Uint8Array }> {
|
||||
const salt = rk && rk.byteLength ? rk : new Uint8Array(32).buffer;
|
||||
const out = await hkdf(dhOut, salt, 'SB-DR-RK', 64);
|
||||
return { rk: out.slice(0, 32), ck: out.slice(32, 64) };
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: ArrayBufferLike, data: Uint8Array): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes as ArrayBuffer, { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign',
|
||||
]);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, data as BufferSource);
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
async function kdfCK(ck: ArrayBuffer): Promise<{ ck: Uint8Array; mk: Uint8Array }> {
|
||||
const mk = await hmacSha256(ck, new Uint8Array([1]));
|
||||
const next = await hmacSha256(ck, new Uint8Array([2]));
|
||||
return { ck: next, mk };
|
||||
}
|
||||
|
||||
function buildPaddedPayload(plaintext: string): Uint8Array {
|
||||
const data = new TextEncoder().encode(plaintext);
|
||||
const len = data.length;
|
||||
let target = PAD_BUCKET;
|
||||
if (len + 6 > target) {
|
||||
target = Math.ceil((len + 6) / PAD_STEP) * PAD_STEP;
|
||||
}
|
||||
if (target > PAD_MAX) {
|
||||
target = Math.ceil((len + 6) / PAD_STEP) * PAD_STEP;
|
||||
}
|
||||
const out = new Uint8Array(target);
|
||||
out.set(new TextEncoder().encode(PAD_MAGIC), 0);
|
||||
out[4] = (len >> 8) & 0xff;
|
||||
out[5] = len & 0xff;
|
||||
out.set(data, 6);
|
||||
if (target > len + 6) {
|
||||
crypto.getRandomValues(out.subarray(6 + len));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function unpadPayload(data: Uint8Array): string {
|
||||
if (data.length < 6) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
const magic = new TextDecoder().decode(data.slice(0, 4));
|
||||
if (magic !== PAD_MAGIC) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
const len = (data[4] << 8) + data[5];
|
||||
if (len <= 0 || 6 + len > data.length) {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
return new TextDecoder().decode(data.slice(6, 6 + len));
|
||||
}
|
||||
|
||||
function headerAad(header: Record<string, unknown>): Uint8Array {
|
||||
return new TextEncoder().encode(stableStringify(header));
|
||||
}
|
||||
|
||||
async function aesGcmEncrypt(
|
||||
mk: ArrayBufferLike,
|
||||
plaintext: string,
|
||||
aad?: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', mk as ArrayBuffer, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = buildPaddedPayload(plaintext);
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, additionalData: aad as BufferSource | undefined },
|
||||
key,
|
||||
encoded as BufferSource,
|
||||
);
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return bufToBase64(combined.buffer);
|
||||
}
|
||||
|
||||
async function aesGcmDecrypt(
|
||||
mk: ArrayBufferLike,
|
||||
ciphertextB64: string,
|
||||
aad?: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', mk as ArrayBuffer, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const combined = new Uint8Array(base64ToBuf(ciphertextB64));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const plainBuf = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv, additionalData: aad as BufferSource | undefined },
|
||||
key,
|
||||
ciphertext as BufferSource,
|
||||
);
|
||||
return unpadPayload(new Uint8Array(plainBuf));
|
||||
}
|
||||
|
||||
async function initSenderState(peerId: string, theirDhPub: string): Promise<RatchetState> {
|
||||
const { pub, priv, algo } = await generateRatchetKeyPair(getLongTermDhAlgo());
|
||||
const dhOut = await deriveDhSecret(algo, priv, theirDhPub);
|
||||
const { rk, ck } = await kdfRK(new Uint8Array(32).buffer, dhOut);
|
||||
return {
|
||||
algo,
|
||||
rk: bufToBase64(rk.buffer),
|
||||
cks: bufToBase64(ck.buffer),
|
||||
ckr: undefined,
|
||||
dhSelfPub: pub,
|
||||
dhSelfPriv: priv,
|
||||
dhRemote: theirDhPub,
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
skipped: {},
|
||||
updated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function initReceiverState(peerId: string, senderDhPub: string, algoHint?: string): Promise<RatchetState> {
|
||||
const algo = algoHint || getLongTermDhAlgo();
|
||||
const ourPriv = await getLongTermDhPrivateKey();
|
||||
const dhOut = await deriveDhSecretWithKey(algo, ourPriv, senderDhPub);
|
||||
const { rk, ck } = await kdfRK(new Uint8Array(32).buffer, dhOut);
|
||||
const { pub, priv } = await generateRatchetKeyPair(algo);
|
||||
return {
|
||||
algo,
|
||||
rk: bufToBase64(rk.buffer),
|
||||
cks: undefined,
|
||||
ckr: bufToBase64(ck.buffer),
|
||||
dhSelfPub: pub,
|
||||
dhSelfPriv: priv,
|
||||
dhRemote: senderDhPub,
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
skipped: {},
|
||||
updated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSendChain(state: RatchetState): Promise<RatchetState> {
|
||||
if (state.cks) return state;
|
||||
const rkBytes = ensureArrayBuf(state.rk);
|
||||
const dhOut = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const { rk, ck } = await kdfRK(rkBytes, dhOut);
|
||||
state.rk = bufToBase64(rk.buffer);
|
||||
state.cks = bufToBase64(ck.buffer);
|
||||
state.updated = Date.now();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function skipMessageKeys(state: RatchetState, until: number): Promise<void> {
|
||||
if (!state.ckr) return;
|
||||
const skipped = state.skipped || {};
|
||||
while (state.nr < until) {
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.ckr));
|
||||
const keyId = `${state.dhRemote}:${state.nr}`;
|
||||
if (Object.keys(skipped).length < MAX_SKIP) {
|
||||
skipped[keyId] = bufToBase64(mk.buffer);
|
||||
} else {
|
||||
recordTelemetry('ratchet_skip_overflow');
|
||||
}
|
||||
state.ckr = bufToBase64(ck.buffer);
|
||||
state.nr += 1;
|
||||
}
|
||||
state.skipped = skipped;
|
||||
}
|
||||
|
||||
async function dhRatchet(state: RatchetState, remoteDh: string, pn: number): Promise<RatchetState> {
|
||||
// Skip remaining keys in old chain
|
||||
await skipMessageKeys(state, pn);
|
||||
state.pn = state.ns;
|
||||
state.ns = 0;
|
||||
state.nr = 0;
|
||||
state.dhRemote = remoteDh;
|
||||
|
||||
// Step 1: new receiving chain
|
||||
const rkBytes = ensureArrayBuf(state.rk);
|
||||
const dhOut1 = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const out1 = await kdfRK(rkBytes, dhOut1);
|
||||
state.rk = bufToBase64(out1.rk.buffer);
|
||||
state.ckr = bufToBase64(out1.ck.buffer);
|
||||
|
||||
// Step 2: new sending chain with a fresh DH key
|
||||
const fresh = await generateRatchetKeyPair(state.algo);
|
||||
state.dhSelfPub = fresh.pub;
|
||||
state.dhSelfPriv = fresh.priv;
|
||||
const dhOut2 = await deriveDhSecret(state.algo, state.dhSelfPriv, state.dhRemote);
|
||||
const out2 = await kdfRK(ensureArrayBuf(state.rk), dhOut2);
|
||||
state.rk = bufToBase64(out2.rk.buffer);
|
||||
state.cks = bufToBase64(out2.ck.buffer);
|
||||
state.updated = Date.now();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function ratchetEncryptDM(
|
||||
peerId: string,
|
||||
theirDhPub: string,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
let state = await getState(peerId);
|
||||
if (!state) {
|
||||
state = await initSenderState(peerId, theirDhPub);
|
||||
}
|
||||
state = await ensureSendChain(state);
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.cks!));
|
||||
const n = state.ns;
|
||||
state.ns += 1;
|
||||
state.cks = bufToBase64(ck.buffer);
|
||||
const header = { v: 2, dh: state.dhSelfPub, pn: state.pn, n, alg: state.algo };
|
||||
const aad = headerAad(header);
|
||||
const ct = await aesGcmEncrypt(mk.buffer, plaintext, aad);
|
||||
const payload = { h: header, ct };
|
||||
const wrapped = bufToBase64(utf8ToBuf(JSON.stringify(payload)));
|
||||
state.updated = Date.now();
|
||||
await setState(peerId, state);
|
||||
return `dr2:${wrapped}`;
|
||||
}
|
||||
|
||||
export async function ratchetDecryptDM(peerId: string, ciphertext: string): Promise<string> {
|
||||
if (!ciphertext.startsWith('dr2:')) {
|
||||
throw new Error('legacy');
|
||||
}
|
||||
const raw = ciphertext.slice(4);
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64ToBuf(raw)));
|
||||
const header = payload.h || {};
|
||||
const ct = String(payload.ct || '');
|
||||
const remoteDh = String(header.dh || '');
|
||||
const pn = Number(header.pn || 0);
|
||||
const n = Number(header.n || 0);
|
||||
const alg = String(header.alg || getLongTermDhAlgo());
|
||||
|
||||
let state = await getState(peerId);
|
||||
if (!state) {
|
||||
state = await initReceiverState(peerId, remoteDh, alg);
|
||||
}
|
||||
|
||||
if (remoteDh && remoteDh !== state.dhRemote) {
|
||||
state = await dhRatchet(state, remoteDh, pn);
|
||||
}
|
||||
|
||||
const skipped = state.skipped || {};
|
||||
const skipKey = `${remoteDh}:${n}`;
|
||||
if (skipped[skipKey]) {
|
||||
const mk = base64ToBuf(skipped[skipKey]);
|
||||
delete skipped[skipKey];
|
||||
state.skipped = skipped;
|
||||
await setState(peerId, state);
|
||||
return aesGcmDecrypt(mk, ct, headerAad(header));
|
||||
}
|
||||
|
||||
await skipMessageKeys(state, n);
|
||||
if (!state.ckr) throw new Error('no_receive_chain');
|
||||
const { ck, mk } = await kdfCK(base64ToBuf(state.ckr));
|
||||
state.ckr = bufToBase64(ck.buffer);
|
||||
state.nr += 1;
|
||||
state.updated = Date.now();
|
||||
await setState(peerId, state);
|
||||
return aesGcmDecrypt(mk.buffer, ct, headerAad(header));
|
||||
}
|
||||
|
||||
export function ratchetHasState(peerId: string): boolean {
|
||||
if (!stateCache) {
|
||||
void loadAllStates();
|
||||
}
|
||||
return Boolean(stateCache?.[peerId]);
|
||||
}
|
||||
|
||||
export function ratchetReset(peerId: string): void {
|
||||
void (async () => {
|
||||
const all = await loadAllStates();
|
||||
if (all[peerId]) {
|
||||
delete all[peerId];
|
||||
await saveAllStates(all);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function ratchetResetAll(): void {
|
||||
stateCache = {};
|
||||
stateLoadPromise = Promise.resolve(stateCache);
|
||||
void saveAllStates({});
|
||||
}
|
||||
|
||||
export function getLongTermDhPublicKey(): string {
|
||||
return getLongTermDhPub();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import { getDHAlgo } from '@/mesh/meshIdentity';
|
||||
import { deleteWorkerRatchetDatabase } from '@/mesh/meshDmWorkerVault';
|
||||
import { ensureWormholeReadyForSecureAction, isWormholeReady } from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
type WorkerRequest =
|
||||
| {
|
||||
id: string;
|
||||
action: 'encrypt';
|
||||
peerId: string;
|
||||
peerDhPub: string;
|
||||
plaintext: string;
|
||||
dhAlgo: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
action: 'decrypt';
|
||||
peerId: string;
|
||||
ciphertext: string;
|
||||
dhAlgo: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
action: 'reset';
|
||||
peerId?: string;
|
||||
};
|
||||
|
||||
type WorkerResponse = { id: string; ok: boolean; result?: string; error?: string };
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let reqCounter = 0;
|
||||
const pending = new Map<string, { resolve: (v: string) => void; reject: (err: Error) => void }>();
|
||||
let browserRatchetClearedForWormhole = false;
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
if (worker) return worker;
|
||||
worker = new Worker(new URL('./meshDm.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const msg = event.data;
|
||||
const handler = pending.get(msg.id);
|
||||
if (!handler) return;
|
||||
pending.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
handler.resolve(String(msg.result || ''));
|
||||
} else {
|
||||
handler.reject(new Error(msg.error || 'worker_error'));
|
||||
}
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
function callWorker(payload: Omit<WorkerRequest, 'id'> & Record<string, unknown>): Promise<string> {
|
||||
const id = `dmw_${Date.now()}_${reqCounter++}`;
|
||||
const req = { ...payload, id } as WorkerRequest;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
try {
|
||||
ensureWorker().postMessage(req);
|
||||
} catch (err) {
|
||||
pending.delete(id);
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function callWormhole(path: string, body: Record<string, unknown>): Promise<string> {
|
||||
const data = await controlPlaneJson<{ result?: string }>(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return String(data?.result || '');
|
||||
}
|
||||
|
||||
async function clearBrowserRatchetForWormhole(): Promise<void> {
|
||||
if (browserRatchetClearedForWormhole) return;
|
||||
try {
|
||||
await callWorker({ action: 'reset' });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
browserRatchetClearedForWormhole = true;
|
||||
}
|
||||
|
||||
export async function ratchetEncryptDM(
|
||||
peerId: string,
|
||||
theirDhPub: string,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
await ensureWormholeReadyForSecureAction('dm_encrypt');
|
||||
if (await isWormholeReady()) {
|
||||
await clearBrowserRatchetForWormhole();
|
||||
return callWormhole('/api/wormhole/dm/encrypt', {
|
||||
peer_id: peerId,
|
||||
peer_dh_pub: theirDhPub,
|
||||
plaintext,
|
||||
});
|
||||
}
|
||||
return callWorker({
|
||||
action: 'encrypt',
|
||||
peerId,
|
||||
peerDhPub: theirDhPub,
|
||||
plaintext,
|
||||
dhAlgo: getDHAlgo() || 'X25519',
|
||||
});
|
||||
}
|
||||
|
||||
export async function ratchetDecryptDM(peerId: string, ciphertext: string): Promise<string> {
|
||||
await ensureWormholeReadyForSecureAction('dm_decrypt');
|
||||
if (await isWormholeReady()) {
|
||||
await clearBrowserRatchetForWormhole();
|
||||
return callWormhole('/api/wormhole/dm/decrypt', {
|
||||
peer_id: peerId,
|
||||
ciphertext,
|
||||
});
|
||||
}
|
||||
return callWorker({
|
||||
action: 'decrypt',
|
||||
peerId,
|
||||
ciphertext,
|
||||
dhAlgo: getDHAlgo() || 'X25519',
|
||||
});
|
||||
}
|
||||
|
||||
export async function ratchetReset(peerId?: string): Promise<void> {
|
||||
await ensureWormholeReadyForSecureAction('dm_reset');
|
||||
if (await isWormholeReady()) {
|
||||
await callWormhole('/api/wormhole/dm/reset', {
|
||||
peer_id: peerId || '',
|
||||
});
|
||||
if (!peerId) browserRatchetClearedForWormhole = true;
|
||||
return;
|
||||
}
|
||||
await callWorker({ action: 'reset', peerId });
|
||||
}
|
||||
|
||||
export async function purgeBrowserDmState(): Promise<void> {
|
||||
try {
|
||||
await callWorker({ action: 'reset' });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (worker) {
|
||||
try {
|
||||
worker.terminate();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
pending.clear();
|
||||
browserRatchetClearedForWormhole = true;
|
||||
await deleteWorkerRatchetDatabase();
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem('sb_mesh_dm_ratchet');
|
||||
sessionStorage.removeItem('sb_mesh_dm_ratchet');
|
||||
localStorage.removeItem('sb_mesh_ratchet_telemetry');
|
||||
sessionStorage.removeItem('sb_mesh_ratchet_telemetry');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export type WorkerRatchetState = {
|
||||
algo: string;
|
||||
rk: string;
|
||||
cks?: string;
|
||||
ckr?: string;
|
||||
dhSelfPub: string;
|
||||
dhSelfPriv: string;
|
||||
dhRemote: string;
|
||||
ns: number;
|
||||
nr: number;
|
||||
pn: number;
|
||||
skipped?: Record<string, string>;
|
||||
updated: number;
|
||||
};
|
||||
|
||||
export const WORKER_RATCHET_DB = 'sb_mesh_dm_worker';
|
||||
const WORKER_RATCHET_DB_VERSION = 2;
|
||||
const WORKER_RATCHET_STATE_STORE = 'ratchet';
|
||||
const WORKER_RATCHET_META_STORE = 'meta';
|
||||
const WORKER_RATCHET_STATE_KEY = 'state';
|
||||
const WORKER_RATCHET_WRAP_KEY_ID = 'ratchet_wrap_key';
|
||||
|
||||
function openWorkerRatchetDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open(WORKER_RATCHET_DB, WORKER_RATCHET_DB_VERSION);
|
||||
open.onupgradeneeded = () => {
|
||||
const db = open.result;
|
||||
if (!db.objectStoreNames.contains(WORKER_RATCHET_STATE_STORE)) {
|
||||
db.createObjectStore(WORKER_RATCHET_STATE_STORE);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(WORKER_RATCHET_META_STORE)) {
|
||||
db.createObjectStore(WORKER_RATCHET_META_STORE);
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
});
|
||||
}
|
||||
|
||||
function readValue<T>(db: IDBDatabase, storeName: string, key: string): Promise<T | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve((req.result as T | undefined) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function writeValue(db: IDBDatabase, storeName: string, key: string, value: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite');
|
||||
tx.objectStore(storeName).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteValue(db: IDBDatabase, storeName: string, key: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite');
|
||||
tx.objectStore(storeName).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getStoredWrapKey(db: IDBDatabase): Promise<CryptoKey | null> {
|
||||
return readValue<CryptoKey>(db, WORKER_RATCHET_META_STORE, WORKER_RATCHET_WRAP_KEY_ID);
|
||||
}
|
||||
|
||||
async function getOrCreateWrapKey(db: IDBDatabase): Promise<CryptoKey> {
|
||||
const existing = await getStoredWrapKey(db);
|
||||
if (existing) return existing;
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
await writeValue(db, WORKER_RATCHET_META_STORE, WORKER_RATCHET_WRAP_KEY_ID, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function encryptSerializedState(db: IDBDatabase, serialized: string): Promise<string> {
|
||||
const key = await getOrCreateWrapKey(db);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(serialized);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
async function decryptSerializedState(db: IDBDatabase, encrypted: string): Promise<string> {
|
||||
const key = await getStoredWrapKey(db);
|
||||
if (!key) {
|
||||
throw new Error('worker_ratchet_wrap_key_missing');
|
||||
}
|
||||
const combined = Uint8Array.from(atob(encrypted), (char) => char.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
function normalizeStateRecord(
|
||||
value: unknown,
|
||||
): Record<string, WorkerRatchetState> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return value as Record<string, WorkerRatchetState>;
|
||||
}
|
||||
|
||||
export async function readWorkerRatchetStates(): Promise<Record<string, WorkerRatchetState>> {
|
||||
const db = await openWorkerRatchetDb();
|
||||
try {
|
||||
const persisted = await readValue<unknown>(db, WORKER_RATCHET_STATE_STORE, WORKER_RATCHET_STATE_KEY);
|
||||
if (persisted == null) return {};
|
||||
if (typeof persisted === 'string') {
|
||||
const decrypted = await decryptSerializedState(db, persisted);
|
||||
return normalizeStateRecord(JSON.parse(decrypted));
|
||||
}
|
||||
|
||||
const legacy = normalizeStateRecord(persisted);
|
||||
if (Object.keys(legacy).length > 0) {
|
||||
try {
|
||||
const encrypted = await encryptSerializedState(db, JSON.stringify(legacy));
|
||||
await writeValue(db, WORKER_RATCHET_STATE_STORE, WORKER_RATCHET_STATE_KEY, encrypted);
|
||||
} catch {
|
||||
await deleteValue(db, WORKER_RATCHET_STATE_STORE, WORKER_RATCHET_STATE_KEY);
|
||||
}
|
||||
}
|
||||
return legacy;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorkerRatchetStates(
|
||||
states: Record<string, WorkerRatchetState>,
|
||||
): Promise<void> {
|
||||
const db = await openWorkerRatchetDb();
|
||||
try {
|
||||
const encrypted = await encryptSerializedState(db, JSON.stringify(states));
|
||||
await writeValue(db, WORKER_RATCHET_STATE_STORE, WORKER_RATCHET_STATE_KEY, encrypted);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearWorkerRatchetStates(): Promise<void> {
|
||||
await writeWorkerRatchetStates({});
|
||||
}
|
||||
|
||||
export async function deleteWorkerRatchetDatabase(): Promise<void> {
|
||||
if (typeof indexedDB === 'undefined') return;
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const req = indexedDB.deleteDatabase(WORKER_RATCHET_DB);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
const DB_NAME = 'sb_mesh_keystore';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_KEYS = 'keys';
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_KEYS)) {
|
||||
db.createObjectStore(STORE_KEYS);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
handler: (store: IDBObjectStore) => IDBRequest,
|
||||
): Promise<T> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_KEYS, mode);
|
||||
const store = tx.objectStore(STORE_KEYS);
|
||||
const req = handler(store);
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () => reject(req.error);
|
||||
tx.oncomplete = () => db.close();
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function setKey(id: string, key: CryptoKey): Promise<void> {
|
||||
await withStore<void>('readwrite', (store) => store.put(key, id));
|
||||
}
|
||||
|
||||
export async function getKey(id: string): Promise<CryptoKey | null> {
|
||||
try {
|
||||
const result = await withStore<CryptoKey | undefined>('readonly', (store) => store.get(id));
|
||||
return result || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKey(id: string): Promise<void> {
|
||||
try {
|
||||
await withStore<void>('readwrite', (store) => store.delete(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { deleteKey, getKey, setKey } from '@/mesh/meshKeyStore';
|
||||
|
||||
const DM_EPOCH_SECONDS = 6 * 60 * 60; // 6 hours
|
||||
const MAILBOX_CLAIM_KEY_ID = 'sb_mesh_mailbox_claim_key';
|
||||
|
||||
function bufToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function getOrCreateMailboxClaimKey(): Promise<CryptoKey> {
|
||||
const existing = await getKey(MAILBOX_CLAIM_KEY_ID);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'HMAC', hash: 'SHA-256', length: 256 },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
await setKey(MAILBOX_CLAIM_KEY_ID, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function currentMailboxEpoch(tsSeconds?: number): number {
|
||||
const now = typeof tsSeconds === 'number' ? tsSeconds : Date.now() / 1000;
|
||||
return Math.floor(now / DM_EPOCH_SECONDS);
|
||||
}
|
||||
|
||||
export async function mailboxClaimToken(
|
||||
claimType: 'requests' | 'self',
|
||||
nodeId: string,
|
||||
): Promise<string> {
|
||||
const normalizedNodeId = String(nodeId || '').trim();
|
||||
if (!normalizedNodeId) {
|
||||
throw new Error('nodeId required for mailbox claim token');
|
||||
}
|
||||
const key = await getOrCreateMailboxClaimKey();
|
||||
const message = new TextEncoder().encode(`sb_mailbox_claim|v1|${claimType}|${normalizedNodeId}`);
|
||||
const digest = await crypto.subtle.sign('HMAC', key, message);
|
||||
return bufToHex(digest);
|
||||
}
|
||||
|
||||
export async function mailboxDecoySharedToken(index: number, epoch?: number): Promise<string> {
|
||||
const key = await getOrCreateMailboxClaimKey();
|
||||
const bucket = currentMailboxEpoch(epoch);
|
||||
const ordinal = Math.max(0, Number(index || 0));
|
||||
const message = new TextEncoder().encode(`sb_mailbox_claim|v1|shared_decoy|${bucket}|${ordinal}`);
|
||||
const digest = await crypto.subtle.sign('HMAC', key, message);
|
||||
return bufToHex(digest);
|
||||
}
|
||||
|
||||
export async function purgeMailboxClaimKey(): Promise<void> {
|
||||
await deleteKey(MAILBOX_CLAIM_KEY_ID);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type MerkleStep = { hash: string; side: 'left' | 'right' | string };
|
||||
|
||||
function bufToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
out += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sha256Hex(data: string): Promise<string> {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
|
||||
return bufToHex(buf);
|
||||
}
|
||||
const mod = await import('crypto');
|
||||
return mod.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
async function hashLeaf(value: string): Promise<string> {
|
||||
return sha256Hex(value);
|
||||
}
|
||||
|
||||
async function hashPair(left: string, right: string): Promise<string> {
|
||||
return sha256Hex(`${left}${right}`);
|
||||
}
|
||||
|
||||
export async function buildMerkleRoot(leaves: string[]): Promise<string> {
|
||||
if (!leaves.length) return '';
|
||||
let level = await Promise.all(leaves.map((leaf) => hashLeaf(leaf)));
|
||||
while (level.length > 1) {
|
||||
const next: string[] = [];
|
||||
for (let i = 0; i < level.length; i += 2) {
|
||||
const left = level[i];
|
||||
const right = level[i + 1] ?? left;
|
||||
next.push(await hashPair(left, right));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return level[0];
|
||||
}
|
||||
|
||||
export async function verifyMerkleProof(
|
||||
leafValue: string,
|
||||
index: number,
|
||||
proof: MerkleStep[],
|
||||
root: string,
|
||||
): Promise<boolean> {
|
||||
let current = await hashLeaf(leafValue);
|
||||
let idx = index;
|
||||
for (const step of proof) {
|
||||
const sibling = step.hash ?? '';
|
||||
const side = String(step.side || 'right').toLowerCase();
|
||||
if (side === 'left') {
|
||||
current = await hashPair(sibling, current);
|
||||
} else {
|
||||
current = await hashPair(current, sibling);
|
||||
}
|
||||
idx = Math.floor(idx / 2);
|
||||
}
|
||||
return current === root;
|
||||
}
|
||||
|
||||
export type { MerkleStep };
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Contact } from '@/mesh/meshIdentity';
|
||||
|
||||
export type PrivateLaneHint = {
|
||||
severity: 'warn' | 'danger';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type DmTrustHint = {
|
||||
severity: 'warn' | 'danger';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type PrivateLaneMode =
|
||||
| 'reticulum'
|
||||
| 'relay'
|
||||
| 'ready'
|
||||
| 'hidden'
|
||||
| 'blocked'
|
||||
| 'degraded';
|
||||
|
||||
function cleanReason(value: string | undefined): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
export function shortTrustFingerprint(fingerprint: string | undefined): string {
|
||||
const value = String(fingerprint || '').trim().toLowerCase();
|
||||
if (!value) return 'unknown';
|
||||
if (value.length <= 14) return value;
|
||||
return `${value.slice(0, 8)}..${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function isFirstContactTrustOnly(contact?: Partial<Contact> | null): boolean {
|
||||
if (!contact) return false;
|
||||
if (contact.remotePrekeyMismatch || contact.verify_mismatch || contact.verified) return false;
|
||||
if (contact.verify_registry || contact.verify_inband) return false;
|
||||
return Boolean(contact.remotePrekeyFingerprint || contact.remotePrekeyPinnedAt);
|
||||
}
|
||||
|
||||
export function shouldAutoRevealSasForTrust(contact?: Partial<Contact> | null): boolean {
|
||||
if (!contact) return false;
|
||||
return Boolean(
|
||||
contact.remotePrekeyMismatch || contact.verify_mismatch || isFirstContactTrustOnly(contact),
|
||||
);
|
||||
}
|
||||
|
||||
export function dmTrustPrimaryActionLabel(contact?: Partial<Contact> | null): string {
|
||||
return isFirstContactTrustOnly(contact) ? 'VERIFY SAS NOW' : 'SHOW SAS';
|
||||
}
|
||||
|
||||
export function buildPrivateLaneHint(opts: {
|
||||
activeTab: 'infonet' | 'meshtastic' | 'dms';
|
||||
recentPrivateFallback?: boolean;
|
||||
recentPrivateFallbackReason?: string;
|
||||
dmTransportMode?: PrivateLaneMode;
|
||||
privateInfonetReady?: boolean;
|
||||
privateInfonetTransportReady?: boolean;
|
||||
}): PrivateLaneHint | null {
|
||||
const reason =
|
||||
cleanReason(opts.recentPrivateFallbackReason) ||
|
||||
'A recent private-tier send fell back to clearnet relay.';
|
||||
if (opts.recentPrivateFallback && (opts.activeTab === 'dms' || opts.activeTab === 'infonet')) {
|
||||
return {
|
||||
severity: 'danger',
|
||||
title: 'RECENT PRIVACY DOWNGRADE',
|
||||
detail: `${reason} Treat recent traffic as exposed to weaker metadata protection until the private lane is healthy again.`,
|
||||
};
|
||||
}
|
||||
if (opts.activeTab === 'dms' && opts.dmTransportMode === 'relay') {
|
||||
return {
|
||||
severity: 'warn',
|
||||
title: 'RELAY DELIVERY ACTIVE',
|
||||
detail:
|
||||
'Dead Drop is currently using relay delivery. Content stays encrypted, but timing and mailbox metadata are weaker than direct private delivery.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
opts.activeTab === 'infonet' &&
|
||||
opts.privateInfonetReady &&
|
||||
!opts.privateInfonetTransportReady
|
||||
) {
|
||||
return {
|
||||
severity: 'warn',
|
||||
title: 'TRANSITIONAL PRIVATE LANE',
|
||||
detail:
|
||||
'INFONET gate chat is available, but the strongest transport posture is still warming up. Treat metadata resistance as reduced until Reticulum is ready.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildDmTrustHint(contact?: Partial<Contact> | null): DmTrustHint | null {
|
||||
if (!contact) return null;
|
||||
if (contact.remotePrekeyMismatch) {
|
||||
return {
|
||||
severity: 'danger',
|
||||
title: 'REMOTE PREKEY CHANGED',
|
||||
detail:
|
||||
'Pause private DM sending. Refresh the contact, compare the SAS phrase or another trusted fingerprint, then explicitly trust the new prekey only if it checks out.',
|
||||
};
|
||||
}
|
||||
if (contact.verify_mismatch) {
|
||||
return {
|
||||
severity: 'danger',
|
||||
title: 'CONTACT KEY MISMATCH',
|
||||
detail:
|
||||
'Registry and in-band key evidence disagree for this contact. Re-verify before continuing with private messaging.',
|
||||
};
|
||||
}
|
||||
if (isFirstContactTrustOnly(contact)) {
|
||||
return {
|
||||
severity: 'warn',
|
||||
title: 'FIRST CONTACT (TOFU ONLY)',
|
||||
detail:
|
||||
'This contact is pinned on first sight only. A decrypted DM is not proof of sender identity. Compare the SAS phrase or another trusted fingerprint before sharing sensitive material or acting on requests.',
|
||||
};
|
||||
}
|
||||
if (contact.verify_registry && !contact.verify_inband) {
|
||||
return {
|
||||
severity: 'warn',
|
||||
title: 'REGISTRY ONLY',
|
||||
detail:
|
||||
'This contact has registry verification, but no matching in-band verification yet. SAS comparison is still recommended before sensitive use.',
|
||||
};
|
||||
}
|
||||
if (contact.verify_inband && !contact.verify_registry) {
|
||||
return {
|
||||
severity: 'warn',
|
||||
title: 'IN-BAND ONLY',
|
||||
detail:
|
||||
'This contact has in-band verification, but no matching registry proof yet. Refresh the contact before sensitive use.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
export const PROTOCOL_VERSION = 'infonet/2';
|
||||
export const NETWORK_ID = 'sb-testnet-0';
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
function stableStringify(value: JsonValue): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
||||
}
|
||||
const obj = value as Record<string, JsonValue>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
export function canonicalJson(obj: Record<string, JsonValue>): string {
|
||||
return stableStringify(obj);
|
||||
}
|
||||
|
||||
export function normalizeMessagePayload(payload: Record<string, JsonValue>) {
|
||||
const normalized: Record<string, JsonValue> = {
|
||||
message: String(payload.message ?? ''),
|
||||
destination: String(payload.destination ?? ''),
|
||||
channel: String(payload.channel ?? 'LongFast'),
|
||||
priority: String(payload.priority ?? 'normal'),
|
||||
ephemeral: Boolean(payload.ephemeral ?? false),
|
||||
};
|
||||
const transportLock = String(payload.transport_lock ?? '').toLowerCase();
|
||||
if (transportLock) {
|
||||
normalized.transport_lock = transportLock;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeGateMessagePayload(payload: Record<string, JsonValue>) {
|
||||
const epochValue = Number(payload.epoch ?? 0);
|
||||
return {
|
||||
gate: String(payload.gate ?? '').trim().toLowerCase(),
|
||||
epoch: Number.isFinite(epochValue) ? Math.trunc(epochValue) : 0,
|
||||
ciphertext: String(payload.ciphertext ?? ''),
|
||||
nonce: String(payload.nonce ?? payload.iv ?? ''),
|
||||
sender_ref: String(payload.sender_ref ?? ''),
|
||||
format: String(payload.format ?? 'g1'),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeVotePayload(payload: Record<string, JsonValue>) {
|
||||
const voteVal = Number(payload.vote ?? 0);
|
||||
return {
|
||||
target_id: String(payload.target_id ?? ''),
|
||||
vote: Number.isFinite(voteVal) ? Math.trunc(voteVal) : 0,
|
||||
gate: String(payload.gate ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGateCreatePayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
gate_id: String(payload.gate_id ?? '').toLowerCase(),
|
||||
display_name: String(payload.display_name ?? ''),
|
||||
rules: (payload.rules as JsonValue) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePredictionPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
market_title: String(payload.market_title ?? ''),
|
||||
side: String(payload.side ?? ''),
|
||||
stake_amount: Number(payload.stake_amount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStakePayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
message_id: String(payload.message_id ?? ''),
|
||||
poster_id: String(payload.poster_id ?? ''),
|
||||
side: String(payload.side ?? ''),
|
||||
amount: Number(payload.amount ?? 0),
|
||||
duration_days: Number(payload.duration_days ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDmKeyPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
dh_pub_key: String(payload.dh_pub_key ?? ''),
|
||||
dh_algo: String(payload.dh_algo ?? ''),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDmMessagePayload(payload: Record<string, JsonValue>) {
|
||||
const normalized: Record<string, JsonValue> = {
|
||||
recipient_id: String(payload.recipient_id ?? ''),
|
||||
delivery_class: String(payload.delivery_class ?? '').toLowerCase(),
|
||||
recipient_token: String(payload.recipient_token ?? ''),
|
||||
ciphertext: String(payload.ciphertext ?? ''),
|
||||
msg_id: String(payload.msg_id ?? ''),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
format: String(payload.format ?? 'dm1').trim().toLowerCase(),
|
||||
};
|
||||
const sw = payload.session_welcome;
|
||||
if (sw) {
|
||||
normalized.session_welcome = String(sw);
|
||||
}
|
||||
const senderSeal = payload.sender_seal;
|
||||
if (senderSeal) {
|
||||
normalized.sender_seal = String(senderSeal);
|
||||
}
|
||||
const relaySalt = payload.relay_salt;
|
||||
if (relaySalt) {
|
||||
normalized.relay_salt = String(relaySalt).trim().toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMailboxClaims(payload: Record<string, JsonValue>) {
|
||||
const claims = Array.isArray(payload.mailbox_claims) ? payload.mailbox_claims : [];
|
||||
return claims.flatMap((claim) => {
|
||||
if (!claim || typeof claim !== 'object' || Array.isArray(claim)) return [];
|
||||
const record = claim as Record<string, JsonValue>;
|
||||
return [
|
||||
{
|
||||
type: String(record.type ?? '').toLowerCase(),
|
||||
token: String(record.token ?? ''),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDmPollPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
mailbox_claims: normalizeMailboxClaims(payload),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
nonce: String(payload.nonce ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDmCountPayload(payload: Record<string, JsonValue>) {
|
||||
return normalizeDmPollPayload(payload);
|
||||
}
|
||||
|
||||
export function normalizeDmBlockPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
blocked_id: String(payload.blocked_id ?? ''),
|
||||
action: String(payload.action ?? 'block').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDmKeyWitnessPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
target_id: String(payload.target_id ?? ''),
|
||||
dh_pub_key: String(payload.dh_pub_key ?? ''),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTrustVouchPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
target_id: String(payload.target_id ?? ''),
|
||||
note: String(payload.note ?? '').slice(0, 140),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKeyRotatePayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
old_node_id: String(payload.old_node_id ?? ''),
|
||||
old_public_key: String(payload.old_public_key ?? ''),
|
||||
old_public_key_algo: String(payload.old_public_key_algo ?? ''),
|
||||
new_public_key: String(payload.new_public_key ?? ''),
|
||||
new_public_key_algo: String(payload.new_public_key_algo ?? ''),
|
||||
timestamp: Number(payload.timestamp ?? 0),
|
||||
old_signature: String(payload.old_signature ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKeyRevokePayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
revoked_public_key: String(payload.revoked_public_key ?? ''),
|
||||
revoked_public_key_algo: String(payload.revoked_public_key_algo ?? ''),
|
||||
revoked_at: Number(payload.revoked_at ?? 0),
|
||||
grace_until: Number(payload.grace_until ?? 0),
|
||||
reason: String(payload.reason ?? '').slice(0, 140),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAbuseReportPayload(payload: Record<string, JsonValue>) {
|
||||
return {
|
||||
target_id: String(payload.target_id ?? ''),
|
||||
reason: String(payload.reason ?? '').slice(0, 280),
|
||||
gate: String(payload.gate ?? ''),
|
||||
evidence: String(payload.evidence ?? '').slice(0, 256),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePayload(eventType: string, payload: Record<string, JsonValue>) {
|
||||
if (eventType === 'message') return normalizeMessagePayload(payload);
|
||||
if (eventType === 'gate_message') return normalizeGateMessagePayload(payload);
|
||||
if (eventType === 'vote') return normalizeVotePayload(payload);
|
||||
if (eventType === 'gate_create') return normalizeGateCreatePayload(payload);
|
||||
if (eventType === 'prediction') return normalizePredictionPayload(payload);
|
||||
if (eventType === 'stake') return normalizeStakePayload(payload);
|
||||
if (eventType === 'dm_key') return normalizeDmKeyPayload(payload);
|
||||
if (eventType === 'dm_message') return normalizeDmMessagePayload(payload);
|
||||
if (eventType === 'dm_poll') return normalizeDmPollPayload(payload);
|
||||
if (eventType === 'dm_count') return normalizeDmCountPayload(payload);
|
||||
if (eventType === 'dm_block') return normalizeDmBlockPayload(payload);
|
||||
if (eventType === 'dm_key_witness') return normalizeDmKeyWitnessPayload(payload);
|
||||
if (eventType === 'trust_vouch') return normalizeTrustVouchPayload(payload);
|
||||
if (eventType === 'key_rotate') return normalizeKeyRotatePayload(payload);
|
||||
if (eventType === 'key_revoke') return normalizeKeyRevokePayload(payload);
|
||||
if (eventType === 'abuse_report') return normalizeAbuseReportPayload(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildSignaturePayload(opts: {
|
||||
eventType: string;
|
||||
nodeId: string;
|
||||
sequence: number;
|
||||
payload: Record<string, JsonValue>;
|
||||
}) {
|
||||
const normalized = normalizePayload(opts.eventType, opts.payload);
|
||||
const payloadJson = canonicalJson(normalized);
|
||||
return [
|
||||
PROTOCOL_VERSION,
|
||||
NETWORK_ID,
|
||||
opts.eventType,
|
||||
opts.nodeId,
|
||||
String(opts.sequence),
|
||||
payloadJson,
|
||||
].join('|');
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { deriveSharedSecret, getNodeIdentity } from '@/mesh/meshIdentity';
|
||||
import { hmacSha256 } from '@/mesh/meshDeadDrop';
|
||||
import { deriveWormholeSasPhrase, isWormholeReady } from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
const SAS_WORDS = [
|
||||
'able',
|
||||
'acid',
|
||||
'aged',
|
||||
'also',
|
||||
'area',
|
||||
'army',
|
||||
'atom',
|
||||
'aunt',
|
||||
'away',
|
||||
'baby',
|
||||
'back',
|
||||
'bake',
|
||||
'ball',
|
||||
'band',
|
||||
'bank',
|
||||
'base',
|
||||
'bean',
|
||||
'bear',
|
||||
'belt',
|
||||
'bird',
|
||||
'book',
|
||||
'boom',
|
||||
'boot',
|
||||
'boss',
|
||||
'bowl',
|
||||
'burn',
|
||||
'cafe',
|
||||
'calm',
|
||||
'camp',
|
||||
'card',
|
||||
'cash',
|
||||
'cell',
|
||||
'chat',
|
||||
'city',
|
||||
'clay',
|
||||
'cloud',
|
||||
'coin',
|
||||
'cool',
|
||||
'crew',
|
||||
'data',
|
||||
'dawn',
|
||||
'desk',
|
||||
'dome',
|
||||
'door',
|
||||
'dust',
|
||||
'eagle',
|
||||
'east',
|
||||
'easy',
|
||||
'echo',
|
||||
'edge',
|
||||
'envy',
|
||||
'fair',
|
||||
'farm',
|
||||
'fast',
|
||||
'file',
|
||||
'flag',
|
||||
'foam',
|
||||
'fold',
|
||||
'food',
|
||||
'game',
|
||||
'gate',
|
||||
'gear',
|
||||
'glow',
|
||||
'gold',
|
||||
];
|
||||
|
||||
function sasContext(peerId: string): string | null {
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity) return null;
|
||||
const ids = [identity.nodeId, peerId].sort().join('|');
|
||||
return ids;
|
||||
}
|
||||
|
||||
function bytesToWords(bytes: Uint8Array, count: number): string[] {
|
||||
const out: string[] = [];
|
||||
let acc = 0;
|
||||
let accBits = 0;
|
||||
for (const b of bytes) {
|
||||
acc = (acc << 8) | b;
|
||||
accBits += 8;
|
||||
while (accBits >= 6 && out.length < count) {
|
||||
const idx = (acc >> (accBits - 6)) & 0x3f;
|
||||
out.push(SAS_WORDS[idx]);
|
||||
accBits -= 6;
|
||||
}
|
||||
if (out.length >= count) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function deriveSasPhrase(peerId: string, peerDhPub: string, words: number = 8): Promise<string> {
|
||||
if (await isWormholeReady()) {
|
||||
const result = await deriveWormholeSasPhrase(peerId, peerDhPub, words).catch(() => null);
|
||||
if (result?.ok && result.phrase) {
|
||||
return String(result.phrase || '');
|
||||
}
|
||||
}
|
||||
const ctx = sasContext(peerId);
|
||||
if (!ctx) return '';
|
||||
const secret = await deriveSharedSecret(peerDhPub);
|
||||
const digest = await hmacSha256(secret, `sb_sas|v1|${ctx}`);
|
||||
const bytes = new Uint8Array(digest);
|
||||
const phrase = bytesToWords(bytes, words);
|
||||
return phrase.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { normalizePayload, type JsonValue } from '@/mesh/meshProtocol';
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
function requireFields(payload: Record<string, JsonValue>, fields: string[]): ValidationResult {
|
||||
for (const field of fields) {
|
||||
if (!(field in payload)) {
|
||||
return { ok: false, reason: `Missing field: ${field}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateMessage(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['message', 'destination', 'channel', 'priority', 'ephemeral']);
|
||||
if (!req.ok) return req;
|
||||
const priority = String(payload.priority ?? '');
|
||||
if (!['normal', 'high', 'emergency', 'low'].includes(priority)) {
|
||||
return { ok: false, reason: 'Invalid priority' };
|
||||
}
|
||||
if (typeof payload.ephemeral !== 'boolean') {
|
||||
return { ok: false, reason: 'ephemeral must be boolean' };
|
||||
}
|
||||
const transportLock = String(payload.transport_lock ?? '').toLowerCase();
|
||||
if (transportLock && transportLock !== 'meshtastic') {
|
||||
return { ok: false, reason: 'Invalid transport_lock' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateGateMessage(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['gate', 'epoch', 'ciphertext', 'nonce', 'sender_ref']);
|
||||
if (!req.ok) return req;
|
||||
if ('message' in payload) {
|
||||
return { ok: false, reason: 'plaintext gate message field is not allowed' };
|
||||
}
|
||||
const gate = String(payload.gate ?? '').trim().toLowerCase();
|
||||
if (!gate) {
|
||||
return { ok: false, reason: 'gate cannot be empty' };
|
||||
}
|
||||
const epoch = Number(payload.epoch ?? 0);
|
||||
if (!Number.isFinite(epoch) || Math.trunc(epoch) <= 0) {
|
||||
return { ok: false, reason: 'epoch must be a positive integer' };
|
||||
}
|
||||
if (!String(payload.ciphertext ?? '').trim()) {
|
||||
return { ok: false, reason: 'ciphertext cannot be empty' };
|
||||
}
|
||||
if (!String(payload.nonce ?? '').trim()) {
|
||||
return { ok: false, reason: 'nonce cannot be empty' };
|
||||
}
|
||||
if (!String(payload.sender_ref ?? '').trim()) {
|
||||
return { ok: false, reason: 'sender_ref cannot be empty' };
|
||||
}
|
||||
if (String(payload.format ?? 'g1').trim().toLowerCase() !== 'g1') {
|
||||
return { ok: false, reason: 'Unsupported gate message format' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateVote(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['target_id', 'vote', 'gate']);
|
||||
if (!req.ok) return req;
|
||||
const vote = Number(payload.vote);
|
||||
if (![1, -1].includes(vote)) {
|
||||
return { ok: false, reason: 'Invalid vote' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateGateCreate(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['gate_id', 'display_name', 'rules']);
|
||||
if (!req.ok) return req;
|
||||
if (typeof payload.rules !== 'object') {
|
||||
return { ok: false, reason: 'rules must be an object' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validatePrediction(payload: Record<string, JsonValue>): ValidationResult {
|
||||
return requireFields(payload, ['market_title', 'side', 'stake_amount']);
|
||||
}
|
||||
|
||||
function validateStake(payload: Record<string, JsonValue>): ValidationResult {
|
||||
return requireFields(payload, ['message_id', 'poster_id', 'side', 'amount', 'duration_days']);
|
||||
}
|
||||
|
||||
function validateDmKey(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['dh_pub_key', 'dh_algo', 'timestamp']);
|
||||
if (!req.ok) return req;
|
||||
const algo = String(payload.dh_algo ?? '');
|
||||
if (!['X25519', 'ECDH', 'ECDH_P256'].includes(algo)) {
|
||||
return { ok: false, reason: 'Invalid dh_algo' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateDmMessage(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, [
|
||||
'recipient_id',
|
||||
'delivery_class',
|
||||
'recipient_token',
|
||||
'ciphertext',
|
||||
'msg_id',
|
||||
'timestamp',
|
||||
]);
|
||||
if (!req.ok) return req;
|
||||
const deliveryClass = String(payload.delivery_class ?? '').toLowerCase();
|
||||
if (!['request', 'shared'].includes(deliveryClass)) {
|
||||
return { ok: false, reason: 'Invalid delivery_class' };
|
||||
}
|
||||
if (deliveryClass === 'shared' && !String(payload.recipient_token ?? '').trim()) {
|
||||
return { ok: false, reason: 'recipient_token required for shared delivery' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateMailboxClaims(
|
||||
claims: JsonValue,
|
||||
): ValidationResult {
|
||||
if (!Array.isArray(claims) || claims.length === 0) {
|
||||
return { ok: false, reason: 'mailbox_claims must be a non-empty list' };
|
||||
}
|
||||
for (const claim of claims) {
|
||||
if (!claim || typeof claim !== 'object' || Array.isArray(claim)) {
|
||||
return { ok: false, reason: 'mailbox_claims entries must be objects' };
|
||||
}
|
||||
const record = claim as Record<string, JsonValue>;
|
||||
const claimType = String(record.type ?? '').toLowerCase();
|
||||
if (!['self', 'requests', 'shared'].includes(claimType)) {
|
||||
return { ok: false, reason: 'Invalid mailbox claim type' };
|
||||
}
|
||||
if (!String(record.token ?? '').trim()) {
|
||||
return { ok: false, reason: `${claimType} mailbox claims require token` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateDmPoll(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['mailbox_claims', 'timestamp', 'nonce']);
|
||||
if (!req.ok) return req;
|
||||
return validateMailboxClaims(payload.mailbox_claims);
|
||||
}
|
||||
|
||||
function validateDmCount(payload: Record<string, JsonValue>): ValidationResult {
|
||||
return validateDmPoll(payload);
|
||||
}
|
||||
|
||||
function validateDmBlock(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['blocked_id', 'action']);
|
||||
if (!req.ok) return req;
|
||||
const action = String(payload.action ?? '');
|
||||
if (!['block', 'unblock'].includes(action)) {
|
||||
return { ok: false, reason: 'Invalid action' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateDmKeyWitness(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['target_id', 'dh_pub_key', 'timestamp']);
|
||||
if (!req.ok) return req;
|
||||
const ts = Number(payload.timestamp ?? 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) {
|
||||
return { ok: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateTrustVouch(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['target_id', 'timestamp']);
|
||||
if (!req.ok) return req;
|
||||
const ts = Number(payload.timestamp ?? 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) {
|
||||
return { ok: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateKeyRotate(payload: Record<string, JsonValue>): ValidationResult {
|
||||
return requireFields(payload, [
|
||||
'old_node_id',
|
||||
'old_public_key',
|
||||
'old_public_key_algo',
|
||||
'new_public_key',
|
||||
'new_public_key_algo',
|
||||
'timestamp',
|
||||
'old_signature',
|
||||
]);
|
||||
}
|
||||
|
||||
function validateKeyRevoke(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, [
|
||||
'revoked_public_key',
|
||||
'revoked_public_key_algo',
|
||||
'revoked_at',
|
||||
'grace_until',
|
||||
'reason',
|
||||
]);
|
||||
if (!req.ok) return req;
|
||||
const revokedAt = Number(payload.revoked_at ?? 0);
|
||||
const graceUntil = Number(payload.grace_until ?? 0);
|
||||
if (!Number.isFinite(revokedAt) || revokedAt <= 0) {
|
||||
return { ok: false, reason: 'revoked_at must be positive' };
|
||||
}
|
||||
if (!Number.isFinite(graceUntil) || graceUntil < revokedAt) {
|
||||
return { ok: false, reason: 'grace_until must be >= revoked_at' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateAbuseReport(payload: Record<string, JsonValue>): ValidationResult {
|
||||
const req = requireFields(payload, ['target_id', 'reason']);
|
||||
if (!req.ok) return req;
|
||||
if (!String(payload.reason ?? '').trim()) {
|
||||
return { ok: false, reason: 'reason cannot be empty' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const validators: Record<string, (payload: Record<string, JsonValue>) => ValidationResult> = {
|
||||
message: validateMessage,
|
||||
gate_message: validateGateMessage,
|
||||
vote: validateVote,
|
||||
gate_create: validateGateCreate,
|
||||
prediction: validatePrediction,
|
||||
stake: validateStake,
|
||||
dm_key: validateDmKey,
|
||||
dm_message: validateDmMessage,
|
||||
dm_poll: validateDmPoll,
|
||||
dm_count: validateDmCount,
|
||||
dm_block: validateDmBlock,
|
||||
dm_key_witness: validateDmKeyWitness,
|
||||
trust_vouch: validateTrustVouch,
|
||||
key_rotate: validateKeyRotate,
|
||||
key_revoke: validateKeyRevoke,
|
||||
abuse_report: validateAbuseReport,
|
||||
};
|
||||
|
||||
export function validateEventPayload(
|
||||
eventType: string,
|
||||
payload: Record<string, JsonValue>,
|
||||
): ValidationResult {
|
||||
const validator = validators[eventType];
|
||||
if (!validator) {
|
||||
return { ok: false, reason: 'Unknown event_type' };
|
||||
}
|
||||
const normalized = normalizePayload(eventType, payload);
|
||||
if (JSON.stringify(normalized) !== JSON.stringify(payload)) {
|
||||
return { ok: false, reason: 'Payload is not normalized' };
|
||||
}
|
||||
if (eventType !== 'message' && 'ephemeral' in payload) {
|
||||
return { ok: false, reason: 'ephemeral not allowed for this event type' };
|
||||
}
|
||||
return validator(payload);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export type RecoveredSenderSeal = {
|
||||
sender_id: string;
|
||||
seal_verified: boolean;
|
||||
} | null;
|
||||
|
||||
export const REQUEST_V2_REDUCED_VERSION = 'request-v2-reduced-v3';
|
||||
|
||||
export type SenderRecoveryState = 'pending' | 'verified' | 'failed';
|
||||
|
||||
type SenderRecoveryEnvelopeLike = {
|
||||
delivery_class?: string;
|
||||
sender_id?: string;
|
||||
sender_seal?: string;
|
||||
request_contract_version?: string;
|
||||
sender_recovery_required?: boolean;
|
||||
sender_recovery_state?: string;
|
||||
};
|
||||
|
||||
export function isCanonicalReducedRequestEnvelope(
|
||||
message: SenderRecoveryEnvelopeLike,
|
||||
): boolean {
|
||||
return (
|
||||
String(message.request_contract_version || '').trim() === REQUEST_V2_REDUCED_VERSION &&
|
||||
message.sender_recovery_required === true
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresSenderRecovery(
|
||||
message: SenderRecoveryEnvelopeLike,
|
||||
): boolean {
|
||||
if (isCanonicalReducedRequestEnvelope(message)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
String(message.sender_seal || '').trim() &&
|
||||
String(message.sender_id || '').trim().startsWith('sealed:'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSenderRecoveryState(
|
||||
message: SenderRecoveryEnvelopeLike,
|
||||
): SenderRecoveryState | undefined {
|
||||
const state = String(message.sender_recovery_state || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (state === 'pending' || state === 'verified' || state === 'failed') {
|
||||
return state;
|
||||
}
|
||||
if (isCanonicalReducedRequestEnvelope(message)) {
|
||||
return 'pending';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function shouldAllowRequestActions(
|
||||
message: Pick<
|
||||
SenderRecoveryEnvelopeLike,
|
||||
'request_contract_version' | 'sender_recovery_required' | 'sender_recovery_state'
|
||||
>,
|
||||
): boolean {
|
||||
if (!isCanonicalReducedRequestEnvelope(message)) {
|
||||
return true;
|
||||
}
|
||||
return getSenderRecoveryState(message) === 'verified';
|
||||
}
|
||||
|
||||
export function shouldKeepUnresolvedRequestVisible(
|
||||
message: Pick<
|
||||
SenderRecoveryEnvelopeLike,
|
||||
| 'delivery_class'
|
||||
| 'request_contract_version'
|
||||
| 'sender_recovery_required'
|
||||
| 'sender_recovery_state'
|
||||
>,
|
||||
): boolean {
|
||||
if (String(message.delivery_class || '').trim().toLowerCase() !== 'request') {
|
||||
return false;
|
||||
}
|
||||
const state = getSenderRecoveryState(message);
|
||||
return isCanonicalReducedRequestEnvelope(message) && (state === 'pending' || state === 'failed');
|
||||
}
|
||||
|
||||
export function shouldPromoteRecoveredSenderForKnownContact(
|
||||
resolved: RecoveredSenderSeal,
|
||||
contactId: string,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
resolved &&
|
||||
resolved.seal_verified === true &&
|
||||
String(resolved.sender_id || '').trim() === String(contactId || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPromoteRecoveredSenderForBootstrap(
|
||||
resolved: RecoveredSenderSeal,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
resolved &&
|
||||
resolved.seal_verified === true &&
|
||||
String(resolved.sender_id || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function recoverSenderSealWithFallback(options: {
|
||||
wormholeReady: boolean;
|
||||
openLocal: () => Promise<RecoveredSenderSeal>;
|
||||
openHelper: () => Promise<RecoveredSenderSeal>;
|
||||
}): Promise<RecoveredSenderSeal> {
|
||||
const localResolved = await options.openLocal();
|
||||
if (localResolved) {
|
||||
return localResolved;
|
||||
}
|
||||
if (!options.wormholeReady) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await options.openHelper();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const REQUEST_V2_SENDER_SEAL_VERSION_ERROR = 'request_sender_seal_v3_required';
|
||||
|
||||
export function requiresCanonicalRequestV2SenderSeal(options: {
|
||||
deliveryClass: 'request' | 'shared';
|
||||
useSealedSender?: boolean;
|
||||
}): boolean {
|
||||
return options.deliveryClass === 'request' && options.useSealedSender === true;
|
||||
}
|
||||
|
||||
export function ensureCanonicalRequestV2SenderSeal(senderSeal: string): string {
|
||||
const normalized = String(senderSeal || '').trim();
|
||||
if (!normalized.startsWith('v3:')) {
|
||||
throw new Error(REQUEST_V2_SENDER_SEAL_VERSION_ERROR);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { controlPlaneFetch } from '@/lib/controlPlane';
|
||||
|
||||
export interface WormholeState {
|
||||
installed: boolean;
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
ready: boolean;
|
||||
transport_tier?: string;
|
||||
transport_configured: string;
|
||||
transport_active: string;
|
||||
arti_ready?: boolean;
|
||||
proxy_active: string;
|
||||
last_error: string;
|
||||
started_at: number;
|
||||
pid: number;
|
||||
privacy_level_effective: string;
|
||||
reason: string;
|
||||
last_restart: number;
|
||||
last_start: number;
|
||||
transport: string;
|
||||
proxy: string;
|
||||
anonymous_mode?: boolean;
|
||||
anonymous_mode_ready?: boolean;
|
||||
rns_enabled?: boolean;
|
||||
rns_ready?: boolean;
|
||||
rns_configured_peers?: number;
|
||||
rns_active_peers?: number;
|
||||
rns_private_dm_direct_ready?: boolean;
|
||||
recent_private_clearnet_fallback?: boolean;
|
||||
recent_private_clearnet_fallback_at?: number;
|
||||
recent_private_clearnet_fallback_reason?: string;
|
||||
}
|
||||
|
||||
export interface WormholeSettingsSnapshot {
|
||||
enabled?: boolean;
|
||||
transport?: string;
|
||||
socks_proxy?: string;
|
||||
socks_dns?: boolean;
|
||||
anonymous_mode?: boolean;
|
||||
privacy_profile?: string;
|
||||
}
|
||||
|
||||
export interface WormholeJoinResponse {
|
||||
ok: boolean;
|
||||
identity?: {
|
||||
node_id: string;
|
||||
public_key: string;
|
||||
public_key_algo: string;
|
||||
};
|
||||
runtime?: WormholeState;
|
||||
settings?: WormholeSettingsSnapshot;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 15000;
|
||||
|
||||
let wormholeStateCache:
|
||||
| {
|
||||
value: WormholeState;
|
||||
expiresAt: number;
|
||||
inflight: Promise<WormholeState> | null;
|
||||
}
|
||||
| null = null;
|
||||
let wormholeSettingsCache:
|
||||
| {
|
||||
value: WormholeSettingsSnapshot;
|
||||
expiresAt: number;
|
||||
inflight: Promise<WormholeSettingsSnapshot> | null;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
async function parseState(res: Response): Promise<WormholeState> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || data?.message || 'Wormhole request failed');
|
||||
}
|
||||
return (await res.json()) as WormholeState;
|
||||
}
|
||||
|
||||
function resetWormholeCaches(): void {
|
||||
wormholeStateCache = null;
|
||||
wormholeSettingsCache = null;
|
||||
}
|
||||
|
||||
async function loadWormholeState(): Promise<WormholeState> {
|
||||
const res = await fetch(`${API_BASE}/api/wormhole/status`, { cache: 'no-store' });
|
||||
return parseState(res);
|
||||
}
|
||||
|
||||
async function loadWormholeSettings(): Promise<WormholeSettingsSnapshot> {
|
||||
const res = await fetch(`${API_BASE}/api/settings/wormhole`, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || data?.message || 'Wormhole settings request failed');
|
||||
}
|
||||
return (await res.json()) as WormholeSettingsSnapshot;
|
||||
}
|
||||
|
||||
export function invalidateWormholeRuntimeCache(): void {
|
||||
resetWormholeCaches();
|
||||
}
|
||||
|
||||
export async function fetchWormholeState(force: boolean = false): Promise<WormholeState> {
|
||||
const now = Date.now();
|
||||
if (!force && wormholeStateCache?.value && wormholeStateCache.expiresAt > now) {
|
||||
return wormholeStateCache.value;
|
||||
}
|
||||
if (!force && wormholeStateCache?.inflight) {
|
||||
return wormholeStateCache.inflight;
|
||||
}
|
||||
const inflight = loadWormholeState()
|
||||
.then((value) => {
|
||||
wormholeStateCache = {
|
||||
value,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
return value;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (wormholeStateCache) wormholeStateCache.inflight = null;
|
||||
throw error;
|
||||
});
|
||||
wormholeStateCache = {
|
||||
value: wormholeStateCache?.value || ({} as WormholeState),
|
||||
expiresAt: 0,
|
||||
inflight,
|
||||
};
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export async function fetchWormholeSettings(
|
||||
force: boolean = false,
|
||||
): Promise<WormholeSettingsSnapshot> {
|
||||
const now = Date.now();
|
||||
if (!force && wormholeSettingsCache?.value && wormholeSettingsCache.expiresAt > now) {
|
||||
return wormholeSettingsCache.value;
|
||||
}
|
||||
if (!force && wormholeSettingsCache?.inflight) {
|
||||
return wormholeSettingsCache.inflight;
|
||||
}
|
||||
const inflight = loadWormholeSettings()
|
||||
.then((value) => {
|
||||
wormholeSettingsCache = {
|
||||
value,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
return value;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (wormholeSettingsCache) wormholeSettingsCache.inflight = null;
|
||||
throw error;
|
||||
});
|
||||
wormholeSettingsCache = {
|
||||
value: wormholeSettingsCache?.value || {},
|
||||
expiresAt: 0,
|
||||
inflight,
|
||||
};
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export async function connectWormhole(): Promise<WormholeState> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/connect', {
|
||||
method: 'POST',
|
||||
});
|
||||
const state = await parseState(res);
|
||||
wormholeStateCache = {
|
||||
value: state,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function disconnectWormhole(): Promise<WormholeState> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/disconnect', {
|
||||
method: 'POST',
|
||||
});
|
||||
const state = await parseState(res);
|
||||
wormholeStateCache = {
|
||||
value: state,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function restartWormhole(): Promise<WormholeState> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
const state = await parseState(res);
|
||||
wormholeStateCache = {
|
||||
value: state,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function joinWormhole(): Promise<WormholeJoinResponse> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/join', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || data?.message || 'Wormhole join failed');
|
||||
}
|
||||
const data = (await res.json()) as WormholeJoinResponse;
|
||||
if (data?.runtime) {
|
||||
wormholeStateCache = {
|
||||
value: data.runtime,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
}
|
||||
if (data?.settings) {
|
||||
wormholeSettingsCache = {
|
||||
value: data.settings,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function leaveWormhole(): Promise<WormholeJoinResponse> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/leave', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.detail || data?.message || 'Wormhole leave failed');
|
||||
}
|
||||
const data = (await res.json()) as WormholeJoinResponse;
|
||||
if (data?.runtime) {
|
||||
wormholeStateCache = {
|
||||
value: data.runtime,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
}
|
||||
if (data?.settings) {
|
||||
wormholeSettingsCache = {
|
||||
value: data.settings,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
inflight: null,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import { ensureWormholeReadyForSecureAction, isWormholeReady, isWormholeSecureRequired } from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
export async function canUseWormholeBootstrap(): Promise<boolean> {
|
||||
const ready = await isWormholeReady();
|
||||
if (!ready && (await isWormholeSecureRequired())) {
|
||||
return false;
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
export async function bootstrapEncryptAccessRequest(peerId: string, plaintext: string): Promise<string> {
|
||||
await ensureWormholeReadyForSecureAction('bootstrap_encrypt');
|
||||
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-encrypt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
plaintext,
|
||||
}),
|
||||
});
|
||||
return String(data.result || '');
|
||||
}
|
||||
|
||||
export async function bootstrapDecryptAccessRequest(senderId: string, ciphertext: string): Promise<string> {
|
||||
await ensureWormholeReadyForSecureAction('bootstrap_decrypt');
|
||||
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-decrypt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sender_id: senderId,
|
||||
ciphertext,
|
||||
}),
|
||||
});
|
||||
return String(data.result || '');
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import {
|
||||
cacheWormholeIdentityDescriptor,
|
||||
getNodeIdentity,
|
||||
getPublicKeyAlgo,
|
||||
isSecureModeCached,
|
||||
purgeBrowserSigningMaterial,
|
||||
setSecureModeCached,
|
||||
signEvent,
|
||||
signWithStoredKey,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
|
||||
import { fetchWormholeSettings, fetchWormholeState } from '@/mesh/wormholeClient';
|
||||
|
||||
export interface WormholeIdentity {
|
||||
bootstrapped: boolean;
|
||||
bootstrapped_at: number;
|
||||
scope?: string;
|
||||
gate_id?: string;
|
||||
persona_id?: string;
|
||||
label?: string;
|
||||
node_id: string;
|
||||
public_key: string;
|
||||
public_key_algo: string;
|
||||
sequence: number;
|
||||
dh_pub_key?: string;
|
||||
dh_algo?: string;
|
||||
last_dh_timestamp?: number;
|
||||
bundle_fingerprint?: string;
|
||||
bundle_sequence?: number;
|
||||
bundle_registered_at?: number;
|
||||
created_at?: number;
|
||||
last_used_at?: number;
|
||||
protocol_version: string;
|
||||
}
|
||||
|
||||
export interface WormholeSignedEvent {
|
||||
node_id: string;
|
||||
public_key: string;
|
||||
public_key_algo: string;
|
||||
protocol_version: string;
|
||||
sequence: number;
|
||||
payload: Record<string, unknown>;
|
||||
signature: string;
|
||||
signature_payload: string;
|
||||
}
|
||||
|
||||
export interface WormholeSignedRawMessage {
|
||||
node_id: string;
|
||||
public_key: string;
|
||||
public_key_algo: string;
|
||||
protocol_version: string;
|
||||
signature: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmSenderToken {
|
||||
ok: boolean;
|
||||
sender_token: string;
|
||||
expires_at: number;
|
||||
delivery_class: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmSenderTokenBatch {
|
||||
ok: boolean;
|
||||
delivery_class: string;
|
||||
tokens: Array<{ sender_token: string; expires_at: number }>;
|
||||
}
|
||||
|
||||
export interface WormholeOpenedSeal {
|
||||
ok: boolean;
|
||||
sender_id: string;
|
||||
seal_verified: boolean;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
timestamp?: number;
|
||||
msg_id?: string;
|
||||
}
|
||||
|
||||
export interface WormholeBuiltSeal {
|
||||
ok: boolean;
|
||||
sender_seal: string;
|
||||
sender_id?: string;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
protocol_version?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDeadDropTokenPair {
|
||||
ok: boolean;
|
||||
peer_id: string;
|
||||
epoch: number;
|
||||
current: string;
|
||||
previous: string;
|
||||
}
|
||||
|
||||
export interface WormholePairwiseAlias {
|
||||
ok: boolean;
|
||||
peer_id: string;
|
||||
shared_alias: string;
|
||||
replaced_alias?: string;
|
||||
dm_identity_id?: string;
|
||||
identity_scope?: string;
|
||||
contact?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WormholeRotatedPairwiseAlias {
|
||||
ok: boolean;
|
||||
peer_id: string;
|
||||
active_alias: string;
|
||||
pending_alias: string;
|
||||
grace_until: number;
|
||||
dm_identity_id?: string;
|
||||
identity_scope?: string;
|
||||
contact?: Record<string, unknown>;
|
||||
rotated?: boolean;
|
||||
}
|
||||
|
||||
export interface WormholeDeadDropTokensBatch {
|
||||
ok: boolean;
|
||||
tokens: Array<{ peer_id: string; current: string; previous: string; epoch: number }>;
|
||||
}
|
||||
|
||||
export interface WormholeSasPhrase {
|
||||
ok: boolean;
|
||||
peer_id: string;
|
||||
phrase: string;
|
||||
words: number;
|
||||
}
|
||||
|
||||
export interface WormholeGatePersonasResponse {
|
||||
ok: boolean;
|
||||
gate_id: string;
|
||||
active_persona_id: string;
|
||||
personas: WormholeIdentity[];
|
||||
}
|
||||
|
||||
export interface WormholeComposedGateMessage {
|
||||
ok: boolean;
|
||||
gate_id: string;
|
||||
identity_scope?: string;
|
||||
sender_id: string;
|
||||
public_key: string;
|
||||
public_key_algo: string;
|
||||
protocol_version: string;
|
||||
sequence: number;
|
||||
signature: string;
|
||||
epoch: number;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
sender_ref: string;
|
||||
format: string;
|
||||
key_commitment?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDecryptedGateMessage {
|
||||
ok: boolean;
|
||||
gate_id: string;
|
||||
epoch: number;
|
||||
plaintext: string;
|
||||
identity_scope?: string;
|
||||
detail?: string;
|
||||
self_authored?: boolean;
|
||||
legacy?: boolean;
|
||||
}
|
||||
|
||||
export interface WormholeGateDecryptPayload {
|
||||
gate_id: string;
|
||||
epoch?: number;
|
||||
ciphertext: string;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDecryptedGateMessageBatch {
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
results: WormholeDecryptedGateMessage[];
|
||||
}
|
||||
|
||||
export interface WormholeGateKeyStatus {
|
||||
ok: boolean;
|
||||
gate_id: string;
|
||||
current_epoch: number;
|
||||
previous_epoch?: number;
|
||||
key_commitment?: string;
|
||||
previous_key_commitment?: string;
|
||||
identity_scope?: string;
|
||||
identity_node_id?: string;
|
||||
sender_ref?: string;
|
||||
has_local_access?: boolean;
|
||||
rekey_recommended?: boolean;
|
||||
rekey_recommended_reason?: string;
|
||||
rekey_recommended_at?: number;
|
||||
last_rotated_at?: number;
|
||||
last_rotation_reason?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmContactsResponse {
|
||||
ok: boolean;
|
||||
contacts: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface WormholeStatusSnapshot {
|
||||
ready?: boolean;
|
||||
running?: boolean;
|
||||
transport_tier?: string;
|
||||
transport_active?: string;
|
||||
transport_configured?: string;
|
||||
arti_ready?: boolean;
|
||||
anonymous_mode?: boolean;
|
||||
anonymous_mode_ready?: boolean;
|
||||
rns_enabled?: boolean;
|
||||
rns_ready?: boolean;
|
||||
rns_configured_peers?: number;
|
||||
rns_active_peers?: number;
|
||||
rns_private_dm_direct_ready?: boolean;
|
||||
recent_private_clearnet_fallback?: boolean;
|
||||
recent_private_clearnet_fallback_at?: number;
|
||||
recent_private_clearnet_fallback_reason?: string;
|
||||
}
|
||||
|
||||
export interface ActiveSigningContext {
|
||||
source: 'wormhole' | 'browser';
|
||||
nodeId: string;
|
||||
publicKey: string;
|
||||
publicKeyAlgo: string;
|
||||
}
|
||||
|
||||
let wormholeIdentityCache: { value: WormholeIdentity; ts: number } | null = null;
|
||||
const CACHE_TTL_MS = 3000;
|
||||
|
||||
function getBrowserSigningContext(): ActiveSigningContext | null {
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity) return null;
|
||||
return {
|
||||
source: 'browser',
|
||||
nodeId: identity.nodeId,
|
||||
publicKey: identity.publicKey,
|
||||
publicKeyAlgo: getPublicKeyAlgo(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function isWormholeReady(): Promise<boolean> {
|
||||
try {
|
||||
return Boolean((await fetchWormholeState()).ready);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWormholeStatus(): Promise<WormholeStatusSnapshot> {
|
||||
return (await fetchWormholeState()) as WormholeStatusSnapshot;
|
||||
}
|
||||
|
||||
export async function isWormholeSecureRequired(): Promise<boolean> {
|
||||
try {
|
||||
const data = await fetchWormholeSettings();
|
||||
const value = Boolean(data?.enabled);
|
||||
setSecureModeCached(value);
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[mesh] Wormhole secure-mode status unavailable, keeping cached boundary',
|
||||
error,
|
||||
);
|
||||
return isSecureModeCached();
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWormholeReadyForSecureAction(action: string): Promise<void> {
|
||||
const required = await isWormholeSecureRequired();
|
||||
if (!required) return;
|
||||
const ready = await isWormholeReady();
|
||||
if (!ready) {
|
||||
throw new Error(`wormhole_required_for_${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWormholeIdentity(): Promise<WormholeIdentity> {
|
||||
const now = Date.now();
|
||||
if (wormholeIdentityCache && now - wormholeIdentityCache.ts < CACHE_TTL_MS) {
|
||||
return wormholeIdentityCache.value;
|
||||
}
|
||||
const value = await controlPlaneJson<WormholeIdentity>('/api/wormhole/identity', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
cacheWormholeIdentityDescriptor({
|
||||
nodeId: value.node_id,
|
||||
publicKey: value.public_key,
|
||||
publicKeyAlgo: value.public_key_algo,
|
||||
});
|
||||
await purgeBrowserSigningMaterial();
|
||||
wormholeIdentityCache = { value, ts: now };
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function bootstrapWormholeIdentity(): Promise<WormholeIdentity> {
|
||||
const value = await controlPlaneJson<WormholeIdentity>('/api/wormhole/identity/bootstrap', {
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
});
|
||||
cacheWormholeIdentityDescriptor({
|
||||
nodeId: value.node_id,
|
||||
publicKey: value.public_key,
|
||||
publicKeyAlgo: value.public_key_algo,
|
||||
});
|
||||
await purgeBrowserSigningMaterial();
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function signViaWormhole(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
sequence?: number,
|
||||
gateId?: string,
|
||||
): Promise<WormholeSignedEvent> {
|
||||
return controlPlaneJson<WormholeSignedEvent>('/api/wormhole/sign', {
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: eventType,
|
||||
payload,
|
||||
sequence,
|
||||
gate_id: gateId || '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function enterWormholeGate(
|
||||
gateId: string,
|
||||
rotate: boolean = false,
|
||||
): Promise<{ ok: boolean; identity?: WormholeIdentity; detail?: string }> {
|
||||
return controlPlaneJson<{ ok: boolean; identity?: WormholeIdentity; detail?: string }>(
|
||||
'/api/wormhole/gate/enter',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
rotate,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function leaveWormholeGate(
|
||||
gateId: string,
|
||||
): Promise<{ ok: boolean; gate_id?: string; cleared?: boolean; detail?: string }> {
|
||||
return controlPlaneJson<{ ok: boolean; gate_id?: string; cleared?: boolean; detail?: string }>(
|
||||
'/api/wormhole/gate/leave',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listWormholeGatePersonas(
|
||||
gateId: string,
|
||||
): Promise<WormholeGatePersonasResponse> {
|
||||
return controlPlaneJson<WormholeGatePersonasResponse>(
|
||||
`/api/wormhole/gate/${encodeURIComponent(gateId)}/personas`,
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWormholeGatePersona(
|
||||
gateId: string,
|
||||
label: string,
|
||||
): Promise<{ ok: boolean; identity?: WormholeIdentity; detail?: string }> {
|
||||
return controlPlaneJson<{ ok: boolean; identity?: WormholeIdentity; detail?: string }>(
|
||||
'/api/wormhole/gate/persona/create',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
label,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function activateWormholeGatePersona(
|
||||
gateId: string,
|
||||
personaId: string,
|
||||
): Promise<{ ok: boolean; identity?: WormholeIdentity; detail?: string }> {
|
||||
return controlPlaneJson<{ ok: boolean; identity?: WormholeIdentity; detail?: string }>(
|
||||
'/api/wormhole/gate/persona/activate',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
persona_id: personaId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearWormholeGatePersona(
|
||||
gateId: string,
|
||||
): Promise<{ ok: boolean; identity?: WormholeIdentity; detail?: string }> {
|
||||
return controlPlaneJson<{ ok: boolean; identity?: WormholeIdentity; detail?: string }>(
|
||||
'/api/wormhole/gate/persona/clear',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function retireWormholeGatePersona(
|
||||
gateId: string,
|
||||
personaId: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
retired_persona_id?: string;
|
||||
retired_identity?: WormholeIdentity;
|
||||
active_identity?: WormholeIdentity;
|
||||
detail?: string;
|
||||
}> {
|
||||
return controlPlaneJson<{
|
||||
ok: boolean;
|
||||
retired_persona_id?: string;
|
||||
retired_identity?: WormholeIdentity;
|
||||
active_identity?: WormholeIdentity;
|
||||
detail?: string;
|
||||
}>('/api/wormhole/gate/persona/retire', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
persona_id: personaId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function composeWormholeGateMessage(
|
||||
gateId: string,
|
||||
plaintext: string,
|
||||
): Promise<WormholeComposedGateMessage> {
|
||||
return controlPlaneJson<WormholeComposedGateMessage>('/api/wormhole/gate/message/compose', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
plaintext,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWormholeGateKeyStatus(
|
||||
gateId: string,
|
||||
): Promise<WormholeGateKeyStatus> {
|
||||
return controlPlaneJson<WormholeGateKeyStatus>(
|
||||
`/api/wormhole/gate/${encodeURIComponent(gateId)}/key`,
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function rotateWormholeGateKey(
|
||||
gateId: string,
|
||||
reason: string = 'manual_rotate',
|
||||
): Promise<WormholeGateKeyStatus & { rotated?: boolean; rotation_reason?: string }> {
|
||||
return controlPlaneJson<WormholeGateKeyStatus & { rotated?: boolean; rotation_reason?: string }>(
|
||||
'/api/wormhole/gate/key/rotate',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
reason,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function decryptWormholeGateMessage(
|
||||
gateId: string,
|
||||
epoch: number,
|
||||
ciphertext: string,
|
||||
nonce: string,
|
||||
senderRef: string,
|
||||
): Promise<WormholeDecryptedGateMessage> {
|
||||
return controlPlaneJson<WormholeDecryptedGateMessage>('/api/wormhole/gate/message/decrypt', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
epoch,
|
||||
ciphertext,
|
||||
nonce,
|
||||
sender_ref: senderRef,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptWormholeGateMessages(
|
||||
messages: WormholeGateDecryptPayload[],
|
||||
): Promise<WormholeDecryptedGateMessageBatch> {
|
||||
return controlPlaneJson<WormholeDecryptedGateMessageBatch>('/api/wormhole/gate/messages/decrypt', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: messages.map((message) => ({
|
||||
gate_id: message.gate_id,
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: message.ciphertext,
|
||||
nonce: message.nonce || '',
|
||||
sender_ref: message.sender_ref || '',
|
||||
format: message.format || 'mls1',
|
||||
gate_envelope: message.gate_envelope || '',
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function signRawViaWormhole(message: string): Promise<WormholeSignedRawMessage> {
|
||||
return controlPlaneJson<WormholeSignedRawMessage>('/api/wormhole/sign-raw', {
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerWormholeDmKey(): Promise<WormholeIdentity & { ok: boolean; detail?: string }> {
|
||||
return controlPlaneJson<WormholeIdentity & { ok: boolean; detail?: string }>(
|
||||
'/api/wormhole/dm/register-key',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function issueWormholeDmSenderToken(
|
||||
recipientId: string,
|
||||
deliveryClass: 'request' | 'shared',
|
||||
recipientToken?: string,
|
||||
): Promise<WormholeDmSenderToken> {
|
||||
return controlPlaneJson<WormholeDmSenderToken>('/api/wormhole/dm/sender-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
delivery_class: deliveryClass,
|
||||
recipient_token: recipientToken || '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function issueWormholeDmSenderTokens(
|
||||
recipientId: string,
|
||||
deliveryClass: 'request' | 'shared',
|
||||
recipientToken?: string,
|
||||
count: number = 3,
|
||||
): Promise<WormholeDmSenderTokenBatch> {
|
||||
return controlPlaneJson<WormholeDmSenderTokenBatch>('/api/wormhole/dm/sender-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
delivery_class: deliveryClass,
|
||||
recipient_token: recipientToken || '',
|
||||
count,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function openWormholeSenderSeal(
|
||||
senderSeal: string,
|
||||
candidateDhPub: string,
|
||||
recipientId: string,
|
||||
expectedMsgId: string,
|
||||
): Promise<WormholeOpenedSeal> {
|
||||
return controlPlaneJson<WormholeOpenedSeal>('/api/wormhole/dm/open-seal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sender_seal: senderSeal,
|
||||
candidate_dh_pub: candidateDhPub,
|
||||
recipient_id: recipientId,
|
||||
expected_msg_id: expectedMsgId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildWormholeSenderSeal(
|
||||
recipientId: string,
|
||||
recipientDhPub: string,
|
||||
msgId: string,
|
||||
timestamp: number,
|
||||
): Promise<WormholeBuiltSeal> {
|
||||
return controlPlaneJson<WormholeBuiltSeal>('/api/wormhole/dm/build-seal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
recipient_dh_pub: recipientDhPub,
|
||||
msg_id: msgId,
|
||||
timestamp,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deriveWormholeDeadDropTokenPair(
|
||||
peerId: string,
|
||||
peerDhPub: string,
|
||||
): Promise<WormholeDeadDropTokenPair> {
|
||||
return controlPlaneJson<WormholeDeadDropTokenPair>('/api/wormhole/dm/dead-drop-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_dh_pub: peerDhPub,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function issueWormholePairwiseAlias(
|
||||
peerId: string,
|
||||
peerDhPub: string,
|
||||
): Promise<WormholePairwiseAlias> {
|
||||
return controlPlaneJson<WormholePairwiseAlias>('/api/wormhole/dm/pairwise-alias', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_dh_pub: peerDhPub,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateWormholePairwiseAlias(
|
||||
peerId: string,
|
||||
peerDhPub: string,
|
||||
graceMs: number,
|
||||
): Promise<WormholeRotatedPairwiseAlias> {
|
||||
return controlPlaneJson<WormholeRotatedPairwiseAlias>('/api/wormhole/dm/pairwise-alias/rotate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_dh_pub: peerDhPub,
|
||||
grace_ms: graceMs,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deriveWormholeDeadDropTokens(
|
||||
contacts: Array<{ peer_id: string; peer_dh_pub: string }>,
|
||||
limit: number = 24,
|
||||
): Promise<WormholeDeadDropTokensBatch> {
|
||||
return controlPlaneJson<WormholeDeadDropTokensBatch>('/api/wormhole/dm/dead-drop-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contacts,
|
||||
limit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deriveWormholeSasPhrase(
|
||||
peerId: string,
|
||||
peerDhPub: string,
|
||||
words: number = 8,
|
||||
): Promise<WormholeSasPhrase> {
|
||||
return controlPlaneJson<WormholeSasPhrase>('/api/wormhole/dm/sas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
peer_dh_pub: peerDhPub,
|
||||
words,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWormholeDmContacts(): Promise<WormholeDmContactsResponse> {
|
||||
return controlPlaneJson<WormholeDmContactsResponse>('/api/wormhole/dm/contacts');
|
||||
}
|
||||
|
||||
export async function putWormholeDmContact(
|
||||
peerId: string,
|
||||
contact: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; peer_id: string; contact: Record<string, unknown> }> {
|
||||
return controlPlaneJson('/api/wormhole/dm/contact', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
contact,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWormholeDmContact(
|
||||
peerId: string,
|
||||
): Promise<{ ok: boolean; peer_id: string; deleted: boolean }> {
|
||||
return controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getActiveSigningContext(): Promise<ActiveSigningContext | null> {
|
||||
const secureRequired = await isWormholeSecureRequired();
|
||||
if (await isWormholeReady()) {
|
||||
const identity = await fetchWormholeIdentity();
|
||||
if (identity?.node_id && identity?.public_key) {
|
||||
return {
|
||||
source: 'wormhole',
|
||||
nodeId: identity.node_id,
|
||||
publicKey: identity.public_key,
|
||||
publicKeyAlgo: identity.public_key_algo,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (secureRequired) {
|
||||
return null;
|
||||
}
|
||||
return getBrowserSigningContext();
|
||||
}
|
||||
|
||||
export async function signMeshEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
sequence: number,
|
||||
options?: { gateId?: string },
|
||||
): Promise<{ signature: string; context: ActiveSigningContext; protocolVersion: string; sequence: number }> {
|
||||
await ensureWormholeReadyForSecureAction(`sign_${eventType}`);
|
||||
const context = await getActiveSigningContext();
|
||||
if (!context) {
|
||||
throw new Error('No identity available for signing');
|
||||
}
|
||||
if (context.source === 'wormhole') {
|
||||
try {
|
||||
const signed = await signViaWormhole(
|
||||
eventType,
|
||||
payload,
|
||||
sequence,
|
||||
options?.gateId,
|
||||
);
|
||||
return {
|
||||
signature: signed.signature,
|
||||
context: {
|
||||
source: 'wormhole',
|
||||
nodeId: signed.node_id,
|
||||
publicKey: signed.public_key,
|
||||
publicKeyAlgo: signed.public_key_algo,
|
||||
},
|
||||
protocolVersion: signed.protocol_version,
|
||||
sequence: signed.sequence,
|
||||
};
|
||||
} catch {
|
||||
if (await isWormholeSecureRequired()) {
|
||||
throw new Error(`wormhole_sign_failed_${eventType}`);
|
||||
}
|
||||
console.warn(
|
||||
'[PRIVACY] Wormhole signing failed for %s — falling back to browser-side signing. ' +
|
||||
'Private key material is active in browser memory. Enable secure mode to block this fallback.',
|
||||
eventType,
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('sb:signing-fallback', { detail: { eventType } }));
|
||||
}
|
||||
const browserContext = getBrowserSigningContext();
|
||||
if (!browserContext) throw new Error('No identity available for signing');
|
||||
return {
|
||||
signature: await signEvent(eventType, browserContext.nodeId, sequence, payload),
|
||||
context: browserContext,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
signature: await signEvent(eventType, context.nodeId, sequence, payload),
|
||||
context,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
export async function signRawMeshMessage(
|
||||
message: string,
|
||||
): Promise<{ signature: string; context: ActiveSigningContext; protocolVersion: string }> {
|
||||
await ensureWormholeReadyForSecureAction('sign_raw');
|
||||
const context = await getActiveSigningContext();
|
||||
if (!context) {
|
||||
throw new Error('No identity available for signing');
|
||||
}
|
||||
if (context.source === 'wormhole') {
|
||||
try {
|
||||
const signed = await signRawViaWormhole(message);
|
||||
return {
|
||||
signature: signed.signature,
|
||||
context: {
|
||||
source: 'wormhole',
|
||||
nodeId: signed.node_id,
|
||||
publicKey: signed.public_key,
|
||||
publicKeyAlgo: signed.public_key_algo,
|
||||
},
|
||||
protocolVersion: signed.protocol_version,
|
||||
};
|
||||
} catch {
|
||||
if (await isWormholeSecureRequired()) {
|
||||
throw new Error('wormhole_sign_raw_failed');
|
||||
}
|
||||
console.warn(
|
||||
'[PRIVACY] Wormhole raw signing failed — falling back to browser-side signing. ' +
|
||||
'Private key material is active in browser memory. Enable secure mode to block this fallback.',
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('sb:signing-fallback', { detail: { eventType: 'sign_raw' } }),
|
||||
);
|
||||
}
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity) throw new Error('No identity available for signing');
|
||||
const sig = await signWithStoredKey(message).catch(() => {
|
||||
throw new Error('browser_signing_key_unavailable');
|
||||
});
|
||||
return {
|
||||
signature: sig,
|
||||
context: {
|
||||
source: 'browser',
|
||||
nodeId: identity.nodeId,
|
||||
publicKey: identity.publicKey,
|
||||
publicKeyAlgo: getPublicKeyAlgo(),
|
||||
},
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity) throw new Error('No identity available for signing');
|
||||
const sig = await signWithStoredKey(message).catch(() => {
|
||||
throw new Error('browser_signing_key_unavailable');
|
||||
});
|
||||
return {
|
||||
signature: sig,
|
||||
context,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user