0795bfa40488

feat: Expose native session key client (v2.13.20)

- Add GET_SESSION_KEY/SESSION_KEY host message types to extension sync client - Add getNativeSessionKey(connectionId) returning the decoded 32-byte key - Reject SESSION_KEY responses whose connection_id does not match the request - Reject responses whose decoded key length is not exactly 32 bytes - Cover round-trip and mismatched-connection rejection in sync.test.ts Verification: npm typecheck, focused sync tests 7/7, npm test 260/260, npm run build clean.

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

view file

src/background/sync.test.ts+33 −0
@@ -122,6 +122,39 @@122122     await expect(promise).resolves.toBe("conn-1");123123   });124124 125+  it("decodes a base64url session key for the requested connection", async () => {126+    const chromeMock = installChromeMock();127+    const sync = await import("./sync");128+129+    const promise = sync.getNativeSessionKey("conn-1");130+    expect(chromeMock.port.postMessage).toHaveBeenCalledWith({131+      type: "GET_SESSION_KEY",132+      connection_id: "conn-1",133+    });134+135+    // 32 bytes of 0x07 → URL-safe base64 with no padding.136+    const expected = new Uint8Array(32).fill(7);137+    let binary = "";138+    for (const byte of expected) binary += String.fromCharCode(byte);139+    const key = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");140+141+    chromeMock.emit({ type: "SESSION_KEY", connection_id: "conn-1", key });142+    await expect(promise).resolves.toEqual(expected);143+  });144+145+  it("rejects a session key returned for the wrong connection", async () => {146+    const chromeMock = installChromeMock();147+    const sync = await import("./sync");148+149+    const promise = sync.getNativeSessionKey("conn-1");150+    chromeMock.emit({151+      type: "SESSION_KEY",152+      connection_id: "other",153+      key: "AA".repeat(0),154+    });155+    await expect(promise).rejects.toThrow("unexpected connection");156+  });157+125158   it("rejects pending requests when the native host disconnects", async () => {126159     const chromeMock = installChromeMock();127160     const sync = await import("./sync");

view file

src/background/sync.ts+28 −0
@@ -34,6 +34,7 @@3434     }3535   | { type: "DISCONNECT"; connection_id: string }3636   | { type: "SYNC_SEND"; connection_id: string; data: string }37+  | { type: "GET_SESSION_KEY"; connection_id: string }3738   | { type: "STATUS" };3839 3940 type HostPeer = {@@ -67,6 +68,7 @@6768   | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }6869   | { type: "DISCONNECTED"; connection_id: string }6970   | { type: "SYNC_RECEIVED"; connection_id: string; data: string }71+  | { type: "SESSION_KEY"; connection_id: string; key: string }7072   | { type: "STATUS"; advertising: boolean; tcp_port?: number | null; connections: string[] }7173   | { type: "ERROR"; connection_id?: string | null; message: string };7274 @@ -343,6 +345,32 @@343345 344346 export function sendSyncData(connectionId: string, data: string): void {345347   sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });348+}349+350+export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {351+  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(352+    { type: "GET_SESSION_KEY", connection_id: connectionId },353+    ["SESSION_KEY"]354+  );355+  if (response.connection_id !== connectionId) {356+    throw new Error("sync native host returned session key for unexpected connection");357+  }358+  const decoded = decodeBase64Url(response.key);359+  if (decoded.length !== 32) {360+    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);361+  }362+  return decoded;363+}364+365+function decodeBase64Url(value: string): Uint8Array {366+  const padded = value367+    .replace(/-/g, "+")368+    .replace(/_/g, "/")369+    .padEnd(Math.ceil(value.length / 4) * 4, "=");370+  const binary = atob(padded);371+  const out = new Uint8Array(binary.length);372+  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);373+  return out;346374 }347375 348376 export async function getNativeStatus(): Promise<NativeSyncStatus> {

view file