@localauth/cli 0.1.0 → 0.3.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 +113 -11
- package/cli/cli.ts +11 -11
- package/cli/config.ts +1 -1
- package/cli/encryption.ts +3 -3
- package/cli/run.ts +7 -7
- package/cli/service.ts +16 -2
- package/cli/verify.ts +21 -16
- package/package.json +9 -4
- package/shared/protocol.ts +2 -2
- package/shared/sas.ts +55 -44
- package/skills/localauth/SKILL.md +13 -4
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ 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 #
|
|
62
|
+
localauth verify # match a number on the page to trust end-to-end encryption
|
|
63
63
|
localauth logout # disconnect this machine
|
|
64
64
|
```
|
|
65
65
|
|
|
@@ -156,6 +156,76 @@ input and answers with their link key in either mode. The Secret toggle only con
|
|
|
156
156
|
a CLI login needs a browser on a machine you are not sitting at. Copy or link it into your agent's
|
|
157
157
|
skills directory, for example `~/.claude/skills/localauth`.
|
|
158
158
|
|
|
159
|
+
## Public API and browser tools
|
|
160
|
+
|
|
161
|
+
The [API reference](https://localauth.dev/docs/api) and
|
|
162
|
+
[OpenAPI 3.1 document](https://localauth.dev/api/v1/openapi.json) describe the public `/api/v1` API.
|
|
163
|
+
Changes within v1 are additive; incompatible changes get a new path version.
|
|
164
|
+
The website and CLI 0.2.0 use this same API. Better Auth stays at `/api/auth/*`, including the
|
|
165
|
+
iOS app's OAuth endpoints.
|
|
166
|
+
|
|
167
|
+
**Breaking upgrade to CLI 0.2.0.** The old `/api/me`, `/api/account/*`, `/api/runs/*`,
|
|
168
|
+
`/api/machines/*`, `/api/pairings/*`, `/api/verifications/*`, `/api/r/*` and `/api/tokens/*`
|
|
169
|
+
routes are removed. `/api/m/*` returns HTTP 410 with an instruction to run
|
|
170
|
+
`npm install -g @localauth/cli`. Existing scripts must use `/api/v1` paths, direct single
|
|
171
|
+
resources, paginated lists and the nested error shape. Open website tabs should reload.
|
|
172
|
+
Machine tokens, saved configuration, account keys and encrypted envelopes do not need conversion.
|
|
173
|
+
|
|
174
|
+
CLI 0.1.0 stops working once the Worker is updated, and CLI 0.2.0 needs the updated Worker:
|
|
175
|
+
update both together. Self-hosted Access deployments must move their machine-route bypass policy
|
|
176
|
+
to `/api/v1/machine/*` first.
|
|
177
|
+
|
|
178
|
+
Create a personal access token in **Account, API tokens**. Choose a name, permissions and,
|
|
179
|
+
optionally, an expiry. Copy it when it appears; the service stores only a hash of it.
|
|
180
|
+
The card lists creation, last use and expiry, and lets you revoke a token immediately.
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
curl https://localauth.dev/api/v1/runs \
|
|
184
|
+
-H 'Authorization: Bearer lat_YOUR_TOKEN'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Permissions are `runs:read`, `runs:write`, `machines:read` and `machines:write`.
|
|
188
|
+
Personal tokens cannot change the account, encryption keys, passkeys, other tokens or machine
|
|
189
|
+
verification. In Cloudflare Access mode, personal tokens and OAuth are disabled.
|
|
190
|
+
The website uses the Access JWT and the CLI uses its machine token.
|
|
191
|
+
|
|
192
|
+
Responses use camelCase JSON and epoch milliseconds. Lists return `{ data, nextCursor }`
|
|
193
|
+
and accept `limit` (1–100, default 50) and `cursor`. Scripts and native clients use the
|
|
194
|
+
Authorization header. The website can also use its same-origin session cookie or Access JWT.
|
|
195
|
+
Browser credentials require matching Origin or `Sec-Fetch-Site: same-origin` on reads and
|
|
196
|
+
mutations; contradictory signals are rejected. An Authorization header always takes precedence,
|
|
197
|
+
even if invalid. Cross-origin clients use explicit header credentials; CORS never permits
|
|
198
|
+
cookie credentials. Errors return `{ error: { code, message } }`; rate limits return 429
|
|
199
|
+
with `Retry-After`. Machine tokens and link capabilities have separate routes and authority.
|
|
200
|
+
|
|
201
|
+
The first time someone signs in to the iOS app, localauth.dev shows what the app will be able to
|
|
202
|
+
do and asks them to allow it (`/oauth/consent`); the answer is remembered. **Account, Signed-in
|
|
203
|
+
apps** lists each device where the app is signed in and signs one out at once; the same list is
|
|
204
|
+
`GET /api/v1/app-sessions` and `DELETE /api/v1/app-sessions/{id}` (account scope).
|
|
205
|
+
|
|
206
|
+
The iOS app (client `localauth-ios`) signs in with Better Auth's OAuth provider:
|
|
207
|
+
`/api/auth/oauth2/authorize`, `/api/auth/oauth2/token` and `/api/auth/oauth2/revoke`, with S256
|
|
208
|
+
PKCE and an exact redirect allowlist. Access tokens last an hour; refresh tokens last 90 days and
|
|
209
|
+
rotate on use. A retry within 10 seconds gets the same new pair; reusing a rotated refresh token
|
|
210
|
+
later revokes all of the app's tokens for that person. The app stays signed in independently of
|
|
211
|
+
the browser session it signed in through (it asks for `offline_access`). The app revokes its
|
|
212
|
+
refresh token on sign-out, deleting the account removes every token, and disabling the client row
|
|
213
|
+
switches the whole app off. Third-party clients and registration are not offered. The plugin has a
|
|
214
|
+
documented race in that revocation, which this repository patches; see
|
|
215
|
+
[patches/README.md](patches/README.md).
|
|
216
|
+
|
|
217
|
+
End-to-end data stays sealed through the API. A client that needs plaintext must hold the
|
|
218
|
+
account private key, obtained locally from a passkey PRF wrap, and implement the
|
|
219
|
+
[encryption protocol and fixed test vectors](docs/protocol.md). An OAuth token alone cannot
|
|
220
|
+
decrypt it. Machine-side callback checks and account-key pinning remain mandatory.
|
|
221
|
+
|
|
222
|
+
On browsers supporting WebMCP, dashboard and link pages register login tools and the dashboard
|
|
223
|
+
and device page register machine approval tools. They use the page's existing decryption,
|
|
224
|
+
sealing, callback checks and confirmation dialogs. A link page sees only its run. A locked
|
|
225
|
+
browser asks the person to unlock on the page. Machine output and names are untrusted data.
|
|
226
|
+
There are no WebMCP tools for machine verification, account, encryption, passkey or token changes,
|
|
227
|
+
sign-out or deletion. A person still taps the matching number after approving an E2E machine.
|
|
228
|
+
|
|
159
229
|
## Encryption
|
|
160
230
|
|
|
161
231
|
By default, any browser signed in to your account can read your logins. The Worker encrypts
|
|
@@ -187,15 +257,15 @@ public-key-only envelopes, and messages from a different account key. The servic
|
|
|
187
257
|
private key, so it cannot forge or alter these messages. Input is bound to the current attempt
|
|
188
258
|
and deduplicated by an authenticated message id. Ciphertexts bind their kind and record to prevent swapping.
|
|
189
259
|
|
|
190
|
-
A machine trusts the account public key only after a short
|
|
260
|
+
A machine trusts the account public key only after a short number check, so the service can't
|
|
191
261
|
hand it a key of its own. `localauth verify` (and `localauth login` on an end-to-end account)
|
|
192
262
|
prints a link; the page and the machine exchange ephemeral ECDH keys through the service, the
|
|
193
|
-
machine having committed to its key before seeing the browser's, and both derive
|
|
194
|
-
from the shared secret and everything exchanged
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
263
|
+
machine having committed to its key before seeing the browser's, and both derive a three-digit
|
|
264
|
+
number from the shared secret and everything exchanged. The terminal prints the number; the page
|
|
265
|
+
offers it among two decoys and "None of these match", like a phone sign-in prompt, so nobody
|
|
266
|
+
types anything. Tapping the right number sends a MAC of the exchange that the machine checks
|
|
267
|
+
before pinning the key. A service in the middle would have to guess the number, a 1 in 1000
|
|
268
|
+
chance per attempt, and every failed attempt shows a warning on both sides. The browser also
|
|
199
269
|
stores the account's MAC over the machine's key so other browsers show it as verified.
|
|
200
270
|
|
|
201
271
|
Until it is verified, a machine refuses to run logins for an end-to-end account. If the service
|
|
@@ -257,8 +327,9 @@ production modes. Set it to a securely generated random value and retain it acro
|
|
|
257
327
|
npx wrangler secret put DATA_KEY
|
|
258
328
|
```
|
|
259
329
|
|
|
260
|
-
Machines talk to `/api/
|
|
261
|
-
routes check machine tokens themselves), or create an Access service
|
|
330
|
+
Machines talk to `/api/v1/machine/*` without a browser. Either add a Bypass policy for
|
|
331
|
+
`/api/v1/machine/*` (those routes check machine tokens themselves), or create an Access service
|
|
332
|
+
token, allow it with a
|
|
262
333
|
Service Auth policy and set `LOCALAUTH_ACCESS_CLIENT_ID` and `LOCALAUTH_ACCESS_CLIENT_SECRET`
|
|
263
334
|
before `localauth login`. Remove the `send_email` binding if you don't use email.
|
|
264
335
|
|
|
@@ -275,6 +346,37 @@ npx wrangler email sending enable <your domain> # sign-in codes by email
|
|
|
275
346
|
|
|
276
347
|
Sign-in options without credentials are hidden. Passkeys need no setup.
|
|
277
348
|
|
|
349
|
+
**Sign in with Apple.** In Apple Developer, create an App ID with Sign in with Apple enabled,
|
|
350
|
+
then a web Services ID associated with that primary App ID. Configure the service's domain
|
|
351
|
+
and the return URL `https://YOUR_DOMAIN/api/auth/callback/apple`. Download a Sign in with
|
|
352
|
+
Apple `.p8` key, keeping its Key ID and your Team ID. Set these Worker secrets:
|
|
353
|
+
|
|
354
|
+
```sh
|
|
355
|
+
npx wrangler secret put APPLE_CLIENT_ID # web Services ID
|
|
356
|
+
npx wrangler secret put APPLE_TEAM_ID
|
|
357
|
+
npx wrangler secret put APPLE_KEY_ID
|
|
358
|
+
npx wrangler secret put APPLE_PRIVATE_KEY # full .p8 PEM contents
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The Worker signs and caches Apple's ES256 client secret and renews it before its 180-day
|
|
362
|
+
expiry. There is no manually maintained client-secret JWT. Apple appears first on the sign-in
|
|
363
|
+
page once all four values exist; it can also be connected or disconnected in Account.
|
|
364
|
+
Apple's cross-site form callback is allowed only at its exact POST callback endpoint; OAuth
|
|
365
|
+
state validation still applies. Apple requires an HTTPS domain for callbacks.
|
|
366
|
+
|
|
367
|
+
When the app's bundle ID is decided, enable its Sign in with Apple capability and set
|
|
368
|
+
`APPLE_APP_BUNDLE_ID` to accept native identity-token audiences as well as the web Services ID.
|
|
369
|
+
Set `OAUTH_IOS_REDIRECT_URIS` to the comma-separated exact redirect URLs selected for the app
|
|
370
|
+
(a universal link and/or its custom scheme). No value is invented by the server, and an empty
|
|
371
|
+
allowlist disables authorization. The app's associated-domain file and push notifications
|
|
372
|
+
come with the iOS app; they are not part of this change.
|
|
373
|
+
|
|
374
|
+
Apple's Hide My Email can supply a private relay address. Automatic account linking uses a
|
|
375
|
+
verified email, so a relay address creates a separate localauth account from a different
|
|
376
|
+
GitHub or Google email. To use the same account, sign in to that localauth account first,
|
|
377
|
+
then connect Apple from Connected accounts. Configure Apple's private email relay sender
|
|
378
|
+
registration if you send sign-in email to relay addresses.
|
|
379
|
+
|
|
278
380
|
Then create the tables and deploy:
|
|
279
381
|
|
|
280
382
|
```sh
|
|
@@ -297,7 +399,7 @@ npm run design:qa # screenshots of every design preview, light and dark, ph
|
|
|
297
399
|
```
|
|
298
400
|
|
|
299
401
|
While `npm run dev` runs, http://localhost:8787/design shows every page in every state (signed
|
|
300
|
-
out, waiting logins, failed retries,
|
|
402
|
+
out, waiting logins, failed retries, number verification, dialogs, …) with made-up data, and
|
|
301
403
|
`/design/<id>` opens one with a switcher in the bottom right for states and light/dark. They are
|
|
302
404
|
for tuning the design: no real data, no API calls, and they don't exist in deployed Workers.
|
|
303
405
|
|
package/cli/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Flow, PairingResult, PairingStart, RunData, RunRecord } from '../s
|
|
|
9
9
|
import { accessFromEnv, configPath, loadConnection, removeConnection, saveConnection, serviceUrl, type Connection } from './config.ts'
|
|
10
10
|
import { parseRunArgs } from './retry.ts'
|
|
11
11
|
import { reportBrowserUrls, runWrapped } from './run.ts'
|
|
12
|
-
import { createService, messageOf, Unreachable } from './service.ts'
|
|
12
|
+
import { createService, listAll, messageOf, Unreachable } from './service.ts'
|
|
13
13
|
import { verifyMachine } from './verify.ts'
|
|
14
14
|
|
|
15
15
|
const HELP = `localauth: finish CLI logins on this machine from your phone
|
|
@@ -36,7 +36,7 @@ Usage:
|
|
|
36
36
|
(reads it from stdin when omitted, keeping it out of ps)
|
|
37
37
|
localauth list [--json] This machine's logins and requests
|
|
38
38
|
localauth show <id> [--json] One login or request with its output
|
|
39
|
-
localauth verify
|
|
39
|
+
localauth verify Match a number on the page to trust the account's encryption key
|
|
40
40
|
localauth status Which account and service this machine is connected to
|
|
41
41
|
localauth url The page to open to finish logins
|
|
42
42
|
localauth logout Disconnect this machine
|
|
@@ -130,7 +130,7 @@ export async function main(args: string[]): Promise<number> {
|
|
|
130
130
|
terminal.writeLine(`Not connected. Run: localauth login`)
|
|
131
131
|
return 1
|
|
132
132
|
}
|
|
133
|
-
let reply = await createService(connection.url, connection)('GET', '/api/
|
|
133
|
+
let reply = await createService(connection.url, connection)('GET', '/api/v1/machine/me')
|
|
134
134
|
if (reply.status === 401) return fail(`${connection.url} no longer knows this machine. Run: localauth login`)
|
|
135
135
|
if (reply.status !== 200) return fail(messageOf(reply))
|
|
136
136
|
let encryption = await refreshPin(connection).then(
|
|
@@ -157,7 +157,7 @@ export async function main(args: string[]): Promise<number> {
|
|
|
157
157
|
terminal.writeLine('Not connected.')
|
|
158
158
|
return 0
|
|
159
159
|
}
|
|
160
|
-
await createService(connection.url, connection)('DELETE', '/api/
|
|
160
|
+
await createService(connection.url, connection)('DELETE', '/api/v1/machine/me').catch(() => null)
|
|
161
161
|
removeConnection()
|
|
162
162
|
terminal.writeLine(`Disconnected from ${connection.url}.`)
|
|
163
163
|
return 0
|
|
@@ -198,7 +198,7 @@ function requireConnection(): Connection {
|
|
|
198
198
|
async function login(url: string, name: string): Promise<number> {
|
|
199
199
|
let keys = await generateKeyPair()
|
|
200
200
|
let service = createService(url, { token: '', access: accessFromEnv() })
|
|
201
|
-
let started = await service<PairingStart>('POST', '/api/
|
|
201
|
+
let started = await service<PairingStart>('POST', '/api/v1/machine/pair', { publicKey: keys.publicKey, name })
|
|
202
202
|
if (started.status !== 201) return fail(`${url} refused the connection: ${messageOf(started)}`)
|
|
203
203
|
|
|
204
204
|
// Build the link from the requested service URL, never an address returned by the service.
|
|
@@ -214,7 +214,7 @@ async function login(url: string, name: string): Promise<number> {
|
|
|
214
214
|
let deadline = Date.now() + started.data.expiresIn * 1000
|
|
215
215
|
while (Date.now() < deadline) {
|
|
216
216
|
await new Promise((resolve) => setTimeout(resolve, started.data.interval * 1000))
|
|
217
|
-
let reply = await service<PairingResult>('POST', '/api/
|
|
217
|
+
let reply = await service<PairingResult>('POST', '/api/v1/machine/pair/token', { deviceCode: started.data.deviceCode }).catch(
|
|
218
218
|
() => null,
|
|
219
219
|
)
|
|
220
220
|
if (!reply || reply.status === 202) continue
|
|
@@ -229,9 +229,9 @@ async function login(url: string, name: string): Promise<number> {
|
|
|
229
229
|
}
|
|
230
230
|
saveConnection(connection)
|
|
231
231
|
terminal.writeLine(`${styles.green('✓')} Connected as ${styles.bold(reply.data.user.email)}.`)
|
|
232
|
-
let me = await createService(url, connection)<{ accountKey: { publicKey: string } | null }>('GET', '/api/
|
|
232
|
+
let me = await createService(url, connection)<{ accountKey: { publicKey: string } | null }>('GET', '/api/v1/machine/me')
|
|
233
233
|
if (me.status === 200 && me.data.accountKey) {
|
|
234
|
-
terminal.writeLine('This account uses end-to-end encryption, so check this machine with
|
|
234
|
+
terminal.writeLine('This account uses end-to-end encryption, so check this machine with a number.')
|
|
235
235
|
if (!(await verifyMachine(connection, (line) => terminal.writeLine(line)))) return 1
|
|
236
236
|
}
|
|
237
237
|
terminal.writeLine(`Run logins with: localauth run -- <command> Finish them at ${url}/`)
|
|
@@ -252,12 +252,12 @@ interface OpenedRun {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
async function openRuns(connection: Connection): Promise<OpenedRun[]> {
|
|
255
|
-
let reply = await createService(connection.url, connection)
|
|
255
|
+
let reply = await listAll<RunRecord>(createService(connection.url, connection), '/api/v1/machine/runs')
|
|
256
256
|
if (reply.status === 401) throw new NotConnected(`${connection.url} no longer knows this machine. Run: localauth login`)
|
|
257
257
|
if (reply.status !== 200) throw new Unreachable(messageOf(reply))
|
|
258
258
|
await refreshPin(connection)
|
|
259
259
|
return Promise.all(
|
|
260
|
-
reply.data.
|
|
260
|
+
reply.data.data.map(async (record) => ({
|
|
261
261
|
record,
|
|
262
262
|
data: await decodeRun(connection, record).catch(() => null),
|
|
263
263
|
})),
|
|
@@ -277,7 +277,7 @@ async function finish(connection: Connection, pasted: string, runId: string | un
|
|
|
277
277
|
let { run, flow } = match
|
|
278
278
|
await refreshPin(connection)
|
|
279
279
|
let service = createService(connection.url, connection)
|
|
280
|
-
let sent = await service('POST', `/api/
|
|
280
|
+
let sent = await service('POST', `/api/v1/machine/runs/${run.record.id}/callbacks`, await encodeCallback(connection, run.record, { flowId: flow.id, url: pasted.trim() }))
|
|
281
281
|
if (sent.status !== 202) return fail(messageOf(sent))
|
|
282
282
|
|
|
283
283
|
let deadline = Date.now() + 30_000
|
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
|
|
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
|
|
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)
|
|
@@ -20,11 +20,11 @@ export async function refreshPin(connection: Connection, say: (text: string) =>
|
|
|
20
20
|
connection.accountPublicKey = saved.accountPublicKey
|
|
21
21
|
connection.accountFingerprint = saved.accountFingerprint
|
|
22
22
|
}
|
|
23
|
-
let reply = await createService(connection.url, connection)<{ accountKey: { publicKey: string } | null; machine: { name: string } }>('GET', '/api/
|
|
23
|
+
let reply = await createService(connection.url, connection)<{ accountKey: { publicKey: string } | null; machine: { name: string } }>('GET', '/api/v1/machine/me', undefined, signal)
|
|
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
|
|
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/
|
|
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) => {
|
|
163
163
|
if (!offline) say(`localauth: ${(error as Error).message}`)
|
|
164
164
|
offline = true
|
|
165
165
|
return null
|
|
@@ -249,16 +249,16 @@ export async function createRun(connection: Connection | null, argv: string[], s
|
|
|
249
249
|
async takeMessages(signal: AbortSignal) {
|
|
250
250
|
if (!service) return []
|
|
251
251
|
await refreshPin(connection!, say, signal)
|
|
252
|
-
let reply = await service<{
|
|
252
|
+
let reply = await service<{ data: { kind?: MessageKind; data: unknown }[] }>('POST', `/api/v1/machine/runs/${id}/callbacks/take`, undefined, signal)
|
|
253
253
|
if (reply.status === 404) throw new Error('This request expired or was dismissed.')
|
|
254
|
-
return reply.status === 200 ? reply.data.
|
|
254
|
+
return reply.status === 200 ? reply.data.data : []
|
|
255
255
|
},
|
|
256
256
|
async openMessage<T>(kind: MessageKind, 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) {
|
|
260
260
|
if (!service) return false
|
|
261
|
-
let reply = await service<{ retry: boolean }>('POST', `/api/
|
|
261
|
+
let reply = await service<{ retry: boolean }>('POST', `/api/v1/machine/runs/${id}/retry/take`, undefined, signal)
|
|
262
262
|
return reply.status === 200 && reply.data.retry === true
|
|
263
263
|
},
|
|
264
264
|
startCallbacks(onInput?: (text: string) => void) {
|
|
@@ -270,10 +270,10 @@ export async function createRun(connection: Connection | null, argv: string[], s
|
|
|
270
270
|
await sleep(POLL_MS, undefined, { signal })
|
|
271
271
|
if (!service || (!options.input && !data.flows.some((f) => f.status === 'waiting'))) continue
|
|
272
272
|
await refreshPin(connection!, say, signal)
|
|
273
|
-
let reply = await service<{
|
|
274
|
-
'POST', `/api/
|
|
273
|
+
let reply = await service<{ data: { id: string; kind?: MessageKind; data: string | CallbackData }[] }>(
|
|
274
|
+
'POST', `/api/v1/machine/runs/${id}/callbacks/take`, undefined, signal,
|
|
275
275
|
).catch(() => null)
|
|
276
|
-
for (let message of reply?.status === 200 ? reply.data.
|
|
276
|
+
for (let message of reply?.status === 200 ? reply.data.data : []) {
|
|
277
277
|
if (message.kind === 'input') {
|
|
278
278
|
if (options.input !== true || !onInput) continue
|
|
279
279
|
let input = await openMessage<InputData>({ privateKey: key!, publicKey: connection!.publicKey, accountPublicKey: connection?.accountPublicKey, linkKey: link?.key }, 'input', id, message.data)
|
package/cli/service.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Connection } from './config.ts'
|
|
|
2
2
|
|
|
3
3
|
export interface Reply<T = any> {
|
|
4
4
|
status: number
|
|
5
|
-
data: T & { error?: string; message?: string }
|
|
5
|
+
data: T & { error?: string | { code: string; message: string }; message?: string }
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export class Unreachable extends Error {}
|
|
@@ -48,5 +48,19 @@ export function createService(url: string, connection?: Pick<Connection, 'token'
|
|
|
48
48
|
export type Service = ReturnType<typeof createService>
|
|
49
49
|
|
|
50
50
|
export function messageOf(reply: Reply): string {
|
|
51
|
-
|
|
51
|
+
let error = reply.data?.error
|
|
52
|
+
return (typeof error === 'string' ? error : error?.message) ?? reply.data?.message ?? `HTTP ${reply.status}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Follow v1 cursors rather than silently truncating a machine's run history. */
|
|
56
|
+
export async function listAll<T>(service: Service, path: string): Promise<Reply<{ data: T[]; nextCursor: string | null }>> {
|
|
57
|
+
let data: T[] = [], cursor: string | null = null
|
|
58
|
+
do {
|
|
59
|
+
let query: string = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''
|
|
60
|
+
let reply: Reply<{ data: T[]; nextCursor: string | null }> = await service<{ data: T[]; nextCursor: string | null }>('GET', path + query)
|
|
61
|
+
if (reply.status !== 200) return reply
|
|
62
|
+
data.push(...reply.data.data)
|
|
63
|
+
cursor = reply.data.nextCursor
|
|
64
|
+
} while (cursor)
|
|
65
|
+
return { status: 200, data: { data, nextCursor: null } }
|
|
52
66
|
}
|
package/cli/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fingerprint } from '../shared/e2e.ts'
|
|
2
|
-
import { checkConfirmation, commitment, deriveSas, ephemeral,
|
|
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,27 +15,28 @@ 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
|
|
19
|
-
*
|
|
20
|
-
* the person
|
|
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
|
-
let started = await service<{ id: string }>('POST', '/api/
|
|
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
|
|
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')
|
|
36
37
|
if (view.status !== 'answered' || !view.browserEphemeral || !view.accountPublicKey) return refused(say, view)
|
|
37
38
|
|
|
38
|
-
let revealed = await service('POST', `/api/
|
|
39
|
+
let revealed = await service('POST', `/api/v1/machine/verifications/${id}/reveal`, { ephemeral: own.publicKey })
|
|
39
40
|
if (revealed.status !== 200) throw new Error(messageOf(revealed))
|
|
40
41
|
let transcript: Transcript = {
|
|
41
42
|
verificationId: id,
|
|
@@ -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 {
|
|
48
|
+
let { code, macKey } = await deriveSas(own.privateKey, view.browserEphemeral, transcript)
|
|
48
49
|
say('')
|
|
49
|
-
say(`On the page,
|
|
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
|
-
|
|
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
|
|
57
|
-
connection.accountPublicKey
|
|
58
|
-
connection.
|
|
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.')
|
|
@@ -64,9 +69,9 @@ export async function verifyMachine(connection: Connection, say: Say, timeoutMs
|
|
|
64
69
|
|
|
65
70
|
async function waitFor(service: ReturnType<typeof createService>, id: string, deadline: number, done: (v: MachineView) => boolean) {
|
|
66
71
|
while (Date.now() < deadline) {
|
|
67
|
-
let reply = await service<
|
|
72
|
+
let reply = await service<MachineView>('GET', `/api/v1/machine/verifications/${id}`).catch(() => null)
|
|
68
73
|
if (reply?.status === 404) throw new Error('This verification expired. Run localauth verify again.')
|
|
69
|
-
if (reply?.status === 200 && done(reply.data
|
|
74
|
+
if (reply?.status === 200 && done(reply.data)) return reply.data
|
|
70
75
|
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
|
|
71
76
|
}
|
|
72
77
|
throw new Error('Nobody finished the verification in time. Run localauth verify again.')
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
"!cli/*.test.ts",
|
|
32
32
|
"shared/*.ts",
|
|
33
33
|
"!shared/*.test.ts",
|
|
34
|
+
"!shared/protocol-vectors.ts",
|
|
34
35
|
"skills/localauth/SKILL.md"
|
|
35
36
|
],
|
|
36
37
|
"scripts": {
|
|
38
|
+
"prepare": "patch-package",
|
|
37
39
|
"dev": "DESIGN_PREVIEW=1 wrangler dev --local-upstream localhost:8787 --var DESIGN_PREVIEW:1",
|
|
38
40
|
"build": "node scripts/build-client.ts",
|
|
39
41
|
"deploy": "wrangler deploy",
|
|
@@ -53,17 +55,20 @@
|
|
|
53
55
|
"remix": "^3.0.0"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
|
-
"@better-auth/
|
|
58
|
+
"@better-auth/api-key": "1.7.12",
|
|
59
|
+
"@better-auth/oauth-provider": "1.7.12",
|
|
60
|
+
"@better-auth/passkey": "1.7.12",
|
|
57
61
|
"@cloudflare/workers-types": "^5.20261016.1",
|
|
58
62
|
"@remix-run/ui": "0.12.1",
|
|
59
63
|
"@simplewebauthn/browser": "^13.3.0",
|
|
60
64
|
"@types/node": "^26.6.5",
|
|
61
|
-
"auth": "
|
|
62
|
-
"better-auth": "
|
|
65
|
+
"auth": "1.7.12",
|
|
66
|
+
"better-auth": "1.7.12",
|
|
63
67
|
"drizzle-kit": "^0.31.11",
|
|
64
68
|
"drizzle-orm": "^0.45.4",
|
|
65
69
|
"esbuild": "^0.28.2",
|
|
66
70
|
"jose": "^6.2.12",
|
|
71
|
+
"patch-package": "8.0.1",
|
|
67
72
|
"playwright-core": "^1.64.1",
|
|
68
73
|
"typescript": "^7.0.2",
|
|
69
74
|
"wrangler": "^4.151.0"
|
package/shared/protocol.ts
CHANGED
|
@@ -92,7 +92,7 @@ export interface VerificationRecord {
|
|
|
92
92
|
expiresAt: number
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** Response of POST /api/
|
|
95
|
+
/** Response of POST /api/v1/machine/pair: what `localauth login` shows and polls with. */
|
|
96
96
|
export interface PairingStart {
|
|
97
97
|
deviceCode: string
|
|
98
98
|
userCode: string
|
|
@@ -104,7 +104,7 @@ export interface PairingStart {
|
|
|
104
104
|
signInFirst?: boolean
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Response of POST /api/
|
|
107
|
+
/** Response of POST /api/v1/machine/pair/token once approved. */
|
|
108
108
|
export interface PairingResult {
|
|
109
109
|
token: string
|
|
110
110
|
machineId: string
|
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
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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/
|
|
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
|
-
/**
|
|
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/
|
|
75
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode('localauth/sas/v2'), info: enc.encode(transcriptText(transcript)) },
|
|
63
76
|
material,
|
|
64
|
-
(
|
|
77
|
+
(4 + 32) * 8,
|
|
65
78
|
),
|
|
66
79
|
)
|
|
67
|
-
|
|
68
|
-
let
|
|
69
|
-
let
|
|
70
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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:
|
|
100
|
-
let chosen:
|
|
101
|
-
let far = (a:
|
|
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 =
|
|
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
|
|
110
|
-
export function options(correct:
|
|
111
|
-
let all = [correct, ...decoys(correct,
|
|
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
|
|
@@ -13,6 +13,16 @@ the user opens anywhere (localauth.dev, or their own deployment), and forwards t
|
|
|
13
13
|
address they bring back to the waiting command. Data is encrypted at rest by default, with optional passkey-based end-to-end encryption.
|
|
14
14
|
Tokens are created on this machine.
|
|
15
15
|
|
|
16
|
+
For scripts, the public API is documented at https://localauth.dev/docs/api and
|
|
17
|
+
https://localauth.dev/api/v1/openapi.json. The owner can create a scoped personal token on the
|
|
18
|
+
Account page and send it as `Authorization: Bearer lat_…`. It cannot change the account,
|
|
19
|
+
encryption, passkeys, tokens or machine verification. End-to-end API data stays sealed; a client
|
|
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 taps the matching number. CLI 0.2.0 uses `/api/v1/machine/*`;
|
|
22
|
+
link clients use `/api/v1/links/*`.
|
|
23
|
+
After the API upgrade, CLI 0.1.0 receives HTTP 410 with the update instruction. Ask the owner
|
|
24
|
+
to update it; never change an installed CLI or its config without authorization.
|
|
25
|
+
|
|
16
26
|
## 1. Check that localauth is ready
|
|
17
27
|
|
|
18
28
|
```sh
|
|
@@ -24,9 +34,8 @@ localauth status
|
|
|
24
34
|
- "not verified yet: run localauth verify" (or a command refusing with "verify this machine
|
|
25
35
|
first"): the account uses end-to-end encryption and this machine hasn't checked its key. Run
|
|
26
36
|
`localauth verify` in the background and send the user the link it prints. Once they open it,
|
|
27
|
-
the command prints a
|
|
28
|
-
|
|
29
|
-
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
|
|
30
39
|
the connection, and the user should not send secrets to this machine.
|
|
31
40
|
- "the account key changed": run `localauth verify` as above. "end-to-end encryption was reset":
|
|
32
41
|
reconnect with `localauth login`. Never edit the config to get around either.
|
|
@@ -37,7 +46,7 @@ localauth status
|
|
|
37
46
|
To connect, run `localauth login` in the background (it waits for approval) and send the user the
|
|
38
47
|
link it prints, with the code it shows. They open it, sign in, check the code and tap **Connect**;
|
|
39
48
|
`localauth login` then exits by itself. If the account uses end-to-end encryption, it then prints
|
|
40
|
-
|
|
49
|
+
a three-digit number: send it, and the user taps the same number on the same page. If it says to
|
|
41
50
|
sign in at the service first (a self-hosted copy behind Cloudflare Access), pass that on too.
|
|
42
51
|
Links contain only the device code or a verification id, never keys.
|
|
43
52
|
|