home+web+mac: handoffs route to the build that asked — dev alts included (#1175)
Paul's question exposed a latent bug: Launch Services routes a custom scheme to ONE app, restore.js hardcoded elevenmessenger://home-session, and dev alts register suffixed schemes (elevenmessenger.alt-<slug>) — so an alt's own browser sign-in return was delivered to the release app, which claimed and dropped it. Two halves, matching who initiates: - App-initiated (the browser sign-in handoff): the payload names the build's own registered scheme (Info.plist), validated in restore-payload.js to our prefix only, plain-app fallback. The security stance is unchanged — the returned session is ECIES-wrapped to the initiator's ephemeral key and nonce-bound, so naming someone else's build delivers undecryptable bytes to an app that never asked. - Web-initiated (the join page's "Open in the app"): a page cannot detect installed apps, so the shipped scheme stays the product answer; developers aim the button at an alt with localStorage.lchat_app_scheme (same validation, same fallback). Node tests cover the scheme validation; Mac build passes.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+77
−4
home/test/restore-payload.test.mjs+28 −1home/web/static/restore-payload.js+18 −1home/web/static/restore.js+3 −1ios/LChatMac/MacHomeSignIn.swift+12 −0web/static/join.js+16 −1
diff --git a/home/test/restore-payload.test.mjs b/home/test/restore-payload.test.mjs| index 1a24df7..256d0a6 100644 |
| --- a/home/test/restore-payload.test.mjs |
| +++ b/home/test/restore-payload.test.mjs |
| @@ -2,7 +2,7 @@ |
| // pure half of the passkey handoff (home/web/static/restore-payload.js). |
| // node home/test/restore-payload.test.mjs |
| import assert from "node:assert/strict"; |
| -import { validRestorePayload, validCLIPort, cliReturnURL } from "../web/static/restore-payload.js"; |
| +import { validRestorePayload, validCLIPort, cliReturnURL, validReturnScheme } from "../web/static/restore-payload.js"; |
| // ---- instance shape --------------------------------------------------------- |
| @@ -27,6 +27,33 @@ assert.deepEqual( |
| assert.equal(validRestorePayload({ app: 1, nonce: "n", ephPub: "P", ret: "https://evil.example" }).ret, |
| undefined, "app payload drops extra fields"); |
| +// ---- the app shape's return scheme (dev alts name themselves) ---------------- |
| + |
| +assert.deepEqual( |
| + validRestorePayload({ app: 1, nonce: "n", ephPub: "P", scheme: "elevenmessenger.alt-dek" }), |
| + { app: 1, nonce: "n", ephPub: "P", scheme: "elevenmessenger.alt-dek" }, |
| + "an alt names its own suffixed scheme", |
| +); |
| +assert.equal( |
| + validRestorePayload({ app: 1, nonce: "n", ephPub: "P", scheme: "elevenmessenger" }).scheme, |
| + "elevenmessenger", |
| + "the plain scheme passes too", |
| +); |
| +for (const scheme of ["https", "evil", "elevenmessenger.alt-", "elevenmessenger.alt-UPPER", |
| + "elevenmessengerx", "elevenmessenger.alt-" + "x".repeat(33), 7, null, {}]) { |
| + assert.equal( |
| + validRestorePayload({ app: 1, nonce: "n", ephPub: "P", scheme }).scheme, |
| + undefined, |
| + `junk scheme ${JSON.stringify(scheme)} dropped (plain-app fallback)`, |
| + ); |
| +} |
| +assert.equal(validReturnScheme("elevenmessenger.alt-dek2"), true, "digits in slugs pass"); |
| +assert.equal( |
| + validRestorePayload({ ret: "https://x.example", nonce: "n", ephPub: "P", scheme: "elevenmessenger.alt-dek" }).scheme, |
| + undefined, |
| + "only the app shape carries a scheme", |
| +); |
| + |
| // ---- cli shape -------------------------------------------------------------- |
| assert.deepEqual( |
diff --git a/home/web/static/restore-payload.js b/home/web/static/restore-payload.js| index 717d734..5f97723 100644 |
| --- a/home/web/static/restore-payload.js |
| +++ b/home/web/static/restore-payload.js |
| @@ -22,11 +22,28 @@ export function validCLIPort(port) { |
| return Number.isInteger(port) && port >= 1024 && port <= 65535; |
| } |
| +// The custom scheme an app handoff returns on. The plain scheme is the |
| +// shipped app; a dev alt registers a suffixed one (elevenmessenger.alt-<slug> |
| +// — ios/project.yml), and without naming itself in the payload its own |
| +// sign-in return would be delivered to whichever app owns the PLAIN scheme |
| +// (the release app, which claims and drops it). Constrained to our prefix; |
| +// the security stance is unchanged either way — the returned session is |
| +// ECIES-wrapped to the initiator's ephemeral key and nonce-bound, so a |
| +// crafted payload naming some other installed build delivers only |
| +// undecryptable bytes to an app that never asked. |
| +export function validReturnScheme(s) { |
| + return typeof s === "string" && /^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(s); |
| +} |
| + |
| // validRestorePayload returns the payload if it's one of the three shapes |
| // above (with only the expected fields trusted), else null. |
| export function validRestorePayload(p) { |
| if (!p || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null; |
| - if (p.app === 1) return { app: 1, nonce: p.nonce, ephPub: p.ephPub }; |
| + if (p.app === 1) { |
| + const out = { app: 1, nonce: p.nonce, ephPub: p.ephPub }; |
| + if (validReturnScheme(p.scheme)) out.scheme = p.scheme; // else: plain-app fallback |
| + return out; |
| + } |
| if (p.cli === 1) { |
| if (p.port !== undefined && !validCLIPort(p.port)) return null; |
| return { cli: 1, nonce: p.nonce, ephPub: p.ephPub, port: p.port }; |
diff --git a/home/web/static/restore.js b/home/web/static/restore.js| index 5d228a1..58ced5e 100644 |
| --- a/home/web/static/restore.js |
| +++ b/home/web/static/restore.js |
| @@ -148,7 +148,9 @@ async function restore() { |
| $("actions").classList.add("hidden"); |
| $("badge").classList.add("sealed"); |
| setTimeout(() => { |
| - location.replace("elevenmessenger://home-session#" + |
| + // The initiating build names its own registered scheme (dev alts are |
| + // suffixed); validated in restore-payload.js, plain-app fallback. |
| + location.replace((payload.scheme || "elevenmessenger") + "://home-session#" + |
| bytesToB64url(new TextEncoder().encode(back))); |
| // A custom-scheme redirect switches to the app but never navigates this |
| // tab, so it would sit on "Signing in…" forever. Settle on a final state |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index d4474c5..e04ba28 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -168,6 +168,17 @@ final class MacHomeSignIn: ObservableObject { |
| private static var pendingHandoff: |
| (model: MacHomeSignIn, priv: P256.KeyAgreement.PrivateKey, nonce: String)? |
| + /// The custom scheme THIS build registered (Info.plist; dev alts suffix |
| + /// it — elevenmessenger.alt-<slug>). Named in the handoff payload so the |
| + /// return lands HERE: Launch Services routes a scheme to one app, and |
| + /// without this an alt's own sign-in return went to the release app, |
| + /// which claimed and dropped it. |
| + static var urlScheme: String { |
| + (Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]])? |
| + .first.flatMap { ($0["CFBundleURLSchemes"] as? [String])?.first } |
| + ?? "elevenmessenger" |
| + } |
| + |
| func signInViaBrowser() { |
| let priv = P256.KeyAgreement.PrivateKey() |
| var nonceBytes = [UInt8](repeating: 0, count: 16) |
| @@ -177,6 +188,7 @@ final class MacHomeSignIn: ObservableObject { |
| let payload: [String: Any] = [ |
| "app": 1, "nonce": nonce, |
| "ephPub": Data(priv.publicKey.x963Representation).base64urlEncodedString(), |
| + "scheme": Self.urlScheme, |
| ] |
| guard let json = try? JSONSerialization.data(withJSONObject: payload), |
| let url = URL(string: HomeBase.url.appending(path: "/restore").absoluteString |
diff --git a/web/static/join.js b/web/static/join.js| index 92fa6ad..4f5413b 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -19,6 +19,21 @@ const SESSION_KEY = "lchat_session"; |
| const card = document.getElementById("card"); |
| const loader = document.getElementById("app-loading"); |
| +// Which app scheme "Open in the app" fires. A page cannot DETECT installed |
| +// apps (deliberately — anti-fingerprinting), so the shipped scheme is the |
| +// only product answer; dev alts register suffixed schemes |
| +// (elevenmessenger.alt-<slug>, ios/project.yml) and a developer aims the |
| +// button at one with |
| +// localStorage.lchat_app_scheme = "elevenmessenger.alt-dek" |
| +// — validated to our prefix, anything else falls back to the shipped app. |
| +function appScheme() { |
| + try { |
| + const s = localStorage.getItem("lchat_app_scheme") || ""; |
| + if (/^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(s)) return s; |
| + } catch { /* no storage — the shipped app */ } |
| + return "elevenmessenger"; |
| +} |
| + |
| // The ONE loading state: the same full-screen loader the app uses. It covers |
| // the page until a card has real content, and again while a claim is in |
| // flight — the card and the loader are never shown together. |
| @@ -364,7 +379,7 @@ function showWelcome(kind, onContinue, spaceDesc = "") { |
| appBtn.closest(".row").remove(); |
| } else { |
| appBtn.onclick = () => { |
| - location.href = "elevenmessenger://add?url=" + encodeURIComponent(location.href); |
| + location.href = appScheme() + "://add?url=" + encodeURIComponent(location.href); |
| }; |
| } |
| // "Get the app" is per-instance config (LCHAT_APP_STORE_URL); hidden until set. |