4823bacb5348

fix ES256 passkey signature DER encoding so RPs accept assertions, suppress redundant State 11 banner on Chrome same-browser entries, dedupe State 11 across navigations via session storage, bump v2.10.3

time
2026-04-28 19:21 UTC · 6 months ago
machine
koh2
parent
886a0cd205b0
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-crypto.test.ts+25 −1src/background/passkey-crypto.ts+33 −2src/content/index.ts+35 −20
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.10.2",4+  "version": "2.10.3",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.10.2",4+  "version": "2.10.3",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.10.2",3+  "version": "2.10.3",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/passkey-crypto.test.ts+25 −1
@@ -10,6 +10,27 @@1010   signPasskeyAssertion,1111 } from "./passkey-crypto";1212 13+// Inverse of the DER encoder inside passkey-crypto: parse SEQUENCE { INTEGER r,14+// INTEGER s } back into a fixed-width r||s buffer of 2*coordSize bytes.15+function derEcdsaToP1363(der: Uint8Array, coordSize: number): Uint8Array {16+  if (der[0] !== 0x30) throw new Error("not a SEQUENCE");17+  let off = 2;18+  if (der[1]! & 0x80) off = 2 + (der[1]! & 0x7f);19+  if (der[off] !== 0x02) throw new Error("missing r INTEGER");20+  const rLen = der[off + 1]!;21+  let r = der.slice(off + 2, off + 2 + rLen);22+  off = off + 2 + rLen;23+  if (der[off] !== 0x02) throw new Error("missing s INTEGER");24+  const sLen = der[off + 1]!;25+  let s = der.slice(off + 2, off + 2 + sLen);26+  if (r.length > coordSize) r = r.slice(r.length - coordSize);27+  if (s.length > coordSize) s = s.slice(s.length - coordSize);28+  const out = new Uint8Array(coordSize * 2);29+  out.set(r, coordSize - r.length);30+  out.set(s, coordSize * 2 - s.length);31+  return out;32+}33+1334 describe("passkey crypto", () => {1435   it("generates importable ES256 passkey material", async () => {1536     const generated = await generatePasskeyKeypair(-7);@@ -101,10 +122,13 @@101122       ["verify"]102123     );103124 125+    // signPasskeyAssertion returns DER (WebAuthn-spec format). SubtleCrypto.verify126+    // expects raw IEEE P1363 (r||s) for ECDSA. Convert before verifying.127+    const raw = derEcdsaToP1363(signature, 32);104128     const verified = await crypto.subtle.verify(105129       { name: "ECDSA", hash: "SHA-256" },106130       publicKey,107-      signature.buffer as ArrayBuffer,131+      raw.buffer as ArrayBuffer,108132       new Uint8Array([...authenticatorData, ...clientDataHash]).buffer as ArrayBuffer109133     );110134 

view file

src/background/passkey-crypto.ts+33 −2
@@ -232,6 +232,37 @@232232   }233233 }234234 235+/**236+ * SubtleCrypto.sign("ECDSA") returns IEEE P1363 raw `r||s`, but WebAuthn237+ * requires ASN.1 DER-encoded ECDSA signatures. Without this conversion238+ * the relying party rejects the assertion ("Unable to sign in with your239+ * passkey"). Ed25519 signatures need no conversion.240+ */241+function p1363ToDer(p1363: Uint8Array): Uint8Array {242+  const half = p1363.length / 2;243+  const r = trimAndPadAsn1Integer(p1363.slice(0, half));244+  const s = trimAndPadAsn1Integer(p1363.slice(half));245+  const body = concatBytes(246+    Uint8Array.of(0x02, r.length),247+    r,248+    Uint8Array.of(0x02, s.length),249+    s250+  );251+  return concatBytes(Uint8Array.of(0x30, body.length), body);252+}253+254+function trimAndPadAsn1Integer(integer: Uint8Array): Uint8Array {255+  let start = 0;256+  while (start < integer.length - 1 && integer[start] === 0) start += 1;257+  const trimmed = integer.slice(start);258+  // ASN.1 INTEGER is signed two's-complement; if the high bit is set,259+  // prefix a zero byte so the value reads as positive.260+  if ((trimmed[0]! & 0x80) !== 0) {261+    return concatBytes(Uint8Array.of(0x00), trimmed);262+  }263+  return trimmed;264+}265+235266 export async function signPasskeyAssertion(236267   privateKeyJwk: JsonWebKey,237268   algorithm: -7 | -8,@@ -248,12 +279,12 @@248279       false,249280       ["sign"]250281     );251-    const signature = await crypto.subtle.sign(282+    const raw = await crypto.subtle.sign(252283       { name: "ECDSA", hash: "SHA-256" },253284       key,254285       toArrayBuffer(signInput)255286     );256-    return new Uint8Array(signature);287+    return p1363ToDer(new Uint8Array(raw));257288   }258289 259290   const key = await crypto.subtle.importKey(

view file

src/content/index.ts+35 −20
@@ -976,30 +976,45 @@976976   });977977 }978978 979-// Surface State 11 (passkey-available) once per page session when this domain980-// has a stored passkey. The cross-browser banner inside the surface warns the981-// user when their passkey was created on the other browser and isn't usable982-// here without re-registration.979+// Surface State 11 (passkey-available) only when it adds value:980+//   - Cross-browser entry: Chrome passkey on Firefox or vice versa — neither981+//     browser will auto-route, so the banner explains the re-register need.982+//   - Firefox + same-browser entry: Firefox has no equivalent of983+//     webAuthenticationProxy, so without a banner the user has no signal that984+//     Peach is the source.985+// On Chrome with a same-browser entry: skip — webAuthenticationProxy handles986+// the page's own credentials.get transparently and the popup approval view987+// already surfaces it. A banner here would just be noise.988+//989+// Dedupe across navigation: each navigation re-injects this content script.990+// chrome.storage.session keyed by domain is the natural per-tab/per-session991+// memory; session storage clears when the user closes the browser.983992 {984-  let availabilityChecked = false;985-986993   function checkPasskeyAvailability() {987-    if (availabilityChecked) return;988-    availabilityChecked = true;989994     const domain = location.hostname.replace(/^www\./, "");990995     if (!domain) return;991-    chrome.runtime.sendMessage({992-      type: "GET_PASSKEY_FOR_DOMAIN",993-      payload: { domain },994-    }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {995-      const data = res?.data;996-      if (!data) return;997-      showPasskeyAvailable({998-        rpId: data.rpId,999-        userName: data.userName,1000-        fromBrowser: data.browser,1001-        currentBrowser: detectBrowser(),1002-      });996+    const sessionKey = `peach_state11_shown:${domain}`;997+    chrome.storage.session?.get(sessionKey).then((stored) => {998+      if (stored && (stored as Record<string, unknown>)[sessionKey]) return;999+      chrome.runtime.sendMessage({1000+        type: "GET_PASSKEY_FOR_DOMAIN",1001+        payload: { domain },1002+      }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {1003+        const data = res?.data;1004+        if (!data) return;1005+        const current = detectBrowser();1006+        const isCrossBrowser =1007+          data.browser && current !== "unknown" && data.browser !== current;1008+        const valuableOnFirefox = current === "firefox";1009+        if (!isCrossBrowser && !valuableOnFirefox) return;1010+        chrome.storage.session?.set({ [sessionKey]: Date.now() }).catch(() => {});1011+        showPasskeyAvailable({1012+          rpId: data.rpId,1013+          userName: data.userName,1014+          fromBrowser: data.browser,1015+          currentBrowser: current,1016+        });1017+      }).catch(() => {});10031018     }).catch(() => {});10041019   }10051020 

view file