| 1 | // π€ Validation of the restore page's #fragment payload β pure and |
| 2 | // dependency-free so it's unit-testable in Node |
| 3 | // (home/test/restore-payload.test.mjs). Three callers ask this page to run the |
| 4 | // passkey ceremony, distinguished by shape: |
| 5 | // |
| 6 | // { ret, nonce, ephPub } an instance page: gets back ONE entry's |
| 7 | // credentials, matched by ret's origin |
| 8 | // { app: 1, nonce, ephPub } the native app: gets the home session, |
| 9 | // returned via the FIXED custom scheme |
| 10 | // { cli: 1, nonce, ephPub, port? } the eleven CLI: gets the home session, |
| 11 | // returned via a loopback redirect when |
| 12 | // port is set, else shown as a copy-paste |
| 13 | // code (a terminal SSH'd into another box |
| 14 | // can't receive a local redirect) |
| 15 | // |
| 16 | // Every shape carries an ephemeral public key: whatever comes back is |
| 17 | // ECIES-wrapped to it, so secrets are never plaintext in a URL or on screen. |
| 18 | |
| 19 | // A loopback listener needs an unprivileged, real port. The CLI binds |
| 20 | // 127.0.0.1:0 and reports what it got. |
| 21 | export function validCLIPort(port) { |
| 22 | return Number.isInteger(port) && port >= 1024 && port <= 65535; |
| 23 | } |
| 24 | |
| 25 | // The custom scheme an app handoff returns on. The plain scheme is the |
| 26 | // shipped app; a dev alt registers a suffixed one (elevenmessenger.alt-<slug> |
| 27 | // β ios/project.yml), and without naming itself in the payload its own |
| 28 | // sign-in return would be delivered to whichever app owns the PLAIN scheme |
| 29 | // (the release app, which claims and drops it). Constrained to our prefix; |
| 30 | // the security stance is unchanged either way β the returned session is |
| 31 | // ECIES-wrapped to the initiator's ephemeral key and nonce-bound, so a |
| 32 | // crafted payload naming some other installed build delivers only |
| 33 | // undecryptable bytes to an app that never asked. |
| 34 | export function validReturnScheme(s) { |
| 35 | return typeof s === "string" && /^elevenmessenger(\.alt-[a-z0-9-]{1,32})?$/.test(s); |
| 36 | } |
| 37 | |
| 38 | // validRestorePayload returns the payload if it's one of the three shapes |
| 39 | // above (with only the expected fields trusted), else null. |
| 40 | export function validRestorePayload(p) { |
| 41 | if (!p || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null; |
| 42 | if (p.app === 1) { |
| 43 | const out = { app: 1, nonce: p.nonce, ephPub: p.ephPub }; |
| 44 | if (validReturnScheme(p.scheme)) out.scheme = p.scheme; // else: plain-app fallback |
| 45 | return out; |
| 46 | } |
| 47 | if (p.cli === 1) { |
| 48 | if (p.port !== undefined && !validCLIPort(p.port)) return null; |
| 49 | return { cli: 1, nonce: p.nonce, ephPub: p.ephPub, port: p.port }; |
| 50 | } |
| 51 | if (typeof p.ret !== "string") return null; |
| 52 | try { |
| 53 | new URL(p.ret); // must parse β it's the redirect target |
| 54 | } catch { |
| 55 | return null; |
| 56 | } |
| 57 | return { ret: p.ret, nonce: p.nonce, ephPub: p.ephPub }; |
| 58 | } |
| 59 | |
| 60 | // cliReturnURL builds the loopback redirect for a CLI handoff. The host is |
| 61 | // HARDCODED to 127.0.0.1 (like the app path's custom scheme): a crafted |
| 62 | // payload can vary only the port, so the sealed session can only ever be |
| 63 | // delivered to a listener on the user's own machine β and it's ciphertext to |
| 64 | // everyone but the process holding the ephemeral private key. |
| 65 | export function cliReturnURL(port, backB64) { |
| 66 | if (!validCLIPort(port)) throw new Error("bad CLI port"); |
| 67 | // backB64 is base64url β URL-safe by construction, no encoding needed. |
| 68 | return `http://127.0.0.1:${port}/cb?d=${backB64}`; |
| 69 | } |
| 70 | |