7d8739461dec

feat: Add sync wire codec (v2.13.14)

time
2026-04-30 14:00 UTC · 6 months ago
machine
koh2
parent
e6e751939095
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-wire.test.ts+72 −0src/shared/sync-wire.ts+114 −0
manifest.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": {

view file

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",

view file

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": {

view file

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+});

view file

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+}

view file