@localauth/cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,8 +59,9 @@ localauth finish # hand a pasted callback (read from stdin) t
59
59
  localauth list # this machine's logins and their status
60
60
  localauth show <id> # one login with its output (--json for scripts)
61
61
  localauth status # account and service this machine is connected to
62
- localauth verify # compare four emoji with the page to trust end-to-end encryption
62
+ localauth verify # match a number on the page to trust end-to-end encryption
63
63
  localauth logout # disconnect this machine
64
+ localauth approve --title <text> [--description <text>] [--timeout <minutes>]
64
65
  ```
65
66
 
66
67
  `localauth finish` matches the address to the waiting login by port and OAuth state, so a user
@@ -185,7 +186,7 @@ curl https://localauth.dev/api/v1/runs \
185
186
  ```
186
187
 
187
188
  Permissions are `runs:read`, `runs:write`, `machines:read` and `machines:write`.
188
- Personal tokens cannot change the account, encryption keys, passkeys, other tokens or emoji
189
+ Personal tokens cannot change the account, encryption keys, passkeys, other tokens or machine
189
190
  verification. In Cloudflare Access mode, personal tokens and OAuth are disabled.
190
191
  The website uses the Access JWT and the CLI uses its machine token.
191
192
 
@@ -198,16 +199,21 @@ even if invalid. Cross-origin clients use explicit header credentials; CORS neve
198
199
  cookie credentials. Errors return `{ error: { code, message } }`; rate limits return 429
199
200
  with `Retry-After`. Machine tokens and link capabilities have separate routes and authority.
200
201
 
202
+ The first time someone signs in to the iOS app, localauth.dev shows what the app will be able to
203
+ do and asks them to allow it (`/oauth/consent`); the answer is remembered. **Account, Signed-in
204
+ apps** lists each device where the app is signed in and signs one out at once; the same list is
205
+ `GET /api/v1/app-sessions` and `DELETE /api/v1/app-sessions/{id}` (account scope).
206
+
201
207
  The iOS app (client `localauth-ios`) signs in with Better Auth's OAuth provider:
202
208
  `/api/auth/oauth2/authorize`, `/api/auth/oauth2/token` and `/api/auth/oauth2/revoke`, with S256
203
209
  PKCE and an exact redirect allowlist. Access tokens last an hour; refresh tokens last 90 days and
204
210
  rotate on use. A retry within 10 seconds gets the same new pair; reusing a rotated refresh token
205
- later revokes all of the app's tokens for that person. The app stays signed in independently
206
- of the browser session it signed in through (it asks for `offline_access`). The app revokes its
207
- refresh token on sign-out, deleting the account removes every token, and disabling the client
208
- row switches the whole app off. Third-party clients, registration and
209
- consent are not offered. The plugin has a documented race in that revocation, which this
210
- repository patches; see [patches/README.md](patches/README.md).
211
+ later revokes all of the app's tokens for that person. The app stays signed in independently of
212
+ the browser session it signed in through (it asks for `offline_access`). The app revokes its
213
+ refresh token on sign-out, deleting the account removes every token, and disabling the client row
214
+ switches the whole app off. Third-party clients and registration are not offered. The plugin has a
215
+ documented race in that revocation, which this repository patches; see
216
+ [patches/README.md](patches/README.md).
211
217
 
212
218
  End-to-end data stays sealed through the API. A client that needs plaintext must hold the
213
219
  account private key, obtained locally from a passkey PRF wrap, and implement the
@@ -218,8 +224,8 @@ On browsers supporting WebMCP, dashboard and link pages register login tools and
218
224
  and device page register machine approval tools. They use the page's existing decryption,
219
225
  sealing, callback checks and confirmation dialogs. A link page sees only its run. A locked
220
226
  browser asks the person to unlock on the page. Machine output and names are untrusted data.
221
- There are no WebMCP tools for emoji verification, account, encryption, passkey or token changes,
222
- sign-out or deletion. A person still compares emoji after approving an E2E machine.
227
+ There are no WebMCP tools for machine verification, account, encryption, passkey or token changes,
228
+ sign-out or deletion. A person still taps the matching number after approving an E2E machine.
223
229
 
224
230
  ## Encryption
225
231
 
@@ -252,15 +258,15 @@ public-key-only envelopes, and messages from a different account key. The servic
252
258
  private key, so it cannot forge or alter these messages. Input is bound to the current attempt
253
259
  and deduplicated by an authenticated message id. Ciphertexts bind their kind and record to prevent swapping.
254
260
 
255
- A machine trusts the account public key only after a short emoji check, so the service can't
261
+ A machine trusts the account public key only after a short number check, so the service can't
256
262
  hand it a key of its own. `localauth verify` (and `localauth login` on an end-to-end account)
257
263
  prints a link; the page and the machine exchange ephemeral ECDH keys through the service, the
258
- machine having committed to its key before seeing the browser's, and both derive four emoji
259
- from the shared secret and everything exchanged (the 64 emoji of Matrix's SAS verification,
260
- shown with their names so different emoji fonts still compare). The terminal prints the four;
261
- the page offers four rows of them and "None of these match", so nobody types emoji. Picking the
262
- right row sends a MAC of the exchange that the machine checks before pinning the key. A service
263
- in the middle would have to guess the emoji, a 1 in 2^24 chance per attempt. The browser also
264
+ machine having committed to its key before seeing the browser's, and both derive a three-digit
265
+ number from the shared secret and everything exchanged. The terminal prints the number; the page
266
+ offers it among two decoys and "None of these match", like a phone sign-in prompt, so nobody
267
+ types anything. Tapping the right number sends a MAC of the exchange that the machine checks
268
+ before pinning the key. A service in the middle would have to guess the number, a 1 in 1000
269
+ chance per attempt, and every failed attempt shows a warning on both sides. The browser also
264
270
  stores the account's MAC over the machine's key so other browsers show it as verified.
265
271
 
266
272
  Until it is verified, a machine refuses to run logins for an end-to-end account. If the service
@@ -394,7 +400,7 @@ npm run design:qa # screenshots of every design preview, light and dark, ph
394
400
  ```
395
401
 
396
402
  While `npm run dev` runs, http://localhost:8787/design shows every page in every state (signed
397
- out, waiting logins, failed retries, emoji verification, dialogs, …) with made-up data, and
403
+ out, waiting logins, failed retries, number verification, dialogs, …) with made-up data, and
398
404
  `/design/<id>` opens one with a switcher in the bottom right for states and light/dark. They are
399
405
  for tuning the design: no real data, no API calls, and they don't exist in deployed Workers.
400
406
 
package/cli/ask.ts CHANGED
@@ -159,3 +159,60 @@ export async function ask(connection: Connection, options: AskOptions, say: (tex
159
159
  await run.close()
160
160
  }
161
161
  }
162
+
163
+ export interface ApproveOptions { title: string; description?: string; timeout: number }
164
+ export function parseApproveArgs(rest: string[]): ApproveOptions {
165
+ let options: ApproveOptions = { title: '', timeout: 15 }
166
+ for (let i = 0; i < rest.length; i++) {
167
+ let value = rest[i + 1]
168
+ if (value === undefined) throw new Error(`${rest[i]} needs a value.`)
169
+ if (rest[i] === '--title') options.title = value
170
+ else if (rest[i] === '--description') options.description = value
171
+ else if (rest[i] === '--timeout') {
172
+ options.timeout = Number(value)
173
+ if (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout > 60) throw new Error('--timeout must be greater than 0 and at most 60.')
174
+ } else throw new Error(`Unknown localauth approve option "${rest[i]}".`)
175
+ i++
176
+ }
177
+ if (!options.title) throw new Error('localauth approve needs --title.')
178
+ return options
179
+ }
180
+
181
+ /** Only an answer sealed with the pinned account key can approve; anything else that arrives can only decline. */
182
+ export async function approve(connection: Connection, options: ApproveOptions, say: (text: string) => void): Promise<number> {
183
+ if (!connection.accountPublicKey) throw new Error('localauth approve needs end-to-end encryption: add a passkey on your account, then run localauth verify.')
184
+ let expiresAt = Date.now() + Math.ceil(options.timeout * 60_000)
185
+ let request: AskRequest = { title: options.title, description: options.description, fields: [{ name: 'APPROVE', label: 'Approve', secret: false, required: true }] }
186
+ let data: RunData = { authenticatedMessages: true, kind: 'ask', approval: true, title: options.title, ask: request, expiresAt, command: '', flows: [], output: '', exitCode: null, attempt: 1, retryUntil: null }
187
+ if (!isAskRequest(data.ask)) throw new Error('Invalid approval request.')
188
+ let run = await createRun(connection, [], say, { retry: false, link: false }, data)
189
+ let stop = new AbortController(), interrupted = 0
190
+ let listeners = Object.entries({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }).map(([signal, code]) => {
191
+ let listener = () => { interrupted = code; stop.abort() }
192
+ process.on(signal, listener); return { signal, listener }
193
+ })
194
+ try {
195
+ await run.publish()
196
+ let decided: boolean | null = null
197
+ while (!stop.signal.aborted && Date.now() < expiresAt && decided === null) {
198
+ let signal = AbortSignal.any([stop.signal, AbortSignal.timeout(Math.max(1, expiresAt - Date.now()))])
199
+ try {
200
+ for (let message of await run.takeMessages(signal)) {
201
+ if (message.kind === 'decline') { decided = false; break }
202
+ if (message.kind !== 'answer' || typeof message.data !== 'string' || !message.data.startsWith('a1.')) continue
203
+ let value = await run.openMessage<AnswerData>('answer', message.data)
204
+ if (isAnswer(value) && !value.declined && answerFields(value, request)?.APPROVE === 'yes') { decided = true; break }
205
+ }
206
+ if (decided === null) await sleep(Math.min(1500, Math.max(1, expiresAt - Date.now())), undefined, { signal })
207
+ } catch (error) { if (!signal.aborted) throw error }
208
+ }
209
+ data.exitCode = decided ? 0 : 1
210
+ await run.publish(AbortSignal.timeout(3000)).catch(() => {})
211
+ if (interrupted) return interrupted
212
+ say(decided ? 'localauth: approved.' : decided === false ? 'localauth: the owner declined the request.' : 'localauth: the request timed out.')
213
+ return decided ? 0 : 1
214
+ } finally {
215
+ for (let { signal, listener } of listeners) process.off(signal, listener)
216
+ await run.close()
217
+ }
218
+ }
package/cli/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ask, parseAskArgs } from './ask.ts'
1
+ import { ask, parseAskArgs, approve, parseApproveArgs } from './ask.ts'
2
2
  import * as os from 'node:os'
3
3
  import { createTerminal } from 'remix/terminal'
4
4
 
@@ -32,11 +32,15 @@ Usage:
32
32
  LD_*, NODE_OPTIONS, BROWSER, LOCALAUTH_* or other loader variables
33
33
  Title/labels: max 120; description: max 2000 (plain text + links)
34
34
  Requests expire in 15 minutes (max 60); decline/timeout exits 1
35
+ localauth approve --title <text> [--description <text>] [--timeout <minutes>]
36
+ Wait for the owner to allow something. They answer from their
37
+ signed-in list with a fresh passkey check; no link is made
38
+ Needs end-to-end encryption; exits 0 if approved, 1 otherwise
35
39
  localauth finish [<callback-url>] Hand a pasted http://localhost:... address to the waiting command
36
40
  (reads it from stdin when omitted, keeping it out of ps)
37
41
  localauth list [--json] This machine's logins and requests
38
42
  localauth show <id> [--json] One login or request with its output
39
- localauth verify Compare four emoji with the page to trust the account's encryption key
43
+ localauth verify Match a number on the page to trust the account's encryption key
40
44
  localauth status Which account and service this machine is connected to
41
45
  localauth url The page to open to finish logins
42
46
  localauth logout Disconnect this machine
@@ -78,6 +82,9 @@ export async function main(args: string[]): Promise<number> {
78
82
  case 'ask':
79
83
  return await ask(requireConnection(), parseAskArgs(rest), say)
80
84
 
85
+ case 'approve':
86
+ return await approve(requireConnection(), parseApproveArgs(rest), say)
87
+
81
88
  case 'browser':
82
89
  return await reportBrowserUrls(rest, say)
83
90
 
@@ -231,7 +238,7 @@ async function login(url: string, name: string): Promise<number> {
231
238
  terminal.writeLine(`${styles.green('✓')} Connected as ${styles.bold(reply.data.user.email)}.`)
232
239
  let me = await createService(url, connection)<{ accountKey: { publicKey: string } | null }>('GET', '/api/v1/machine/me')
233
240
  if (me.status === 200 && me.data.accountKey) {
234
- terminal.writeLine('This account uses end-to-end encryption, so check this machine with four emoji.')
241
+ terminal.writeLine('This account uses end-to-end encryption, so check this machine with a number.')
235
242
  if (!(await verifyMachine(connection, (line) => terminal.writeLine(line)))) return 1
236
243
  }
237
244
  terminal.writeLine(`Run logins with: localauth run -- <command> Finish them at ${url}/`)
package/cli/config.ts CHANGED
@@ -16,7 +16,7 @@ export interface Connection {
16
16
  publicKey: string
17
17
  accountPublicKey?: string
18
18
  accountFingerprint?: string
19
- /** True once the account key was confirmed with emoji (localauth verify), not just trusted on first sight. */
19
+ /** True once the account key was confirmed with a number (localauth verify), not just trusted on first sight. */
20
20
  accountVerified?: boolean
21
21
  user: { email: string; name: string }
22
22
  /** A Cloudflare Access service token, when the service sits behind Access. */
package/cli/encryption.ts CHANGED
@@ -6,7 +6,7 @@ import { createService, messageOf } from './service.ts'
6
6
  export const KEY_CHANGED = 'the account key changed; run localauth verify to check and trust the new one'
7
7
  export const KEY_REMOVED = 'end-to-end encryption was reset for this account; run localauth login to reconnect this machine'
8
8
  export const NEEDS_VERIFY = 'this account uses end-to-end encryption; verify this machine first: localauth verify'
9
- /** A pinned key that changed needs a new emoji check; one that vanished needs a new connection, never a quiet downgrade. */
9
+ /** A pinned key that changed needs a new number check; one that vanished needs a new connection, never a quiet downgrade. */
10
10
  export function checkPin(pinned: string | undefined, reported: string | null) {
11
11
  if (pinned && !reported) throw new Error(KEY_REMOVED)
12
12
  if (pinned && pinned !== reported) throw new Error(KEY_CHANGED)
@@ -24,7 +24,7 @@ export async function refreshPin(connection: Connection, say: (text: string) =>
24
24
  if (reply.status !== 200) throw new Error(messageOf(reply))
25
25
  let publicKey = reply.data.accountKey?.publicKey ?? null
26
26
  checkPin(connection.accountPublicKey, publicKey)
27
- // An account key is trusted only after the person compared emoji (localauth verify), never on sight.
27
+ // An account key is trusted only after the person matched a number (localauth verify), never on sight.
28
28
  if (publicKey && !connection.accountPublicKey) throw new Error(NEEDS_VERIFY)
29
29
  void say
30
30
  return { publicKey, machineName: reply.data.machine?.name }
package/cli/run.ts CHANGED
@@ -159,7 +159,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
159
159
  snapshot.output = redactor.redact(snapshot.output)
160
160
  for (let flow of snapshot.flows) flow.note = redactor.redact(flow.note)
161
161
  let sealed = await encodeRun(connection!, id, snapshot, signal)
162
- let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
162
+ let reply = await service('PUT', `/api/v1/machine/runs/${id}`, { state: runState(snapshot), data: sealed, kind: data.kind ?? 'login', ...(data.approval ? { approval: true } : {}), ...(data.kind === 'ask' ? { expiresAt: data.expiresAt } : {}), ...(link ? { linkTokenHash: link.tokenHash, linkData: await sealLink(link.key, 'run', id, snapshot) } : {}) }, signal).catch((error) => {
163
163
  if (!offline) say(`localauth: ${(error as Error).message}`)
164
164
  offline = true
165
165
  return null
@@ -253,7 +253,7 @@ export async function createRun(connection: Connection | null, argv: string[], s
253
253
  if (reply.status === 404) throw new Error('This request expired or was dismissed.')
254
254
  return reply.status === 200 ? reply.data.data : []
255
255
  },
256
- async openMessage<T>(kind: MessageKind, value: unknown) {
256
+ async openMessage<T>(kind: Exclude<MessageKind, 'decline'>, value: unknown) {
257
257
  return openMessage<T>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, kind, id, value)
258
258
  },
259
259
  async takeRetry(signal: AbortSignal) {
package/cli/verify.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fingerprint } from '../shared/e2e.ts'
2
- import { checkConfirmation, commitment, deriveSas, ephemeral, sasText, type Transcript } from '../shared/sas.ts'
2
+ import { checkConfirmation, commitment, deriveSas, ephemeral, type Transcript } from '../shared/sas.ts'
3
3
  import { saveConnection, type Connection } from './config.ts'
4
4
  import { createService, messageOf } from './service.ts'
5
5
 
@@ -15,21 +15,22 @@ interface MachineView {
15
15
  const POLL_MS = 1500
16
16
 
17
17
  /**
18
- * Verify the account key with a browser that holds it (shared/sas.ts): print a link, print the four
19
- * emoji once the page has answered, and trust the account key only after the browser confirms that
20
- * the person picked them. Returns true when this machine now trusts a verified account key.
18
+ * Verify the account key with a browser that holds it (shared/sas.ts): print a link, print a
19
+ * three-digit number once the page has answered, and trust the account key only after the browser
20
+ * confirms that the person tapped the same number there. Returns true when this machine now trusts a verified account key.
21
21
  */
22
22
  export async function verifyMachine(connection: Connection, say: Say, timeoutMs = 10 * 60 * 1000): Promise<boolean> {
23
23
  let service = createService(connection.url, connection)
24
24
  let own = await ephemeral()
25
25
  let started = await service<{ id: string }>('POST', '/api/v1/machine/verifications', {
26
26
  commitment: await commitment(own.publicKey, connection.publicKey),
27
+ version: 2,
27
28
  })
28
29
  if (started.status === 409) throw new Error('This account has no end-to-end encryption to verify. Add a passkey on the Account page first.')
29
30
  if (started.status !== 201) throw new Error(messageOf(started))
30
31
  let id = started.data.id
31
32
  say(`To verify this machine, open ${connection.url}/verify/${id}`)
32
- say('The emoji to pick there appear here once the page is open.')
33
+ say('The number to tap there appears here once the page is open.')
33
34
 
34
35
  let deadline = Date.now() + timeoutMs
35
36
  let view = await waitFor(service, id, deadline, (v) => v.status !== 'committed')
@@ -44,18 +45,22 @@ export async function verifyMachine(connection: Connection, say: Say, timeoutMs
44
45
  machineEphemeral: own.publicKey,
45
46
  browserEphemeral: view.browserEphemeral,
46
47
  }
47
- let { sas, macKey } = await deriveSas(own.privateKey, view.browserEphemeral, transcript)
48
+ let { code, macKey } = await deriveSas(own.privateKey, view.browserEphemeral, transcript)
48
49
  say('')
49
- say(`On the page, pick the row with: ${sasText(sas)}`)
50
+ say(`On the page, tap ${code}`)
50
51
  say('')
51
52
 
52
53
  view = await waitFor(service, id, deadline, (v) => v.status === 'confirmed' || v.status === 'rejected')
53
- if (view.status !== 'confirmed' || !view.confirmation || !(await checkConfirmation(macKey, transcript, view.confirmation))) {
54
+ // The MAC vouches for the transcript only, so trust nothing else from this reply: a relay could
55
+ // keep a valid MAC and report a key of its own next to it.
56
+ let unchanged = view.accountPublicKey === transcript.accountPublicKey && view.browserEphemeral === transcript.browserEphemeral
57
+ if (view.status !== 'confirmed' || !unchanged || !view.confirmation || !(await checkConfirmation(macKey, transcript, view.confirmation))) {
54
58
  return refused(say, view)
55
59
  }
56
- let changed = connection.accountPublicKey && connection.accountPublicKey !== view.accountPublicKey
57
- connection.accountPublicKey = view.accountPublicKey!
58
- connection.accountFingerprint = await fingerprint(view.accountPublicKey!)
60
+ let accountPublicKey = transcript.accountPublicKey
61
+ let changed = connection.accountPublicKey && connection.accountPublicKey !== accountPublicKey
62
+ connection.accountPublicKey = accountPublicKey
63
+ connection.accountFingerprint = await fingerprint(accountPublicKey)
59
64
  connection.accountVerified = true
60
65
  saveConnection(connection)
61
66
  say(changed ? '✓ Verified the new account key. This machine now encrypts to it.' : '✓ Verified. Logins from this machine are end-to-end encrypted to your account.')
@@ -75,7 +80,7 @@ async function waitFor(service: ReturnType<typeof createService>, id: string, de
75
80
  function refused(say: Say, view: MachineView): false {
76
81
  say(
77
82
  view.status === 'rejected'
78
- ? '✗ Not verified: the page said the emoji did not match, or the request was declined.'
83
+ ? '✗ Not verified: the page said the number did not match, or the request was declined.'
79
84
  : '✗ Not verified: the confirmation from the page did not check out.',
80
85
  )
81
86
  say(' Someone may be intercepting this connection. Do not send secrets to this machine until it verifies.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@localauth/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Finish CLI logins on a headless server from your phone, with optional passkey-based end-to-end encryption",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  "build": "node scripts/build-client.ts",
41
41
  "deploy": "wrangler deploy",
42
42
  "test": "NODE_ENV=test remix test",
43
- "typecheck": "tsc --noEmit",
43
+ "typecheck": "tsc --noEmit && tsc --noEmit -p app/landing-demo",
44
44
  "db:generate": "drizzle-kit generate",
45
45
  "db:migrate:local": "wrangler d1 migrations apply localauth --local",
46
46
  "db:migrate": "wrangler d1 migrations apply localauth --remote",
@@ -52,6 +52,7 @@
52
52
  "node": ">=24.3.0"
53
53
  },
54
54
  "dependencies": {
55
+ "@simplewebauthn/server": "13.3.3",
55
56
  "remix": "^3.0.0"
56
57
  },
57
58
  "devDependencies": {
@@ -62,6 +63,8 @@
62
63
  "@remix-run/ui": "0.12.1",
63
64
  "@simplewebauthn/browser": "^13.3.0",
64
65
  "@types/node": "^26.6.5",
66
+ "@types/react": "18.3.12",
67
+ "@types/react-dom": "18.3.1",
65
68
  "auth": "1.7.12",
66
69
  "better-auth": "1.7.12",
67
70
  "drizzle-kit": "^0.31.11",
@@ -70,6 +73,7 @@
70
73
  "jose": "^6.2.12",
71
74
  "patch-package": "8.0.1",
72
75
  "playwright-core": "^1.64.1",
76
+ "preact": "10.25.4",
73
77
  "typescript": "^7.0.2",
74
78
  "wrangler": "^4.151.0"
75
79
  }
@@ -21,7 +21,7 @@ export interface Flow {
21
21
  }
22
22
 
23
23
  /** Login details: plaintext in the owner API by default, opaque in end-to-end mode. */
24
- export type MessageKind = 'callback' | 'input' | 'answer'
24
+ export type MessageKind = 'callback' | 'input' | 'answer' | 'decline'
25
25
  export interface InputData { attempt: number; text: string; messageId: string }
26
26
  export interface AskField { name: string; label: string; secret: boolean; required: boolean }
27
27
  export interface AskRequest { title?: string; description?: string; fields: AskField[] }
@@ -33,6 +33,8 @@ export type AnswerData = { declined: true; messageId: string }
33
33
  export interface RunData {
34
34
  authenticatedMessages?: boolean
35
35
  kind?: 'login' | 'ask'
36
+ /** Answered only with a fresh passkey check, never by typing or through a link. */
37
+ approval?: boolean
36
38
  inputEnabled?: boolean
37
39
  secret?: boolean
38
40
  ask?: AskRequest
package/shared/sas.ts CHANGED
@@ -7,31 +7,22 @@ import { fromBase64Url, generateKeyPair, importPrivateKey, importPublicKey, toBa
7
7
  * 1. The machine makes an ephemeral ECDH key and sends only a commitment to it.
8
8
  * 2. The browser sends its ephemeral key and the account public key.
9
9
  * 3. The machine reveals its ephemeral key; the browser checks it against the commitment.
10
- * 4. Both derive four emoji and a MAC key from ECDH(ephemerals) and the whole transcript.
10
+ * 4. Both derive a short number and a MAC key from ECDH(ephemerals) and the whole transcript.
11
11
  *
12
12
  * Someone in the middle must fix their key toward the browser before seeing the browser's, and
13
13
  * toward the machine before seeing the machine's, so they can't search for keys that make both
14
- * sides show the same emoji: they get one guess in 2^24 per attempt. The person compares the
15
- * emoji the terminal prints with the options on the page; the browser then confirms with a MAC.
16
- *
17
- * The emoji are the 64 of the Matrix SAS specification, chosen to be told apart easily.
14
+ * sides show the same number: each attempt succeeds with probability 10^-SAS_DIGITS, and every
15
+ * failed one shows the person a warning. The terminal prints the number; the page offers it among
16
+ * decoys, like a phone sign-in prompt, and the person taps the one that matches. The browser then
17
+ * confirms with a MAC.
18
18
  */
19
19
 
20
- export const SAS_EMOJI: ReadonlyArray<readonly [emoji: string, name: string]> = [
21
- ['🐶', 'Dog'], ['🐱', 'Cat'], ['🦁', 'Lion'], ['🐎', 'Horse'], ['🦄', 'Unicorn'], ['🐷', 'Pig'], ['🐘', 'Elephant'], ['🐰', 'Rabbit'],
22
- ['🐼', 'Panda'], ['🐓', 'Rooster'], ['🐧', 'Penguin'], ['🐢', 'Turtle'], ['🐟', 'Fish'], ['🐙', 'Octopus'], ['🦋', 'Butterfly'], ['🌷', 'Flower'],
23
- ['🌳', 'Tree'], ['🌵', 'Cactus'], ['🍄', 'Mushroom'], ['🌏', 'Globe'], ['🌙', 'Moon'], ['☁️', 'Cloud'], ['🔥', 'Fire'], ['🍌', 'Banana'],
24
- ['🍎', 'Apple'], ['🍓', 'Strawberry'], ['🌽', 'Corn'], ['🍕', 'Pizza'], ['🎂', 'Cake'], ['❤️', 'Heart'], ['😀', 'Smiley'], ['🤖', 'Robot'],
25
- ['🎩', 'Hat'], ['👓', 'Glasses'], ['🔧', 'Spanner'], ['🎅', 'Santa'], ['👍', 'Thumbs up'], ['☂️', 'Umbrella'], ['⌛', 'Hourglass'], ['⏰', 'Clock'],
26
- ['🎁', 'Gift'], ['💡', 'Light bulb'], ['📕', 'Book'], ['✏️', 'Pencil'], ['📎', 'Paperclip'], ['✂️', 'Scissors'], ['🔒', 'Lock'], ['🔑', 'Key'],
27
- ['🔨', 'Hammer'], ['☎️', 'Telephone'], ['🏁', 'Flag'], ['🚂', 'Train'], ['🚲', 'Bicycle'], ['✈️', 'Aeroplane'], ['🚀', 'Rocket'], ['🏆', 'Trophy'],
28
- ['⚽', 'Ball'], ['🎸', 'Guitar'], ['🎺', 'Trumpet'], ['🔔', 'Bell'], ['⚓', 'Anchor'], ['🎧', 'Headphones'], ['📁', 'Folder'], ['📌', 'Pin'],
29
- ]
30
-
31
- /** Four indexes into SAS_EMOJI. */
32
- export type Sas = [number, number, number, number]
33
-
34
- /** Everything both sides must agree on; any difference changes the emoji and the MAC key. */
20
+ /** Digits in the code. The owner chose three: one chance in a thousand per attempt. */
21
+ export const SAS_DIGITS = 3
22
+ /** Numbers the page offers: the right one and decoys. */
23
+ export const SAS_CHOICES = 3
24
+
25
+ /** Everything both sides must agree on; any difference changes the number and the MAC key. */
35
26
  export interface Transcript {
36
27
  verificationId: string
37
28
  machinePublicKey: string
@@ -43,7 +34,7 @@ export interface Transcript {
43
34
  const enc = new TextEncoder()
44
35
 
45
36
  export async function commitment(machineEphemeral: string, machinePublicKey: string): Promise<string> {
46
- let digest = await crypto.subtle.digest('SHA-256', enc.encode(`localauth/sas/v1/commit|${machineEphemeral}|${machinePublicKey}`))
37
+ let digest = await crypto.subtle.digest('SHA-256', enc.encode(`localauth/sas/v2/commit|${machineEphemeral}|${machinePublicKey}`))
47
38
  return toBase64Url(new Uint8Array(digest))
48
39
  }
49
40
 
@@ -53,28 +44,51 @@ export async function ephemeral() {
53
44
  return { publicKey: pair.publicKey, privateKey: await importPrivateKey(pair.privateKey) }
54
45
  }
55
46
 
56
- /** The emoji and the confirmation MAC key, from our ephemeral private key and the peer's ephemeral. */
47
+ /** What the browser pins from the verification before it sends its own ephemeral key. */
48
+ export interface Pinned {
49
+ commitment: string
50
+ machineId: string
51
+ machinePublicKey: string
52
+ }
53
+
54
+ /**
55
+ * Whether the machine's revealed ephemeral is the one it committed to, judged only against values
56
+ * pinned before the browser sent its own key. A service that could change the commitment or the
57
+ * machine key afterwards would see the browser's key first and could try keys until the numbers
58
+ * match, which defeats the commitment.
59
+ */
60
+ export async function honestReveal(
61
+ pinned: Pinned,
62
+ revealed: { commitment: string; machineId: string; machinePublicKey: string; machineEphemeral: string | null },
63
+ ): Promise<boolean> {
64
+ if (!revealed.machineEphemeral) return false
65
+ if (revealed.commitment !== pinned.commitment || revealed.machineId !== pinned.machineId || revealed.machinePublicKey !== pinned.machinePublicKey) return false
66
+ return (await commitment(revealed.machineEphemeral, pinned.machinePublicKey)) === pinned.commitment
67
+ }
68
+
69
+ /** The code and the confirmation MAC key, from our ephemeral private key and the peer's ephemeral. */
57
70
  export async function deriveSas(ownEphemeral: CryptoKey, peerEphemeral: string, transcript: Transcript) {
58
71
  let shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'ECDH', public: await importPublicKey(peerEphemeral) }, ownEphemeral, 256))
59
72
  let material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits'])
60
73
  let bits = new Uint8Array(
61
74
  await crypto.subtle.deriveBits(
62
- { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('localauth/sas/v1'), info: enc.encode(transcriptText(transcript)) },
75
+ { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('localauth/sas/v2'), info: enc.encode(transcriptText(transcript)) },
63
76
  material,
64
- (3 + 32) * 8,
77
+ (4 + 32) * 8,
65
78
  ),
66
79
  )
67
- let n = (bits[0] << 16) | (bits[1] << 8) | bits[2]
68
- let sas: Sas = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63]
69
- let macKey = await crypto.subtle.importKey('raw', bits.slice(3), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
70
- return { sas, macKey }
80
+ // A 32-bit number reduced to SAS_DIGITS digits: the bias is below one part in four million.
81
+ let n = new DataView(bits.buffer).getUint32(0)
82
+ let code = String(n % 10 ** SAS_DIGITS).padStart(SAS_DIGITS, '0')
83
+ let macKey = await crypto.subtle.importKey('raw', bits.slice(4), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])
84
+ return { code, macKey }
71
85
  }
72
86
 
73
87
  function transcriptText(t: Transcript): string {
74
- return ['localauth/sas/v1', t.verificationId, t.machinePublicKey, t.accountPublicKey, t.machineEphemeral, t.browserEphemeral].join('|')
88
+ return ['localauth/sas/v2', t.verificationId, t.machinePublicKey, t.accountPublicKey, t.machineEphemeral, t.browserEphemeral].join('|')
75
89
  }
76
90
 
77
- /** The browser's confirmation that the person picked the matching emoji. */
91
+ /** The browser's confirmation that the person picked the matching number. */
78
92
  export async function confirmation(macKey: CryptoKey, transcript: Transcript): Promise<string> {
79
93
  let mac = await crypto.subtle.sign('HMAC', macKey, enc.encode(`confirm|${transcriptText(transcript)}`))
80
94
  return toBase64Url(new Uint8Array(mac))
@@ -88,27 +102,28 @@ export async function checkConfirmation(macKey: CryptoKey, transcript: Transcrip
88
102
  }
89
103
  }
90
104
 
91
- export function sameSas(a: Sas, b: Sas): boolean {
92
- return a.every((value, i) => value === b[i])
105
+ /** Positions where two codes differ. */
106
+ function distance(a: string, b: string) {
107
+ return [...a].filter((digit, i) => digit !== b[i]).length
93
108
  }
94
109
 
95
110
  /**
96
111
  * Wrong answers to show next to the right one: random, distinct, and each differing from the right
97
- * one and from each other in at least two places, so a glance can't confuse them.
112
+ * one and from each other in at least two digits, so a glance can't confuse them.
98
113
  */
99
- export function decoys(correct: Sas, count = 3, random: (n: number) => number = randomBelow): Sas[] {
100
- let chosen: Sas[] = []
101
- let far = (a: Sas, b: Sas) => a.filter((value, i) => value !== b[i]).length >= 2
114
+ export function decoys(correct: string, count = SAS_CHOICES - 1, random: (n: number) => number = randomBelow): string[] {
115
+ let chosen: string[] = []
116
+ let far = (a: string, b: string) => distance(a, b) >= Math.min(2, SAS_DIGITS)
102
117
  while (chosen.length < count) {
103
- let candidate = [random(64), random(64), random(64), random(64)] as Sas
118
+ let candidate = Array.from({ length: SAS_DIGITS }, () => random(10)).join('')
104
119
  if (far(candidate, correct) && chosen.every((other) => far(candidate, other))) chosen.push(candidate)
105
120
  }
106
121
  return chosen
107
122
  }
108
123
 
109
- /** The right answer and the decoys in random order. */
110
- export function options(correct: Sas, random: (n: number) => number = randomBelow): Sas[] {
111
- let all = [correct, ...decoys(correct, 3, random)]
124
+ /** The right code and the decoys in random order. */
125
+ export function options(correct: string, random: (n: number) => number = randomBelow): string[] {
126
+ let all = [correct, ...decoys(correct, SAS_CHOICES - 1, random)]
112
127
  for (let i = all.length - 1; i > 0; i--) {
113
128
  let j = random(i + 1)
114
129
  ;[all[i], all[j]] = [all[j], all[i]]
@@ -125,10 +140,6 @@ function randomBelow(n: number): number {
125
140
  }
126
141
  }
127
142
 
128
- export function sasText(sas: Sas): string {
129
- return sas.map((i) => `${SAS_EMOJI[i][0]} ${SAS_EMOJI[i][1]}`).join(' ')
130
- }
131
-
132
143
  /**
133
144
  * Cross-device record that this account verified a machine's key: an HMAC under a key only holders
134
145
  * of the account private key can derive (ECDH of the account key with itself), so the service
@@ -10,6 +10,7 @@ export function isRunData(value: unknown): value is RunData {
10
10
  return !!v && typeof v.title === 'string' && typeof v.command === 'string' && typeof v.output === 'string'
11
11
  && (v.authenticatedMessages === undefined || typeof v.authenticatedMessages === 'boolean')
12
12
  && (v.kind === undefined || ['login', 'ask'].includes(v.kind))
13
+ && (v.approval === undefined || (v.approval === true && v.kind === 'ask'))
13
14
  && (v.inputEnabled === undefined || typeof v.inputEnabled === 'boolean')
14
15
  && (v.secret === undefined || typeof v.secret === 'boolean')
15
16
  && (v.ask === undefined || (v.kind === 'ask' && isAskRequest(v.ask)))
@@ -16,9 +16,9 @@ Tokens are created on this machine.
16
16
  For scripts, the public API is documented at https://localauth.dev/docs/api and
17
17
  https://localauth.dev/api/v1/openapi.json. The owner can create a scoped personal token on the
18
18
  Account page and send it as `Authorization: Bearer lat_…`. It cannot change the account,
19
- encryption, passkeys, tokens or emoji verification. End-to-end API data stays sealed; a client
19
+ encryption, passkeys, tokens or machine verification. End-to-end API data stays sealed; a client
20
20
  needs the account key to read or answer it. Browser agents may use the page's WebMCP login
21
- and machine-pairing tools; a person still compares emoji. CLI 0.2.0 uses `/api/v1/machine/*`;
21
+ and machine-pairing tools; a person still taps the matching number. CLI 0.2.0 uses `/api/v1/machine/*`;
22
22
  link clients use `/api/v1/links/*`.
23
23
  After the API upgrade, CLI 0.1.0 receives HTTP 410 with the update instruction. Ask the owner
24
24
  to update it; never change an installed CLI or its config without authorization.
@@ -34,9 +34,8 @@ localauth status
34
34
  - "not verified yet: run localauth verify" (or a command refusing with "verify this machine
35
35
  first"): the account uses end-to-end encryption and this machine hasn't checked its key. Run
36
36
  `localauth verify` in the background and send the user the link it prints. Once they open it,
37
- the command prints a line of four emoji with names ("pick the row with: 🐶 Dog 🍎 Apple …"):
38
- send that line too. On the page they pick the row with the same four emoji, or "None of these
39
- match". It exits 0 when verified. If it reports a mismatch, stop: someone may be intercepting
37
+ the command prints a three-digit number ("On the page, tap 482"): send that number too. On the
38
+ page they tap the same number among three, or "None of these match". It exits 0 when verified. If it reports a mismatch, stop: someone may be intercepting
40
39
  the connection, and the user should not send secrets to this machine.
41
40
  - "the account key changed": run `localauth verify` as above. "end-to-end encryption was reset":
42
41
  reconnect with `localauth login`. Never edit the config to get around either.
@@ -47,7 +46,7 @@ localauth status
47
46
  To connect, run `localauth login` in the background (it waits for approval) and send the user the
48
47
  link it prints, with the code it shows. They open it, sign in, check the code and tap **Connect**;
49
48
  `localauth login` then exits by itself. If the account uses end-to-end encryption, it then prints
50
- four emoji: send that line, and the user picks the matching row on the same page. If it says to
49
+ a three-digit number: send it, and the user taps the same number on the same page. If it says to
51
50
  sign in at the service first (a self-hosted copy behind Cloudflare Access), pass that on too.
52
51
  Links contain only the device code or a verification id, never keys.
53
52
 
@@ -186,3 +185,12 @@ nothing to paste, the command finishes on its own once the user approves.
186
185
  title on the page (Kargo, Codex, …).
187
186
  - If the user gives up, stop the background `localauth run` process so the command stops listening.
188
187
  - `localauth help` lists every command.
188
+
189
+ ## Asking the owner to allow something
190
+
191
+ ```sh
192
+ localauth approve --title "Allow access to the production database" --description "Why it is needed"
193
+ ```
194
+
195
+ It exits 0 if approved and 1 if declined or timed out. The owner answers from their signed-in list with a new passkey check, so there is no link to send them: tell them to open localauth.dev. It needs end-to-end encryption on the account.
196
+