eleven / messenger Public

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>
Paul Campbell 2b517840be570f468d5c5d40dd35878aa4319611 parent 56184a0
5 files changed, +77 −4
  • home/test/restore-payload.test.mjs +28 −1
  • home/web/static/restore-payload.js +18 −1
  • home/web/static/restore.js +3 −1
  • ios/LChatMac/MacHomeSignIn.swift +12 −0
  • web/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.