@ccmsg/cli 0.2.8 → 0.2.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.6.0"
23
+ "@ccmsg/protocol": "1.7.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
package/src/auth/auth.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  AuthChallengeResult,
6
6
  AuthRecord,
7
7
  AuthRefreshArgs,
8
+ AuthRefreshReason,
8
9
  AuthRefreshResult,
9
10
  AuthRegisterArgs,
10
11
  AuthResolveArgs,
@@ -145,6 +146,18 @@ export interface MintedSession {
145
146
  readonly refresh: { readonly value: Base64Url; readonly expires_at: Timestamp };
146
147
  }
147
148
 
149
+ /** What is known about the client that asked for a refresh: its own word for
150
+ * why, and what the carrier observed of it.
151
+ *
152
+ * Kept on the family as `last_refresh` and read by nobody but the person whose
153
+ * sessions they are — a run of `reconnect` at an hour they were asleep is
154
+ * something to recognise. Nothing here is checked, so nothing may turn on it. */
155
+ export interface RefreshFrom {
156
+ readonly reason?: AuthRefreshReason;
157
+ readonly ip?: string;
158
+ readonly userAgent?: string;
159
+ }
160
+
148
161
  /** What a registration URL is, as the command that made it prints it. */
149
162
  export interface IssuedRegistration {
150
163
  readonly sub: Subject;
@@ -455,6 +468,11 @@ export class Auth {
455
468
  endpoint: claims.endpoint,
456
469
  rp_id: claims.rp_id,
457
470
  sign_count: verified.signCount,
471
+ // What the authenticator said about backing this credential up, kept
472
+ // because it decides what removing the line costs the person and nothing
473
+ // else: neither flag is ever read to admit or refuse an exchange.
474
+ backup_eligible: verified.backupEligible,
475
+ backup_state: verified.backupState,
458
476
  ...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
459
477
  ...(args.device_label === undefined ? {} : { device_label: args.device_label }),
460
478
  registered_at: at,
@@ -656,7 +674,7 @@ export class Auth {
656
674
  * a family minted elsewhere is carried there rather than done here — two
657
675
  * instances rotating one family in parallel would merge by last write and
658
676
  * read exactly like a stolen token being replayed (§2.4). */
659
- async refreshToken(value: Base64Url): Promise<MintedSession> {
677
+ async refreshToken(value: Base64Url, from: RefreshFrom = {}): Promise<MintedSession> {
660
678
  const held = this.deps.records.byRefresh(value);
661
679
  if (held === undefined) {
662
680
  // Not the standing generation, nor the one before it. Either it never was
@@ -666,12 +684,16 @@ export class Auth {
666
684
  throw new OpError("auth_invalid", "この refresh token は使えません");
667
685
  }
668
686
  if (held.body.iss !== this.deps.self) {
687
+ // What the client said about this refresh stays here: `auth_rotate`
688
+ // carries the value and nothing else, and the address the issuer would
689
+ // see is this instance's rather than the person's. The issuer records
690
+ // that the family rotated, which is the part it can vouch for.
669
691
  const answer = (await this.#atIssuer(held.body.iss, "auth_rotate", {
670
692
  refresh_token: value,
671
693
  } satisfies AuthRotateArgs)) as AuthRotateResult;
672
694
  return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
673
695
  }
674
- const rotated = this.rotate(value);
696
+ const rotated = this.rotate(value, from);
675
697
  return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
676
698
  }
677
699
 
@@ -703,7 +725,7 @@ export class Auth {
703
725
 
704
726
  /** Rotate a family this instance minted. The one writer's own operation, and
705
727
  * what `auth_rotate` runs on its behalf. */
706
- rotate(value: Base64Url): AuthRotateResult {
728
+ rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
707
729
  const held = this.deps.records.byRefresh(value);
708
730
  if (held === undefined) {
709
731
  this.#failReused(value);
@@ -732,6 +754,16 @@ export class Auth {
732
754
  iss: this.deps.self,
733
755
  access,
734
756
  refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
757
+ // Written by this instance because it is the family's `iss`, and only for
758
+ // the rotation that just happened — the caller's word about why, and
759
+ // where it was asked from, are a hint for the person reading their own
760
+ // sessions back and are never checked (contract, `TokenFamily`).
761
+ last_refresh: {
762
+ at,
763
+ ...(from.reason === undefined ? {} : { reason: from.reason }),
764
+ ...(from.ip === undefined ? {} : { ip: from.ip }),
765
+ ...(from.userAgent === undefined ? {} : { user_agent: from.userAgent }),
766
+ },
735
767
  previous_refresh: { value: held.body.refresh.value, expires_at: at + PREVIOUS_GRACE_MS },
736
768
  // The value going out of service is remembered as a digest for as long as
737
769
  // it would have been accepted, so that presenting it later is recognised
package/src/auth/http.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
3
  type AuthAssertArgs,
4
+ type AuthRefreshTokenArgs,
4
5
  type AuthRegisterArgs,
5
6
  type ErrorCode,
6
7
  type InstanceId,
@@ -196,7 +197,12 @@ export async function handleAuth(
196
197
  if (held === undefined) {
197
198
  return refusal("auth_invalid", "この要求には refresh token がありません", cors);
198
199
  }
199
- const minted = await deps.auth.refreshToken(held);
200
+ const { reason } = args as unknown as AuthRefreshTokenArgs;
201
+ const minted = await deps.auth.refreshToken(held, {
202
+ ...(reason === undefined ? {} : { reason }),
203
+ ...(seen.ip === undefined ? {} : { ip: seen.ip }),
204
+ ...(seen.userAgent === undefined ? {} : { userAgent: seen.userAgent }),
205
+ });
200
206
  return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
201
207
  }
202
208
  }
@@ -9,11 +9,15 @@ import { type CborValue, decodeCbor, decodeCborWhole, mapEntry } from "./cbor.ts
9
9
  * log; nothing branches on it. */
10
10
  export class WebAuthnError extends Error {}
11
11
 
12
- /** The flags of the authenticator data (L2 §6.1). Only two are read: that a
13
- * person was present, and that they were verified — the registration asks for
14
- * `userVerification: "required"`, so both have to hold on every exchange. */
12
+ /** The flags of the authenticator data (L2 §6.1). Two of them decide whether an
13
+ * exchange is admitted: that a person was present, and that they were verified
14
+ * — the registration asks for `userVerification: "required"`, so both have to
15
+ * hold on every exchange. The two backup flags decide nothing; they are read at
16
+ * registration and kept as a hint for the person reading their own list. */
15
17
  const FLAG_USER_PRESENT = 0x01;
16
18
  const FLAG_USER_VERIFIED = 0x04;
19
+ const FLAG_BACKUP_ELIGIBLE = 0x08;
20
+ const FLAG_BACKUP_STATE = 0x10;
17
21
  const FLAG_ATTESTED_CREDENTIAL = 0x40;
18
22
 
19
23
  export function base64UrlDecode(value: string): Uint8Array {
@@ -131,6 +135,12 @@ export interface VerifiedRegistration {
131
135
  readonly credentialId: Base64Url;
132
136
  readonly publicKey: Base64Url;
133
137
  readonly signCount: number;
138
+ /** The BE flag: whether the authenticator may back this credential up, which
139
+ * is what separates a synced passkey from one that lives on a single device. */
140
+ readonly backupEligible: boolean;
141
+ /** The BS flag: whether it was backed up at this moment. Eligible and not yet
142
+ * backed up is an ordinary state on a device that has just made the key. */
143
+ readonly backupState: boolean;
134
144
  }
135
145
 
136
146
  /** Check a registration (L2 §7.1) and answer what is worth keeping.
@@ -180,6 +190,8 @@ export function verifyRegistration(
180
190
  credentialId: base64UrlEncode(data.credentialId),
181
191
  publicKey: base64UrlEncode(data.publicKey),
182
192
  signCount: data.signCount,
193
+ backupEligible: (data.flags & FLAG_BACKUP_ELIGIBLE) !== 0,
194
+ backupState: (data.flags & FLAG_BACKUP_STATE) !== 0,
183
195
  };
184
196
  }
185
197
 
@@ -150,16 +150,43 @@ export function serviceFor(env: Env = process.env, platform = process.platform):
150
150
  throw new CommandError("capability_unavailable", `${platform} には登録先がありません`);
151
151
  }
152
152
 
153
- class LaunchdService implements Service {
153
+ /** Whether launchd's refusal to bootstrap is it saying the unit is already
154
+ * there. Errno 37 is `EBUSY`, which is what a bootstrap racing the teardown of
155
+ * the same label gets. */
156
+ function alreadyLoaded(answer: RunResult): boolean {
157
+ const said = `${answer.stdout} ${answer.stderr}`;
158
+ return /already (loaded|bootstrapped)|Operation already in progress|: 37:/.test(said);
159
+ }
160
+
161
+ /** What the init system said when it refused.
162
+ *
163
+ * The command and its stderr, unabridged: launchd's refusals are numbered
164
+ * rather than worded, and a `Bootstrap failed: 5: Input/output error` handed
165
+ * straight to the person is worth more than anything this could say instead. */
166
+ function refuse(command: readonly string[], answer: RunResult): never {
167
+ const said = answer.stderr.trim() || answer.stdout.trim();
168
+ throw new CommandError(
169
+ "internal_error",
170
+ `${command.join(" ")} が失敗しました (exit ${String(answer.code)})${said === "" ? "" : `: ${said}`}`,
171
+ );
172
+ }
173
+
174
+ export class LaunchdService implements Service {
154
175
  readonly kind = "launchd" as const;
155
176
  readonly unitFile: string;
177
+ readonly #label: string;
156
178
  readonly #env: Env;
157
179
  readonly #domain: string;
158
180
 
159
- constructor(env: Env) {
181
+ /** The label is a parameter for `Run`'s reason: a test that drives this
182
+ * machine's real launchd has to do it under a name that is not the one the
183
+ * machine's own supervisor is registered under. Nothing in ccmsg passes it —
184
+ * `serviceFor` is where the name is settled. */
185
+ constructor(env: Env, label: string = LAUNCHD_LABEL) {
160
186
  this.#env = env;
187
+ this.#label = label;
161
188
  const home = env["HOME"] ?? homedir();
162
- this.unitFile = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
189
+ this.unitFile = join(home, "Library", "LaunchAgents", `${label}.plist`);
163
190
  this.#domain = `gui/${String(process.getuid?.() ?? 0)}`;
164
191
  }
165
192
 
@@ -173,7 +200,7 @@ class LaunchdService implements Service {
173
200
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
174
201
  '<plist version="1.0">',
175
202
  "<dict>",
176
- ` <key>Label</key><string>${LAUNCHD_LABEL}</string>`,
203
+ ` <key>Label</key><string>${this.#label}</string>`,
177
204
  " <key>ProgramArguments</key>",
178
205
  " <array>",
179
206
  ...supervisorCommand().map((arg) => ` <string>${escapeXml(arg)}</string>`),
@@ -194,26 +221,48 @@ class LaunchdService implements Service {
194
221
  ].join("\n");
195
222
  }
196
223
 
224
+ /** Registering is writing the unit and starting it: a supervisor nothing is
225
+ * supervising is not what the person asked for, and `RunAtLoad` means that a
226
+ * unit put in front of launchd at all is a unit launchd runs. What `start`
227
+ * adds is the case where the file is already there. */
197
228
  async register(run: Run): Promise<ServiceState> {
198
229
  write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
199
- await run(["launchctl", "bootstrap", this.#domain, this.unitFile]);
200
- return await this.state(run);
230
+ return await this.start(run);
201
231
  }
202
232
 
203
233
  async unregister(run: Run): Promise<{ unregistered: boolean }> {
204
- await run(["launchctl", "bootout", `${this.#domain}/${LAUNCHD_LABEL}`]);
234
+ await run(["launchctl", "bootout", `${this.#domain}/${this.#label}`]);
205
235
  const existed = existsSync(this.unitFile);
206
236
  rmSync(this.unitFile, { force: true });
207
237
  return { unregistered: existed };
208
238
  }
209
239
 
240
+ /** Put the unit in front of launchd if it is not already there, then start
241
+ * the program.
242
+ *
243
+ * `kickstart` alone is what a loaded unit needs, and launchd answers a unit
244
+ * it has never heard of with `No such process` — which is exactly the state
245
+ * a login leaves behind, and the state `unregister` leaves behind however
246
+ * quickly a `register` follows it. So what is loaded is read first and the
247
+ * missing half is done here rather than assumed to have been done by
248
+ * whoever wrote the file. */
210
249
  async start(run: Run): Promise<ServiceState> {
211
- await run(["launchctl", "kickstart", `${this.#domain}/${LAUNCHD_LABEL}`]);
250
+ const before = await this.#report(run);
251
+ if (before.service?.loaded !== true) {
252
+ const bootstrap = ["launchctl", "bootstrap", this.#domain, this.unitFile];
253
+ const answer = await run(bootstrap);
254
+ // `Operation already in progress` and `Service is already loaded` are
255
+ // launchd saying the unit is there, which is all this asked for.
256
+ if (answer.code !== 0 && !alreadyLoaded(answer)) refuse(bootstrap, answer);
257
+ }
258
+ const kickstart = ["launchctl", "kickstart", `${this.#domain}/${this.#label}`];
259
+ const answer = await run(kickstart);
260
+ if (answer.code !== 0) refuse(kickstart, answer);
212
261
  return await this.state(run);
213
262
  }
214
263
 
215
264
  async stop(run: Run): Promise<ServiceState> {
216
- await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${LAUNCHD_LABEL}`]);
265
+ await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${this.#label}`]);
217
266
  return await this.state(run);
218
267
  }
219
268
 
@@ -226,7 +275,7 @@ class LaunchdService implements Service {
226
275
 
227
276
  async #report(run: Run): Promise<Omit<ServiceState, "program">> {
228
277
  const registered = existsSync(this.unitFile);
229
- const printed = await run(["launchctl", "print", `${this.#domain}/${LAUNCHD_LABEL}`]);
278
+ const printed = await run(["launchctl", "print", `${this.#domain}/${this.#label}`]);
230
279
  // A non-zero exit is launchd saying it has no such service, which is not
231
280
  // the same as having nothing to say: the report stays `null` only when the
232
281
  // question could not be put, and here it was and the answer was "no".
@@ -294,11 +343,10 @@ class SystemdService implements Service {
294
343
  return { kind: "command", show: [...unit, "--no-pager"], follow: [...unit, "-f"] };
295
344
  }
296
345
 
346
+ /** `LaunchdService.register`'s reasoning, in systemd's vocabulary. */
297
347
  async register(run: Run): Promise<ServiceState> {
298
348
  write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
299
- await run(["systemctl", "--user", "daemon-reload"]);
300
- await run(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT]);
301
- return await this.state(run);
349
+ return await this.start(run);
302
350
  }
303
351
 
304
352
  async unregister(run: Run): Promise<{ unregistered: boolean }> {
@@ -309,8 +357,20 @@ class SystemdService implements Service {
309
357
  return { unregistered: existed };
310
358
  }
311
359
 
360
+ /** `LaunchdService.start`'s reasoning: a unit systemd has not read is a unit
361
+ * `start` answers `not found` for, and a file written since the last reload
362
+ * is exactly that. `enable --now` starts it and puts it in the target, so a
363
+ * supervisor registered today is running after the next login. */
312
364
  async start(run: Run): Promise<ServiceState> {
313
- await run(["systemctl", "--user", "start", SYSTEMD_UNIT]);
365
+ const before = await this.#report(run);
366
+ if (before.service?.loaded !== true) {
367
+ const reload = ["systemctl", "--user", "daemon-reload"];
368
+ const reloaded = await run(reload);
369
+ if (reloaded.code !== 0) refuse(reload, reloaded);
370
+ }
371
+ const enable = ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT];
372
+ const answer = await run(enable);
373
+ if (answer.code !== 0) refuse(enable, answer);
314
374
  return await this.state(run);
315
375
  }
316
376