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
↓ peach-browser-4823bacb.tar.gzatlas at this save
files changed6 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 −20manifest.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": {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",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": {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 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(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