ae28e74af922
feat: Add encrypted sync entry payloads (v2.13.17)
- time
- 2026-04-30 14:11 UTC · 6 months ago
- machine
- koh2
- parent
- 19c5b50deb77
↓ peach-browser-ae28e74a.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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": {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+});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+}