7d8739461dec
feat: Add sync wire codec (v2.13.14)
- time
- 2026-04-30 14:00 UTC · 6 months ago
- machine
- koh2
- parent
- e6e751939095
↓ peach-browser-7d873946.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-wire.test.ts+72 −0src/shared/sync-wire.ts+114 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.13",4+ "version": "2.13.14",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.13",4+ "version": "2.13.14",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.13.13",3+ "version": "2.13.14",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/shared/sync-wire.test.ts+72 −0
@@ -0,0 +1,72 @@1+import { describe, expect, it } from "vitest";2+3+import { decodeSyncMessage, encodeSyncMessage, isSyncMessage } from "./sync-wire";4+import type { SyncMessage } from "./sync-types";5+6+describe("sync wire codec", () => {7+ it("round-trips sync messages through base64url JSON", () => {8+ const message: SyncMessage = {9+ type: "SYNC_HELLO",10+ deviceId: "phone-\u2603",11+ clockMs: 1_778_000_000_000,12+ vaultVersion: 4,13+ };14+15+ const encoded = encodeSyncMessage(message);16+17+ expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);18+ expect(encoded).not.toMatch(/[+/=]/);19+ expect(decodeSyncMessage(encoded)).toEqual(message);20+ });21+22+ it("round-trips manifest and encrypted entry payloads", () => {23+ const messages: SyncMessage[] = [24+ {25+ type: "SYNC_MANIFEST",26+ entries: [{ id: "entry-1", modifiedAt: 42, deletedAt: 45, hash: "abc123" }],27+ },28+ {29+ type: "SYNC_ENTRIES",30+ entries: [31+ {32+ id: "entry-1",33+ modifiedAt: 42,34+ ciphertext: "ciphertext",35+ nonce: "nonce",36+ tag: "tag",37+ },38+ ],39+ },40+ ];41+42+ for (const message of messages) {43+ expect(decodeSyncMessage(encodeSyncMessage(message))).toEqual(message);44+ }45+ });46+47+ it("validates message-specific required fields", () => {48+ expect(isSyncMessage({ type: "SYNC_REQUEST", ids: ["a", "b"] })).toBe(true);49+ expect(isSyncMessage({ type: "PAIR_ACCEPT", edPublicKey: "key", name: "Laptop" })).toBe(50+ true51+ );52+ expect(isSyncMessage({ type: "SYNC_REQUEST", ids: [7] })).toBe(false);53+ expect(isSyncMessage({ type: "SYNC_HELLO", deviceId: "phone", clockMs: "now" })).toBe(54+ false55+ );56+ expect(isSyncMessage({ type: "SYNC_MANIFEST", entries: [{ id: "a" }] })).toBe(false);57+ expect(isSyncMessage({ type: "UNKNOWN" })).toBe(false);58+ });59+60+ it("rejects malformed wire payloads with a stable error", () => {61+ const malformedJson = btoa("{").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");62+ const unknownType = btoa(JSON.stringify({ type: "BOGUS" }))63+ .replace(/\+/g, "-")64+ .replace(/\//g, "_")65+ .replace(/=+$/g, "");66+67+ expect(() => decodeSyncMessage("@@@")).toThrow("invalid sync message");68+ expect(() => decodeSyncMessage("A")).toThrow("invalid sync message");69+ expect(() => decodeSyncMessage(malformedJson)).toThrow("invalid sync message");70+ expect(() => decodeSyncMessage(unknownType)).toThrow("invalid sync message");71+ });72+});src/shared/sync-wire.ts+114 −0
@@ -0,0 +1,114 @@1+import type { SyncMessage } from "./sync-types";2+3+const textEncoder = new TextEncoder();4+const textDecoder = new TextDecoder("utf-8", { fatal: true });5+6+export function encodeSyncMessage(message: SyncMessage): string {7+ return base64urlEncode(textEncoder.encode(JSON.stringify(message)));8+}9+10+export function decodeSyncMessage(data: string): SyncMessage {11+ try {12+ const raw = textDecoder.decode(base64urlDecode(data));13+ const parsed = JSON.parse(raw) as unknown;14+ if (isSyncMessage(parsed)) {15+ return parsed;16+ }17+ } catch {18+ // Normalize malformed transport payloads so callers do not branch on parser details.19+ }20+ throw new Error("invalid sync message");21+}22+23+export function isSyncMessage(value: unknown): value is SyncMessage {24+ if (!isRecord(value) || typeof value.type !== "string") return false;25+26+ switch (value.type) {27+ case "SYNC_HELLO":28+ return (29+ isString(value.deviceId) &&30+ isFiniteNumber(value.clockMs) &&31+ isFiniteNumber(value.vaultVersion)32+ );33+ case "SYNC_MANIFEST":34+ return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry);35+ case "SYNC_REQUEST":36+ return isStringArray(value.ids);37+ case "SYNC_ENTRIES":38+ return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry);39+ case "SYNC_DONE":40+ return true;41+ case "SYNC_ERROR":42+ return isString(value.code) && isString(value.message);43+ case "PAIR_REQUEST":44+ return isString(value.edPublicKey) && isString(value.name) && isString(value.code);45+ case "PAIR_ACCEPT":46+ return isString(value.edPublicKey) && isString(value.name);47+ case "PAIR_REJECT":48+ return isString(value.reason);49+ default:50+ return false;51+ }52+}53+54+function isSyncManifestEntry(value: unknown): boolean {55+ return (56+ isRecord(value) &&57+ isString(value.id) &&58+ isFiniteNumber(value.modifiedAt) &&59+ isOptionalNumber(value.deletedAt) &&60+ isString(value.hash)61+ );62+}63+64+function isEncryptedSyncEntry(value: unknown): boolean {65+ return (66+ isRecord(value) &&67+ isString(value.id) &&68+ isFiniteNumber(value.modifiedAt) &&69+ isOptionalNumber(value.deletedAt) &&70+ isString(value.ciphertext) &&71+ isString(value.nonce) &&72+ isString(value.tag)73+ );74+}75+76+function isRecord(value: unknown): value is Record<string, unknown> {77+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);78+}79+80+function isString(value: unknown): value is string {81+ return typeof value === "string";82+}83+84+function isStringArray(value: unknown): value is string[] {85+ return Array.isArray(value) && value.every(isString);86+}87+88+function isFiniteNumber(value: unknown): value is number {89+ return typeof value === "number" && Number.isFinite(value);90+}91+92+function isOptionalNumber(value: unknown): boolean {93+ return value === undefined || isFiniteNumber(value);94+}95+96+function base64urlEncode(bytes: Uint8Array): string {97+ let binary = "";98+ for (const byte of bytes) binary += String.fromCharCode(byte);99+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");100+}101+102+function base64urlDecode(value: string): Uint8Array {103+ if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {104+ throw new Error("invalid sync message");105+ }106+ const padded = value107+ .replace(/-/g, "+")108+ .replace(/_/g, "/")109+ .padEnd(Math.ceil(value.length / 4) * 4, "=");110+ const binary = atob(padded);111+ const out = new Uint8Array(binary.length);112+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);113+ return out;114+}