ae28e74af922

feat: Add encrypted sync entry payloads (v2.13.17)

time
2026-04-30 14:11 UTC · 6 months ago
machine
koh2
parent
19c5b50deb77
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-entry-payload.test.ts+95 −0src/shared/sync-entry-payload.ts+118 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.16",4+  "version": "2.13.17",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.16",4+  "version": "2.13.17",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.16",3+  "version": "2.13.17",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/shared/sync-entry-payload.test.ts+95 −0
@@ -0,0 +1,95 @@1+import { describe, expect, it } from "vitest";2+3+import {4+  decryptSyncEntry,5+  encryptSyncEntry,6+  importSyncEntryKey,7+  stripNonPortableSecrets,8+} from "./sync-entry-payload";9+import type { VaultEntry } from "./types";10+11+function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {12+  return {13+    id: "entry-1",14+    type: "login",15+    title: "GitHub",16+    username: "june",17+    password: "secret",18+    url: "https://github.com",19+    createdAt: 1_000,20+    modifiedAt: 1_000,21+    ...overrides,22+  };23+}24+25+describe("sync entry payloads", () => {26+  it("encrypts and decrypts vault entries through an AES-GCM envelope", async () => {27+    const key = await importSyncEntryKey(new Uint8Array(32).fill(7));28+    const source = entry({ deletedAt: 2_000, notes: "sync me" });29+30+    const encrypted = await encryptSyncEntry(source, key);31+32+    expect(encrypted).toMatchObject({33+      id: "entry-1",34+      modifiedAt: 1_000,35+      deletedAt: 2_000,36+      ciphertext: expect.stringMatching(/^[A-Za-z0-9_-]+$/),37+      nonce: expect.stringMatching(/^[A-Za-z0-9_-]+$/),38+      tag: expect.stringMatching(/^[A-Za-z0-9_-]+$/),39+    });40+    expect(await decryptSyncEntry(encrypted, key)).toEqual(source);41+  });42+43+  it("strips passkey private keys before encryption", async () => {44+    const key = await importSyncEntryKey(new Uint8Array(32).fill(9));45+    const source = entry({46+      passkey: {47+        rpId: "github.com",48+        rpName: "GitHub",49+        credentialId: "credential",50+        publicKeyCose: "public",51+        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "secret" },52+        userHandle: "user",53+        userName: "june",54+        userDisplayName: "June",55+        algorithm: -7,56+        signCount: 3,57+        aaguid: "aaguid",58+        backupEligible: true,59+        backupState: true,60+        browser: "chrome",61+        createdAt: 1_000,62+      },63+      privateKeyJwk: { kty: "EC", d: "legacy-secret" },64+    });65+66+    const encrypted = await encryptSyncEntry(source, key);67+    const decrypted = await decryptSyncEntry(encrypted, key);68+69+    expect(decrypted.passkey?.privateKeyJwk).toBeUndefined();70+    expect(decrypted.privateKeyJwk).toBeUndefined();71+    expect(stripNonPortableSecrets(source).passkey?.publicKeyCose).toBe("public");72+  });73+74+  it("rejects invalid key sizes", async () => {75+    await expect(importSyncEntryKey(new Uint8Array(31))).rejects.toThrow(76+      "sync entry key must be 32 bytes"77+    );78+  });79+80+  it("rejects tampered ciphertext or mismatched envelope metadata", async () => {81+    const key = await importSyncEntryKey(new Uint8Array(32).fill(11));82+    const encrypted = await encryptSyncEntry(entry(), key);83+84+    const replacement = encrypted.ciphertext.endsWith("A") ? "B" : "A";85+    await expect(86+      decryptSyncEntry(87+        { ...encrypted, ciphertext: `${encrypted.ciphertext.slice(0, -1)}${replacement}` },88+        key89+      )90+    ).rejects.toThrow();91+    await expect(decryptSyncEntry({ ...encrypted, id: "other" }, key)).rejects.toThrow(92+      "invalid sync entry payload"93+    );94+  });95+});

view file

src/shared/sync-entry-payload.ts+118 −0
@@ -0,0 +1,118 @@1+import type { EncryptedSyncEntry } from "./sync-types";2+import type { VaultEntry } from "./types";3+4+const NONCE_BYTES = 12;5+const GCM_TAG_BYTES = 16;6+const textEncoder = new TextEncoder();7+const textDecoder = new TextDecoder("utf-8", { fatal: true });8+9+export async function importSyncEntryKey(rawKey: Uint8Array): Promise<CryptoKey> {10+  if (rawKey.length !== 32) {11+    throw new Error("sync entry key must be 32 bytes");12+  }13+  return crypto.subtle.importKey("raw", asArrayBuffer(rawKey), "AES-GCM", false, [14+    "encrypt",15+    "decrypt",16+  ]);17+}18+19+export async function encryptSyncEntry(20+  entry: VaultEntry,21+  key: CryptoKey22+): Promise<EncryptedSyncEntry> {23+  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));24+  const syncEntry = stripNonPortableSecrets(entry);25+  const encrypted = new Uint8Array(26+    await crypto.subtle.encrypt(27+      { name: "AES-GCM", iv: nonce, tagLength: GCM_TAG_BYTES * 8 },28+      key,29+      textEncoder.encode(JSON.stringify(syncEntry))30+    )31+  );32+  const tagOffset = encrypted.length - GCM_TAG_BYTES;33+  if (tagOffset < 0) throw new Error("invalid encrypted sync entry");34+35+  return {36+    id: syncEntry.id,37+    modifiedAt: syncEntry.modifiedAt,38+    ...(syncEntry.deletedAt !== undefined ? { deletedAt: syncEntry.deletedAt } : {}),39+    ciphertext: base64urlEncode(encrypted.slice(0, tagOffset)),40+    nonce: base64urlEncode(nonce),41+    tag: base64urlEncode(encrypted.slice(tagOffset)),42+  };43+}44+45+export async function decryptSyncEntry(46+  payload: EncryptedSyncEntry,47+  key: CryptoKey48+): Promise<VaultEntry> {49+  const ciphertext = base64urlDecode(payload.ciphertext);50+  const tag = base64urlDecode(payload.tag);51+  const combined = new Uint8Array(ciphertext.length + tag.length);52+  combined.set(ciphertext);53+  combined.set(tag, ciphertext.length);54+55+  const plain = textDecoder.decode(56+    await crypto.subtle.decrypt(57+      {58+        name: "AES-GCM",59+        iv: asArrayBuffer(base64urlDecode(payload.nonce)),60+        tagLength: GCM_TAG_BYTES * 8,61+      },62+      key,63+      combined64+    )65+  );66+  const entry = JSON.parse(plain) as unknown;67+  if (!isVaultEntryEnvelope(entry, payload)) {68+    throw new Error("invalid sync entry payload");69+  }70+  return entry;71+}72+73+export function stripNonPortableSecrets(entry: VaultEntry): VaultEntry {74+  const clone = structuredClone(entry);75+  if (clone.passkey) {76+    delete clone.passkey.privateKeyJwk;77+  }78+  delete clone.privateKeyJwk;79+  return clone;80+}81+82+function isVaultEntryEnvelope(value: unknown, envelope: EncryptedSyncEntry): value is VaultEntry {83+  if (!value || typeof value !== "object" || Array.isArray(value)) return false;84+  const entry = value as Partial<VaultEntry>;85+  return (86+    entry.id === envelope.id &&87+    entry.modifiedAt === envelope.modifiedAt &&88+    entry.deletedAt === envelope.deletedAt &&89+    typeof entry.title === "string" &&90+    typeof entry.createdAt === "number"91+  );92+}93+94+function base64urlEncode(bytes: Uint8Array): string {95+  let binary = "";96+  for (const byte of bytes) binary += String.fromCharCode(byte);97+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");98+}99+100+function base64urlDecode(value: string): Uint8Array {101+  if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {102+    throw new Error("invalid base64url");103+  }104+  const padded = value105+    .replace(/-/g, "+")106+    .replace(/_/g, "/")107+    .padEnd(Math.ceil(value.length / 4) * 4, "=");108+  const binary = atob(padded);109+  const out = new Uint8Array(binary.length);110+  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);111+  return out;112+}113+114+function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {115+  const copy = new Uint8Array(bytes.length);116+  copy.set(bytes);117+  return copy.buffer;118+}

view file