0cee4e9ef31c
delete orphaned sync files: src/sync/, capacitor-sync-transport, sync-identity-glue, ml-detector, DiscoveredPeer type
- time
- 2026-05-07 14:38 UTC · 5 months ago
- machine
- koh2
- parent
- 18b8a770ad1e
↓ peach-browser-0cee4e9e.tar.gzatlas at this save
files changed6 files
src/background/ml-detector.ts+0 −128src/background/sync-identity-glue.ts+0 −30src/shared/capacitor-sync-transport.ts+0 −226src/shared/sync-types.ts+0 −22src/sync/manifest.ts+0 −85src/sync/noise.ts+0 −192src/background/ml-detector.ts+0 −128
@@ -1,128 +0,0 @@1-import * as ort from "onnxruntime-web";2-import type {3- FieldPrediction,4- FieldType,5- MLFieldFeatures,6-} from "../shared/ml";7-8-const LABELS: FieldType[] = ["username", "password", "email", "totp", "none"];9-10-class BackgroundMLDetector {11- private session: ort.InferenceSession | null = null;12- private isLoading = false;13- private loadPromise: Promise<void> | null = null;14-15- async init(modelUrl?: string): Promise<void> {16- if (this.session) return;17- if (this.isLoading && this.loadPromise) {18- await this.loadPromise;19- return;20- }21-22- this.isLoading = true;23- this.loadPromise = this.loadModel(modelUrl);24- try {25- await this.loadPromise;26- } finally {27- this.isLoading = false;28- }29- }30-31- private async loadModel(modelUrl?: string): Promise<void> {32- ort.env.wasm.wasmPaths = chrome.runtime.getURL("assets/");33- ort.env.wasm.numThreads = 1;34- ort.env.wasm.simd = true;35-36- const url = modelUrl || chrome.runtime.getURL("models/form_detector.onnx");37- this.session = await ort.InferenceSession.create(url);38- }39-40- async predict(features: MLFieldFeatures): Promise<FieldPrediction> {41- if (!this.session) {42- await this.init();43- }44- if (!this.session) {45- throw new Error("ML model not initialized");46- }47-48- const vector = this.featuresToVector(features);49- const input = new ort.Tensor("float32", vector, [1, 45]);50- const inputName = this.session.inputNames[0];51- const outputName = this.session.outputNames[0];52- const results = await this.session.run({ [inputName]: input });53- const probs = results[outputName].data as Float32Array;54-55- let maxIndex = 0;56- let maxProb = probs[0];57- for (let i = 1; i < probs.length; i += 1) {58- if (probs[i] > maxProb) {59- maxProb = probs[i];60- maxIndex = i;61- }62- }63-64- return {65- type: LABELS[maxIndex],66- confidence: maxProb,67- probabilities: {68- username: probs[0],69- password: probs[1],70- email: probs[2],71- totp: probs[3],72- none: probs[4],73- },74- };75- }76-77- private featuresToVector(features: MLFieldFeatures): Float32Array {78- return new Float32Array([79- features.type_text,80- features.type_email,81- features.type_password,82- features.type_tel,83- features.type_number,84- features.type_search,85- features.type_url,86- features.type_other,87- features.auto_username,88- features.auto_email,89- features.auto_current_password,90- features.auto_new_password,91- features.auto_one_time_code,92- features.auto_off,93- features.auto_other,94- features.name_has_user,95- features.name_has_login,96- features.name_has_email,97- features.name_has_pass,98- features.name_length,99- features.id_has_user,100- features.id_has_login,101- features.id_has_email,102- features.id_has_pass,103- features.id_length,104- features.placeholder_has_user,105- features.placeholder_has_email,106- features.placeholder_has_pass,107- features.placeholder_length,108- features.aria_label_has_user,109- features.aria_label_has_email,110- features.aria_label_has_pass,111- features.aria_label_length,112- features.parent_is_form,113- features.parent_is_div,114- features.parent_is_section,115- features.sibling_count,116- features.has_password_sibling,117- features.has_email_sibling,118- features.form_has_submit,119- features.form_action_has_login,120- features.is_required,121- features.has_placeholder,122- features.has_aria_label,123- features.inputmode_numeric,124- ]);125- }126-}127-128-export const backgroundMLDetector = new BackgroundMLDetector();src/background/sync-identity-glue.ts+0 −30
@@ -1,30 +0,0 @@1-/**2- * Local device id helpers shared between the lifecycle tick and the3- * 6-digit pairing flow. Both need the same base64url-encoded 16-byte id4- * the native host expects in `Advertise` / `StartPairingServer`.5- */6-7-const DEVICE_ID_KEY = "peach_device_id";8-9-export async function getOrCreateLocalDeviceId(): Promise<string> {10- try {11- const stored = await chrome.storage.local.get(DEVICE_ID_KEY);12- const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];13- if (typeof value === "string" && value) return value;14- } catch {15- // fall through to generation16- }17- const random = crypto.getRandomValues(new Uint8Array(16));18- let binary = "";19- for (const byte of random) binary += String.fromCharCode(byte);20- const id = btoa(binary)21- .replace(/\+/g, "-")22- .replace(/\//g, "_")23- .replace(/=+$/g, "");24- try {25- await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });26- } catch {27- // best-effort28- }29- return id;30-}src/shared/capacitor-sync-transport.ts+0 −226
@@ -1,226 +0,0 @@1-/**2- * Capacitor (Android) sync transport.3- *4- * The Android WebView cannot use the native messaging host (stdio is for5- * extension service workers only) and cannot open raw TCP sockets. The6- * desktop host exposes the same Noise NK protocol over WebSocket at7- * `tcp_port + 1` so this side speaks WebSocket binary frames carrying the8- * exact same bytes as the TCP path.9- *10- * Bytes-on-the-wire MUST stay identical to the native TCP/WS framing — see11- * `native/peach-sync-host/src/frame.rs`. A WebSocket Binary message carries12- * one logical Noise/auth/transport frame; the u32 length prefix used on TCP13- * is absent here because tungstenite already frames messages.14- */15-16-import { noiseNKInitiator, performInitiatorAuth, type NoiseNKSession } from "./noise-js";17-import type { SyncSessionTransport } from "../background/sync-coordinator";18-import type { DiscoveredPeer } from "./sync-types";19-20-const HANDSHAKE_TIMEOUT_MS = 10_000;21-22-function hexToBytes(hex: string): Uint8Array {23- if (hex.length % 2 !== 0) throw new Error("invalid hex");24- const out = new Uint8Array(hex.length / 2);25- for (let i = 0; i < hex.length; i += 2) {26- out[i / 2] = parseInt(hex.slice(i, i + 2), 16);27- }28- return out;29-}30-31-function base64urlEncode(bytes: Uint8Array): string {32- let binary = "";33- for (const byte of bytes) binary += String.fromCharCode(byte);34- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");35-}36-37-function base64urlDecode(value: string): Uint8Array {38- const padded = value39- .replace(/-/g, "+")40- .replace(/_/g, "/")41- .padEnd(Math.ceil(value.length / 4) * 4, "=");42- const binary = atob(padded);43- const out = new Uint8Array(binary.length);44- for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);45- return out;46-}47-48-/**49- * Open a WebSocket to the peer, complete the Noise NK handshake + auth, and50- * return a SyncSessionTransport that runBlobSyncSession can drive.51- */52-export async function createCapacitorSyncTransport(53- peer: DiscoveredPeer,54- ourPrivateKey: Uint8Array,55- ourPublicKey: Uint8Array,56-): Promise<SyncSessionTransport> {57- if (peer.wsPort === undefined) {58- throw new Error("peer did not advertise a WebSocket port");59- }60- const wsUrl = `ws://${peer.host}:${peer.wsPort}/`;61-62- const ws = await new Promise<WebSocket>((resolve, reject) => {63- const socket = new WebSocket(wsUrl);64- socket.binaryType = "arraybuffer";65- const timer = setTimeout(66- () => {67- try { socket.close(); } catch { /* best-effort */ }68- reject(new Error("WebSocket open timeout"));69- },70- HANDSHAKE_TIMEOUT_MS,71- );72- socket.addEventListener("open", () => {73- clearTimeout(timer);74- resolve(socket);75- });76- socket.addEventListener("error", () => {77- clearTimeout(timer);78- reject(new Error(`Cannot connect to ${wsUrl}`));79- });80- });81-82- // Single-message receive queue. The Noise handshake / auth round-trips83- // alternate sends and receives in a strict order; any frame that arrives84- // while no pending receiver exists is queued for the next caller.85- const queue: Uint8Array[] = [];86- let pending: ((data: Uint8Array) => void) | null = null;87- let pendingReject: ((err: Error) => void) | null = null;88- let closed = false;89- let lastError: string | null = null;90-91- ws.addEventListener("message", (event) => {92- const data = new Uint8Array(event.data as ArrayBuffer);93- if (pending) {94- const resolve = pending;95- pending = null;96- pendingReject = null;97- resolve(data);98- } else {99- queue.push(data);100- }101- });102- ws.addEventListener("close", () => {103- closed = true;104- if (pendingReject) {105- const reject = pendingReject;106- pending = null;107- pendingReject = null;108- reject(new Error("WebSocket closed during handshake"));109- }110- });111- ws.addEventListener("error", () => {112- lastError = "websocket_error";113- if (pendingReject) {114- const reject = pendingReject;115- pending = null;116- pendingReject = null;117- reject(new Error("WebSocket error"));118- }119- });120-121- function rawSend(bytes: Uint8Array): Promise<void> {122- if (closed) return Promise.reject(new Error("WebSocket closed"));123- // Force a fresh ArrayBuffer copy so the WebSocket type checker doesn't124- // hit the SharedArrayBuffer-or-ArrayBuffer ambiguity that some Uint8Array125- // typings expose.126- const copy = new Uint8Array(bytes);127- ws.send(copy.buffer);128- return Promise.resolve();129- }130-131- function rawReceive(): Promise<Uint8Array> {132- if (queue.length > 0) return Promise.resolve(queue.shift()!);133- if (closed) return Promise.reject(new Error("WebSocket closed"));134- return new Promise<Uint8Array>((resolve, reject) => {135- pending = resolve;136- pendingReject = reject;137- });138- }139-140- const responderPublicKey = hexToBytes(peer.x25519PublicKey);141-142- // 1) Noise NK handshake bootstrap143- const session: NoiseNKSession = await noiseNKInitiator(144- responderPublicKey,145- rawSend,146- rawReceive,147- );148-149- // 2) Auth challenge/proof — encrypted via the post-handshake transport150- const sendEncrypted = async (data: Uint8Array): Promise<void> => {151- await rawSend(session.encrypt(data));152- };153- const receiveEncrypted = async (): Promise<Uint8Array> => {154- return session.decrypt(await rawReceive());155- };156-157- await performInitiatorAuth(158- session,159- ourPrivateKey,160- ourPublicKey,161- responderPublicKey,162- sendEncrypted,163- receiveEncrypted,164- );165-166- // 3) Hand off to runBlobSyncSession via SyncSessionTransport.167- // The session-coordinator framing is base64url-encoded JSON over Noise.168- // We bridge it: outgoing frame string → bytes(base64url-decode) → encrypt169- // → ws.send. Incoming ws.message → decrypt → bytes → base64url-encode →170- // hand to onFrame handlers as a string.171- const frameHandlers: Array<(frame: string) => void> = [];172- const errorHandlers: Array<(message: string) => void> = [];173- const closeHandlers: Array<() => void> = [];174-175- // Replace the message listener with one that decrypts every frame and176- // routes it to onFrame. Drain anything queued from the auth round.177- ws.addEventListener("message", (event) => {178- try {179- const cipher = new Uint8Array(event.data as ArrayBuffer);180- const plain = session.decrypt(cipher);181- const frame = base64urlEncode(plain);182- for (const h of frameHandlers) h(frame);183- } catch (err) {184- const msg = err instanceof Error ? err.message : String(err);185- for (const h of errorHandlers) h(msg);186- }187- });188- ws.addEventListener("close", () => {189- for (const h of closeHandlers) h();190- });191- ws.addEventListener("error", () => {192- const msg = lastError || "websocket_error";193- for (const h of errorHandlers) h(msg);194- });195-196- return {197- send: async (frame: string) => {198- const plain = base64urlDecode(frame);199- await rawSend(session.encrypt(plain));200- },201- onFrame: (handler) => {202- frameHandlers.push(handler);203- return () => {204- const i = frameHandlers.indexOf(handler);205- if (i >= 0) frameHandlers.splice(i, 1);206- };207- },208- onError: (handler) => {209- errorHandlers.push(handler);210- return () => {211- const i = errorHandlers.indexOf(handler);212- if (i >= 0) errorHandlers.splice(i, 1);213- };214- },215- onClose: (handler) => {216- closeHandlers.push(handler);217- return () => {218- const i = closeHandlers.indexOf(handler);219- if (i >= 0) closeHandlers.splice(i, 1);220- };221- },222- close: () => {223- try { ws.close(); } catch { /* best-effort */ }224- },225- };226-}src/shared/sync-types.ts+0 −22
@@ -105,25 +105,3 @@105105 */106106 connectionId?: string;107107 }108-109-/**110- * A peer the native sync host has seen on mDNS. The popup pairing flow lists111- * these and routes a tap to `INITIATE_PAIR_WITH_PEER` with the same shape.112- *113- * `alreadyPaired` is computed against `getPairedDevices()` at broadcast time114- * so the popup can render "Already paired ✓" without a second round-trip.115- */116-export interface DiscoveredPeer {117- deviceId: string;118- deviceName: string;119- host: string;120- port: number;121- /**122- * WebSocket port advertised by the peer (mDNS `wsport`). The Capacitor123- * Android bridge connects through this; the desktop extension uses raw124- * TCP via the native messaging host and ignores it.125- */126- wsPort?: number;127- x25519PublicKey: string;128- alreadyPaired: boolean;129-}src/sync/manifest.ts+0 −85
@@ -1,85 +0,0 @@1-import { VaultEntry } from "../shared/types";2-3-export interface ManifestEntry {4- id: string;5- modifiedAt: number;6- deletedAt?: number;7- hash: string;8-}9-10-export interface ManifestDiffResult {11- sendToPeer: ManifestEntry[];12- requestFromPeer: string[];13- conflicts: ManifestEntry[];14-}15-16-const GC_TTL_MS = 90 * 24 * 60 * 60 * 1000;17-18-export function diffManifests(19- localManifest: ManifestEntry[],20- remoteManifest: ManifestEntry[]21-): ManifestDiffResult {22- const localMap = new Map<string, ManifestEntry>(localManifest.map((e) => [e.id, e]));23- const remoteMap = new Map<string, ManifestEntry>(remoteManifest.map((e) => [e.id, e]));24-25- const sendToPeer: ManifestEntry[] = [];26- const requestFromPeer: string[] = [];27- const conflicts: ManifestEntry[] = [];28-29- const allIds = new Set([...localMap.keys(), ...remoteMap.keys()]);30-31- for (const id of allIds) {32- const local = localMap.get(id);33- const remote = remoteMap.get(id);34-35- if (local && !remote) {36- sendToPeer.push(local);37- } else if (!local && remote) {38- requestFromPeer.push(id);39- } else if (local && remote) {40- if (local.modifiedAt === remote.modifiedAt) {41- if (local.hash !== remote.hash) {42- conflicts.push(resolveConflict(local, remote));43- }44- } else if (local.modifiedAt > remote.modifiedAt) {45- sendToPeer.push(local);46- } else {47- requestFromPeer.push(id);48- }49- }50- }51-52- return { sendToPeer, requestFromPeer, conflicts };53-}54-55-export function resolveConflict(a: ManifestEntry, b: ManifestEntry): ManifestEntry {56- return a.id >= b.id ? a : b;57-}58-59-export function gcTombstones(entries: ManifestEntry[], nowMs: number): ManifestEntry[] {60- return entries.filter(61- (e) => !(e.deletedAt && e.deletedAt > 0 && nowMs - e.deletedAt > GC_TTL_MS)62- );63-}64-65-export async function contentHash(entry: VaultEntry): Promise<string> {66- const { id: _id, modifiedAt: _modifiedAt, deletedAt: _deletedAt, ...rest } = entry;67- const json = JSON.stringify(rest);68- const encoded = new TextEncoder().encode(json);69- const digest = await crypto.subtle.digest("SHA-256", encoded);70- const bytes = new Uint8Array(digest).slice(0, 16);71- return Array.from(bytes)72- .map((b) => b.toString(16).padStart(2, "0"))73- .join("");74-}75-76-export async function buildManifest(entries: VaultEntry[]): Promise<ManifestEntry[]> {77- return Promise.all(78- entries.map(async (entry) => ({79- id: entry.id,80- modifiedAt: entry.modifiedAt,81- deletedAt: entry.deletedAt,82- hash: await contentHash(entry),83- }))84- );85-}src/sync/noise.ts+0 −192
@@ -1,192 +0,0 @@1-/**2- * Noise NK State Machine for Peach Sync3- *4- * Pattern: Noise NK5- * DH: X255196- * Cipher: ChaCha20-Poly13057- * Hash: BLAKE2s8- *9- * NK Pattern:10- * <- s (pre-message: responder static public key)11- * ...12- * -> e, es (initiator sends ephemeral key, performs DH with responder static)13- * <- e, ee (responder sends ephemeral key, performs DH)14- */15-16-import { x25519 } from "@noble/curves/ed25519.js";17-import { chacha20poly1305 } from "@noble/ciphers/chacha.js";18-import { blake2s } from "@noble/hashes/blake2.js";19-import { randomBytes } from "@noble/hashes/utils.js";20-21-export interface KeyPair {22- publicKey: Uint8Array;23- privateKey: Uint8Array;24-}25-26-export interface NoiseResult {27- output: Uint8Array | null;28- canSend: boolean;29- isComplete: boolean;30-}31-32-export class NoiseNKStateMachine {33- private staticKeyPair: KeyPair;34- private ephemeralKeyPair: KeyPair | null = null;35- private remoteEphemeralKey: Uint8Array | null = null;36- private readonly isInitiator: boolean;37- private remoteStaticKey: Uint8Array | null = null;38- private sendNonce = 0;39- private recvNonce = 0;40- private sessionKey: Uint8Array | null = null;41- private handshakeComplete = false;42- private canSendMessages = false;43-44- constructor(staticKeyPair: KeyPair, isInitiator: boolean) {45- this.staticKeyPair = staticKeyPair;46- this.isInitiator = isInitiator;47- }48-49- setRemoteStaticKey(key: Uint8Array): void {50- if (this.isInitiator) {51- this.remoteStaticKey = key;52- }53- }54-55- private deriveSessionKey(dhOutputs: Uint8Array[]): Uint8Array {56- const totalLength = dhOutputs.reduce((sum, arr) => sum + arr.length, 0);57- const concat = new Uint8Array(totalLength);58- let offset = 0;59- for (const output of dhOutputs) {60- concat.set(output, offset);61- offset += output.length;62- }63- return blake2s(concat, { dkLen: 32 });64- }65-66- private performDH(localPrivate: Uint8Array, remotePublic: Uint8Array): Uint8Array {67- return x25519.getSharedSecret(localPrivate, remotePublic);68- }69-70- private generateEphemeralKeyPair(): KeyPair {71- const privateKey = randomBytes(32);72- const publicKey = x25519.getPublicKey(privateKey);73- return { publicKey, privateKey };74- }75-76- private encryptWithKey(key: Uint8Array, plaintext: Uint8Array, nonce: bigint): Uint8Array {77- const nonceBytes = new Uint8Array(12);78- const view = new DataView(nonceBytes.buffer);79- view.setBigUint64(4, nonce, true);80- const cipher = chacha20poly1305(key, nonceBytes);81- return cipher.encrypt(plaintext);82- }83-84- private decryptWithKey(key: Uint8Array, ciphertext: Uint8Array, nonce: bigint): Uint8Array {85- const nonceBytes = new Uint8Array(12);86- const view = new DataView(nonceBytes.buffer);87- view.setBigUint64(4, nonce, true);88- const cipher = chacha20poly1305(key, nonceBytes);89- return cipher.decrypt(ciphertext);90- }91-92- readMessage(data: Uint8Array): NoiseResult {93- if (this.handshakeComplete) {94- return { output: null, canSend: this.canSendMessages, isComplete: true };95- }96-97- if (this.isInitiator) {98- if (!this.remoteStaticKey) {99- throw new Error("Remote static key not set");100- }101-102- if (this.ephemeralKeyPair === null) {103- this.ephemeralKeyPair = this.generateEphemeralKeyPair();104- const e = this.ephemeralKeyPair.publicKey;105- const es = this.performDH(this.ephemeralKeyPair.privateKey, this.remoteStaticKey);106- const key = this.deriveSessionKey([es]);107- const output = this.encryptWithKey(key, data, BigInt(this.sendNonce));108- this.sendNonce++;109- return { output, canSend: true, isComplete: false };110- } else {111- this.remoteEphemeralKey = data;112- const ee = this.performDH(this.ephemeralKeyPair.privateKey, this.remoteEphemeralKey);113- this.sessionKey = this.deriveSessionKey([ee]);114- this.handshakeComplete = true;115- this.canSendMessages = true;116- return { output: null, canSend: true, isComplete: true };117- }118- } else {119- if (this.remoteEphemeralKey === null) {120- this.remoteEphemeralKey = data;121- const e = this.staticKeyPair.publicKey;122- const ee = this.performDH(this.staticKeyPair.privateKey, this.remoteEphemeralKey);123- this.sessionKey = this.deriveSessionKey([ee]);124- return { output: e, canSend: true, isComplete: false };125- } else {126- const es = this.performDH(this.staticKeyPair.privateKey, this.remoteEphemeralKey);127- const key = this.deriveSessionKey([es]);128- const output = this.decryptWithKey(key, data, BigInt(this.recvNonce));129- this.recvNonce++;130- this.sessionKey = this.deriveSessionKey([es, this.remoteEphemeralKey]);131- this.handshakeComplete = true;132- this.canSendMessages = true;133- return { output, canSend: true, isComplete: true };134- }135- }136- }137-138- writeMessage(payload: Uint8Array): Uint8Array {139- if (this.handshakeComplete) {140- return this.encrypt(payload);141- }142-143- if (this.isInitiator) {144- if (this.ephemeralKeyPair === null) {145- this.ephemeralKeyPair = this.generateEphemeralKeyPair();146- return this.ephemeralKeyPair.publicKey;147- } else if (this.remoteEphemeralKey === null) {148- if (!this.remoteStaticKey) {149- throw new Error("Remote static key not set");150- }151- const es = this.performDH(this.ephemeralKeyPair.privateKey, this.remoteStaticKey);152- const key = this.deriveSessionKey([es]);153- return this.encryptWithKey(key, payload, BigInt(this.sendNonce));154- }155- throw new Error("Invalid handshake state");156- } else {157- if (this.remoteEphemeralKey === null) {158- this.remoteEphemeralKey = payload;159- return this.staticKeyPair.publicKey;160- } else {161- const es = this.performDH(this.staticKeyPair.privateKey, this.remoteEphemeralKey);162- const key = this.deriveSessionKey([es]);163- this.sessionKey = key;164- this.handshakeComplete = true;165- this.canSendMessages = true;166- return this.encryptWithKey(key, payload, BigInt(this.sendNonce));167- }168- }169- }170-171- getSessionKey(): Uint8Array | null {172- return this.sessionKey;173- }174-175- encrypt(plaintext: Uint8Array): Uint8Array {176- if (!this.sessionKey) {177- throw new Error("Session key not available");178- }179- const ciphertext = this.encryptWithKey(this.sessionKey, plaintext, BigInt(this.sendNonce));180- this.sendNonce++;181- return ciphertext;182- }183-184- decrypt(ciphertext: Uint8Array): Uint8Array {185- if (!this.sessionKey) {186- throw new Error("Session key not available");187- }188- const plaintext = this.decryptWithKey(this.sessionKey, ciphertext, BigInt(this.recvNonce));189- this.recvNonce++;190- return plaintext;191- }192-}