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:
anoracleofra-code
2026-03-26 05:58:04 -06:00
parent d363013742
commit 668ce16dc7
363 changed files with 170430 additions and 23203 deletions
@@ -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,
);
}
+50
View File
@@ -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';
}
+112
View File
@@ -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;
}
+522
View File
@@ -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;
+670
View File
@@ -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();
}
+208
View File
@@ -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);
}
+693
View File
@@ -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();
}
+164
View File
@@ -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 */
}
}
}
+169
View File
@@ -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
+57
View File
@@ -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 */
}
}
+56
View File
@@ -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);
}
+65
View File
@@ -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 };
+136
View File
@@ -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;
}
+234
View File
@@ -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('|');
}
+110
View File
@@ -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(' ');
}
+255
View File
@@ -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);
}
+121
View File
@@ -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;
}
+259
View File
@@ -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 || '');
}
+907
View File
@@ -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,
};
}