@ccmsg/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,389 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import type { AssertionCredential, Base64Url, RegistrationCredential } from "@ccmsg/protocol";
3
+ import { type CborValue, decodeCbor, decodeCborWhole, mapEntry } from "./cbor.ts";
4
+
5
+ /** Why a registration or an assertion was refused.
6
+ *
7
+ * One class for all of them because the caller does one thing with any of
8
+ * them: answers `auth_invalid`. The message says which step failed, for the
9
+ * log; nothing branches on it. */
10
+ export class WebAuthnError extends Error {}
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. */
15
+ const FLAG_USER_PRESENT = 0x01;
16
+ const FLAG_USER_VERIFIED = 0x04;
17
+ const FLAG_ATTESTED_CREDENTIAL = 0x40;
18
+
19
+ export function base64UrlDecode(value: string): Uint8Array {
20
+ return new Uint8Array(Buffer.from(value, "base64url"));
21
+ }
22
+
23
+ export function base64UrlEncode(bytes: Uint8Array): Base64Url {
24
+ return Buffer.from(bytes).toString("base64url");
25
+ }
26
+
27
+ export function sha256(bytes: Uint8Array | string): Uint8Array {
28
+ return new Uint8Array(createHash("sha256").update(bytes).digest());
29
+ }
30
+
31
+ /** What the browser said about the exchange it ran, as the parts that are
32
+ * checked here. Fields beyond these are left alone: a browser may add them,
33
+ * and the two that must be absent are refused by name below. */
34
+ interface ClientData {
35
+ readonly type?: unknown;
36
+ readonly challenge?: unknown;
37
+ readonly origin?: unknown;
38
+ readonly crossOrigin?: unknown;
39
+ readonly topOrigin?: unknown;
40
+ }
41
+
42
+ /** The authenticator data, as far as it is read (L2 §6.1). */
43
+ export interface AuthenticatorData {
44
+ readonly rpIdHash: Uint8Array;
45
+ readonly flags: number;
46
+ readonly signCount: number;
47
+ /** Present on a registration, absent on an assertion. */
48
+ readonly credentialId?: Uint8Array;
49
+ /** The COSE key, exactly the bytes it occupied, so it is stored as it came. */
50
+ readonly publicKey?: Uint8Array;
51
+ }
52
+
53
+ export function parseAuthenticatorData(bytes: Uint8Array): AuthenticatorData {
54
+ if (bytes.length < 37) throw new WebAuthnError("the authenticator data is too short");
55
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56
+ const rpIdHash = bytes.subarray(0, 32);
57
+ const flags = bytes[32] as number;
58
+ const signCount = view.getUint32(33);
59
+ if ((flags & FLAG_ATTESTED_CREDENTIAL) === 0) return { rpIdHash, flags, signCount };
60
+ if (bytes.length < 55) throw new WebAuthnError("the attested credential data is too short");
61
+ const idLength = view.getUint16(53);
62
+ const idEnd = 55 + idLength;
63
+ if (bytes.length < idEnd) throw new WebAuthnError("the credential id runs past the end");
64
+ const credentialId = bytes.subarray(55, idEnd);
65
+ // The key is followed by extensions when there are any, so its own length is
66
+ // what the decoder reports rather than what is left in the buffer.
67
+ const rest = bytes.subarray(idEnd);
68
+ let after: number;
69
+ try {
70
+ ({ rest: after } = decodeCbor(rest));
71
+ } catch (cause) {
72
+ throw new WebAuthnError(`the credential's public key could not be read: ${String(cause)}`);
73
+ }
74
+ const publicKey = rest.subarray(0, rest.length - after);
75
+ return { rpIdHash, flags, signCount, credentialId, publicKey };
76
+ }
77
+
78
+ /** What the two exchanges check in common (L2 §7.1 steps 7-11, §7.2 steps
79
+ * 11-15): what the browser was doing, which challenge it answered, which page
80
+ * asked, and that the answer belongs to one page rather than an embedded one.
81
+ *
82
+ * The origin is compared against the set the operator configured for the web
83
+ * UI rather than against the endpoint: the endpoint is where the instance is
84
+ * dialed, and the page may be served from another name under the same
85
+ * registrable domain (DR-0001 §2.3). */
86
+ export function checkClientData(
87
+ clientDataJson: Uint8Array,
88
+ expected: { type: string; challenge: string; origins: readonly string[] },
89
+ ): void {
90
+ let parsed: ClientData;
91
+ try {
92
+ parsed = JSON.parse(new TextDecoder().decode(clientDataJson)) as ClientData;
93
+ } catch (cause) {
94
+ throw new WebAuthnError(`the client data is not JSON: ${String(cause)}`);
95
+ }
96
+ if (parsed.type !== expected.type) {
97
+ throw new WebAuthnError(`the client data is for ${String(parsed.type)}`);
98
+ }
99
+ if (typeof parsed.challenge !== "string" || !equalStrings(parsed.challenge, expected.challenge)) {
100
+ throw new WebAuthnError("the client data answers another challenge");
101
+ }
102
+ if (typeof parsed.origin !== "string" || !expected.origins.includes(parsed.origin)) {
103
+ throw new WebAuthnError(`${String(parsed.origin)} is not an origin this instance serves`);
104
+ }
105
+ // What is refused is an exchange an embedding page ran, which is what either
106
+ // of these says when it is there to say it. `crossOrigin: false` is not that:
107
+ // Chromium writes the field on every message, and reading its presence as the
108
+ // refusal would turn away every credential those browsers make. `topOrigin`
109
+ // is only ever written when the exchange was cross-origin, so its presence at
110
+ // all is the refusal.
111
+ if (parsed.crossOrigin === true || parsed.topOrigin !== undefined) {
112
+ throw new WebAuthnError("this exchange must not be run from an embedded page");
113
+ }
114
+ }
115
+
116
+ /** The relying party and the person, as every exchange states them.
117
+ *
118
+ * Several relying parties may be named on an assertion, because a credential
119
+ * does not say which one it was made for and this instance may serve more than
120
+ * one name. Each is one the operator configured; nothing is widened here. */
121
+ export function checkAuthenticator(data: AuthenticatorData, rpIds: readonly string[]): void {
122
+ if (!rpIds.some((rpId) => equalBytes(data.rpIdHash, sha256(rpId)))) {
123
+ throw new WebAuthnError("the authenticator answered for another relying party");
124
+ }
125
+ if ((data.flags & FLAG_USER_PRESENT) === 0) throw new WebAuthnError("no person was present");
126
+ if ((data.flags & FLAG_USER_VERIFIED) === 0) throw new WebAuthnError("no person was verified");
127
+ }
128
+
129
+ /** What a verified registration leaves behind, as the record keeps it. */
130
+ export interface VerifiedRegistration {
131
+ readonly credentialId: Base64Url;
132
+ readonly publicKey: Base64Url;
133
+ readonly signCount: number;
134
+ }
135
+
136
+ /** Check a registration (L2 §7.1) and answer what is worth keeping.
137
+ *
138
+ * Attestation is `none` by the request the page makes, so what this reads out
139
+ * of the attestation object is the authenticator data and the key — there is no
140
+ * statement about the hardware to verify, and one that arrived would mean the
141
+ * page asked for something other than what this instance asked it to. */
142
+ export function verifyRegistration(
143
+ credential: RegistrationCredential,
144
+ expected: { challenge: string; origins: readonly string[]; rpId: string },
145
+ ): VerifiedRegistration {
146
+ checkClientData(base64UrlDecode(credential.client_data_json), {
147
+ type: "webauthn.create",
148
+ challenge: expected.challenge,
149
+ origins: expected.origins,
150
+ });
151
+ let attestation: CborValue;
152
+ try {
153
+ attestation = decodeCborWhole(base64UrlDecode(credential.attestation_object));
154
+ } catch (cause) {
155
+ throw new WebAuthnError(`the attestation object could not be read: ${String(cause)}`);
156
+ }
157
+ if (mapEntry(attestation, "fmt") !== "none") {
158
+ throw new WebAuthnError("this instance registers credentials without attestation");
159
+ }
160
+ const statement = mapEntry(attestation, "attStmt");
161
+ if (!(statement instanceof Map) || statement.size !== 0) {
162
+ throw new WebAuthnError("an unattested registration carries an empty statement");
163
+ }
164
+ const authData = mapEntry(attestation, "authData");
165
+ if (!(authData instanceof Uint8Array)) {
166
+ throw new WebAuthnError("the attestation object carries no authenticator data");
167
+ }
168
+ const data = parseAuthenticatorData(authData);
169
+ checkAuthenticator(data, [expected.rpId]);
170
+ if (data.credentialId === undefined || data.publicKey === undefined) {
171
+ throw new WebAuthnError("the registration carries no credential");
172
+ }
173
+ // The id the browser reported and the one the authenticator signed are the
174
+ // same value by construction; comparing them is what says the two halves of
175
+ // the message describe one credential.
176
+ if (!equalBytes(data.credentialId, base64UrlDecode(credential.raw_id))) {
177
+ throw new WebAuthnError("the credential named is not the one attested");
178
+ }
179
+ return {
180
+ credentialId: base64UrlEncode(data.credentialId),
181
+ publicKey: base64UrlEncode(data.publicKey),
182
+ signCount: data.signCount,
183
+ };
184
+ }
185
+
186
+ /** Check an assertion (L2 §7.2) against the key a registration left behind. */
187
+ export async function verifyAssertion(
188
+ credential: AssertionCredential,
189
+ known: { publicKey: Base64Url; signCount?: number },
190
+ expected: { challenge: string; origins: readonly string[]; rpIds: readonly string[] },
191
+ ): Promise<{ signCount: number }> {
192
+ const clientDataJson = base64UrlDecode(credential.client_data_json);
193
+ checkClientData(clientDataJson, {
194
+ type: "webauthn.get",
195
+ challenge: expected.challenge,
196
+ origins: expected.origins,
197
+ });
198
+ const authData = base64UrlDecode(credential.authenticator_data);
199
+ const data = parseAuthenticatorData(authData);
200
+ checkAuthenticator(data, expected.rpIds);
201
+ // A synced passkey reports zero forever, and an authenticator that keeps a
202
+ // counter only ever counts up. So once a non-zero reading has been recorded,
203
+ // every later one has to be higher — including a zero, which from an
204
+ // authenticator that was counting is a different device answering with a copy
205
+ // of the credential.
206
+ const last = known.signCount ?? 0;
207
+ if (last !== 0 && data.signCount <= last) {
208
+ throw new WebAuthnError("the authenticator's counter did not advance");
209
+ }
210
+ const signed = new Uint8Array(authData.length + 32);
211
+ signed.set(authData, 0);
212
+ signed.set(sha256(clientDataJson), authData.length);
213
+ const ok = await verifySignature(
214
+ base64UrlDecode(known.publicKey),
215
+ signed,
216
+ base64UrlDecode(credential.signature),
217
+ );
218
+ if (!ok) throw new WebAuthnError("the signature is not this credential's");
219
+ return { signCount: data.signCount };
220
+ }
221
+
222
+ /** The three algorithms a credential may be created with, as the page asks for
223
+ * them, and what each is to WebCrypto. */
224
+ const ES256 = -7;
225
+ const EdDSA = -8;
226
+ const RS256 = -257;
227
+
228
+ export const SUPPORTED_ALGORITHMS: readonly number[] = [ES256, EdDSA, RS256];
229
+
230
+ /** Import the COSE key a registration carried, so a record is never written
231
+ * around a key nothing can verify with.
232
+ *
233
+ * Done at registration rather than at the first assertion: a key that cannot be
234
+ * imported is a credential that can never be used, and finding that out when
235
+ * the person tries to sign in leaves a record nobody can explain. The imported
236
+ * key itself is thrown away — an assertion imports its own (§2.10). */
237
+ export async function checkPublicKey(cose: Uint8Array): Promise<void> {
238
+ const key = decodeCborWhole(cose);
239
+ const alg = mapEntry(key, 3);
240
+ if (typeof alg !== "number" || !SUPPORTED_ALGORITHMS.includes(alg)) {
241
+ throw new WebAuthnError("the key names no algorithm this instance verifies");
242
+ }
243
+ await importPublicKey(key, alg);
244
+ }
245
+
246
+ /** Verify one signature against a COSE key.
247
+ *
248
+ * The key is imported per verification rather than kept: an assertion arrives
249
+ * once every few hours at most, and holding a `CryptoKey` per credential would
250
+ * be a cache of something that is cheap to make and has to be invalidated when
251
+ * the credential is removed. */
252
+ async function verifySignature(
253
+ cose: Uint8Array,
254
+ signed: Uint8Array,
255
+ signature: Uint8Array,
256
+ ): Promise<boolean> {
257
+ const key = decodeCborWhole(cose);
258
+ const alg = mapEntry(key, 3);
259
+ if (typeof alg !== "number" || !SUPPORTED_ALGORITHMS.includes(alg)) {
260
+ throw new WebAuthnError("the key names no algorithm this instance verifies");
261
+ }
262
+ const imported = await importPublicKey(key, alg);
263
+ if (alg === ES256) {
264
+ // WebAuthn signs ES256 as the ASN.1 sequence X.509 uses, while WebCrypto
265
+ // verifies the raw pair, so the two halves are taken out of the DER here.
266
+ const raw = rawEcdsaSignature(signature);
267
+ if (raw === undefined) return false;
268
+ return await crypto.subtle.verify(
269
+ { name: "ECDSA", hash: "SHA-256" },
270
+ imported,
271
+ owned(raw),
272
+ owned(signed),
273
+ );
274
+ }
275
+ const algorithm = alg === EdDSA ? { name: "Ed25519" } : { name: "RSASSA-PKCS1-v1_5" };
276
+ return await crypto.subtle.verify(algorithm, imported, owned(signature), owned(signed));
277
+ }
278
+
279
+ /** The COSE key as WebCrypto holds it. A key whose parts are missing or the
280
+ * wrong shape fails here, which is where both the registration and every
281
+ * assertion find out. */
282
+ async function importPublicKey(key: CborValue, alg: number): Promise<CryptoKey> {
283
+ switch (alg) {
284
+ case ES256: {
285
+ if (mapEntry(key, 1) !== 2) throw new WebAuthnError("an ES256 key is an EC2 key");
286
+ if (mapEntry(key, -1) !== 1) throw new WebAuthnError("an ES256 key is on P-256");
287
+ const x = bytesAt(key, -2, 32);
288
+ const y = bytesAt(key, -3, 32);
289
+ return await crypto.subtle.importKey(
290
+ "jwk",
291
+ { kty: "EC", crv: "P-256", x: base64UrlEncode(x), y: base64UrlEncode(y) },
292
+ { name: "ECDSA", namedCurve: "P-256" },
293
+ false,
294
+ ["verify"],
295
+ );
296
+ }
297
+ case EdDSA: {
298
+ if (mapEntry(key, 1) !== 1) throw new WebAuthnError("an EdDSA key is an OKP key");
299
+ if (mapEntry(key, -1) !== 6) throw new WebAuthnError("an EdDSA key is on Ed25519");
300
+ const x = bytesAt(key, -2, 32);
301
+ return await crypto.subtle.importKey(
302
+ "jwk",
303
+ { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(x) },
304
+ { name: "Ed25519" },
305
+ false,
306
+ ["verify"],
307
+ );
308
+ }
309
+ default: {
310
+ if (mapEntry(key, 1) !== 3) throw new WebAuthnError("an RS256 key is an RSA key");
311
+ const n = bytesAt(key, -1);
312
+ const e = bytesAt(key, -2);
313
+ return await crypto.subtle.importKey(
314
+ "jwk",
315
+ { kty: "RSA", n: base64UrlEncode(n), e: base64UrlEncode(e) },
316
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
317
+ false,
318
+ ["verify"],
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ /** A copy backed by a buffer of its own.
325
+ *
326
+ * Every value here is a view into the frame it was decoded from, and WebCrypto
327
+ * takes only a view that owns its buffer. The copy is a few dozen bytes and
328
+ * happens once per verification. */
329
+ function owned(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
330
+ const copy = new Uint8Array(new ArrayBuffer(bytes.length));
331
+ copy.set(bytes);
332
+ return copy;
333
+ }
334
+
335
+ function bytesAt(key: CborValue, label: number, width?: number): Uint8Array {
336
+ const held = mapEntry(key, label);
337
+ if (!(held instanceof Uint8Array)) {
338
+ throw new WebAuthnError(`the key carries no ${String(label)}`);
339
+ }
340
+ if (width !== undefined && held.length !== width) {
341
+ throw new WebAuthnError(`the key's ${String(label)} is not ${String(width)} bytes`);
342
+ }
343
+ return held;
344
+ }
345
+
346
+ /** The `r` and `s` of a DER-encoded ECDSA signature, each padded to 32 bytes.
347
+ *
348
+ * Nothing is trusted about the lengths: a signature is attacker-supplied until
349
+ * it verifies, so a structure that does not parse is a refusal rather than an
350
+ * exception. */
351
+ function rawEcdsaSignature(der: Uint8Array): Uint8Array | undefined {
352
+ if (der[0] !== 0x30) return undefined;
353
+ let at = 2;
354
+ const parts: Uint8Array[] = [];
355
+ for (let i = 0; i < 2; i += 1) {
356
+ if (der[at] !== 0x02) return undefined;
357
+ const length = der[at + 1];
358
+ if (length === undefined) return undefined;
359
+ const start = at + 2;
360
+ const end = start + length;
361
+ if (end > der.length) return undefined;
362
+ let part = der.subarray(start, end);
363
+ // A leading zero is the DER sign byte; a shorter value is left-padded.
364
+ while (part.length > 32 && part[0] === 0) part = part.subarray(1);
365
+ if (part.length > 32) return undefined;
366
+ parts.push(part);
367
+ at = end;
368
+ }
369
+ const raw = new Uint8Array(64);
370
+ raw.set(parts[0] as Uint8Array, 32 - (parts[0] as Uint8Array).length);
371
+ raw.set(parts[1] as Uint8Array, 64 - (parts[1] as Uint8Array).length);
372
+ return raw;
373
+ }
374
+
375
+ export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
376
+ return a.length === b.length && timingSafeEqual(a, b);
377
+ }
378
+
379
+ /** Compare two secrets without saying where they diverged.
380
+ *
381
+ * Every value compared this way — a challenge, a token, a six-digit code — is
382
+ * one a caller may be guessing, and a comparison that returns at the first
383
+ * difference tells them how far they got. */
384
+ export function equalStrings(a: string, b: string): boolean {
385
+ const left = Buffer.from(a);
386
+ const right = Buffer.from(b);
387
+ if (left.length !== right.length) return false;
388
+ return timingSafeEqual(left, right);
389
+ }
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { type MessageSendArgs, type NotifySendArgs, PROTOCOL_VERSION } from "@cc
3
3
  import {
4
4
  add as addToConfig,
5
5
  ask,
6
+ type CliErrorCode,
6
7
  CommandError,
7
8
  configHome,
8
9
  connect,
@@ -157,6 +158,37 @@ const ROOT: Command = {
157
158
  bare: true,
158
159
  run: (args) => supervised("supervise_status", args, true),
159
160
  },
161
+ {
162
+ name: "passkey",
163
+ summary: "この config home の instance に登録された passkey を扱う",
164
+ usage: "ccmsg daemon passkey <subcommand>",
165
+ children: [
166
+ {
167
+ name: "add",
168
+ summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
169
+ usage:
170
+ "ccmsg daemon passkey add <unit> [endpoint] [--rp-id <domain>] [--name <ラベル>]",
171
+ options: [
172
+ ["--rp-id <domain>", "WebAuthn の relying party。既定は endpoint のホスト"],
173
+ ["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
174
+ ],
175
+ run: (args) => passkeyAdd(args),
176
+ },
177
+ {
178
+ name: "list",
179
+ summary: "登録済みの credential を、新しい順に並べる",
180
+ usage: "ccmsg daemon passkey list [unit]",
181
+ bare: true,
182
+ run: (args) => passkeyAsk(args[0], { admin: "passkey_list" }),
183
+ },
184
+ {
185
+ name: "remove",
186
+ summary: "利用者を消す (credential と token を失効させ、その WS を切る)",
187
+ usage: "ccmsg daemon passkey remove <sub> [unit]",
188
+ run: (args) => passkeyRemove(args),
189
+ },
190
+ ],
191
+ },
160
192
  {
161
193
  name: "log",
162
194
  summary: "instance の daemon.log を出す (--all は行に id を足して多重化)",
@@ -555,6 +587,65 @@ async function serviceOp(
555
587
  };
556
588
  }
557
589
 
590
+ /** The passkey commands, which are asked of the instance itself rather than of
591
+ * the supervisor.
592
+ *
593
+ * They travel on the instance's unix socket and nowhere else: registration is
594
+ * local by design (DR-0001 §2.2), and reaching that address is what says the
595
+ * caller is on the machine. They are not ops of the contract for the same
596
+ * reason — the contract is what reaches an instance over a network. */
597
+ async function passkeyAsk(unit: string | undefined, request: Record<string, unknown>) {
598
+ const target = targetFor(process.env, unit ?? resolveConfigHome());
599
+ const conn = await connect(target.paths.socket);
600
+ if (conn === undefined) {
601
+ throw new CommandError("not_found", `${target.dir} の instance は動いていません`);
602
+ }
603
+ try {
604
+ const answer = await conn.ask(request);
605
+ if (answer["ok"] === true) {
606
+ const { ok: _ok, request_id: _id, ...body } = answer;
607
+ return body;
608
+ }
609
+ const error = answer["error"] as { code?: CliErrorCode; msg?: string } | undefined;
610
+ throw new CommandError(error?.code ?? "internal_error", error?.msg ?? JSON.stringify(answer));
611
+ } finally {
612
+ conn.close();
613
+ }
614
+ }
615
+
616
+ /** `ccmsg daemon passkey add`: one registration URL, and the code that goes
617
+ * with it.
618
+ *
619
+ * Both are printed here and the code is nowhere else — not in the URL, not in
620
+ * anything the instance hands out — so that holding the URL is not enough to
621
+ * register (DR-0001 §2.2). */
622
+ async function passkeyAdd(args: readonly string[]): Promise<unknown> {
623
+ const parsed = options(args, ["rp-id", "name"]);
624
+ const [unit, endpoint] = parsed.rest;
625
+ if (unit === undefined) {
626
+ throw new CommandError(
627
+ "invalid_args",
628
+ "使い方: ccmsg daemon passkey add <unit> [endpoint] [--rp-id <domain>] [--name <ラベル>]",
629
+ );
630
+ }
631
+ const rpId = parsed.named.get("rp-id");
632
+ const name = parsed.named.get("name");
633
+ return await passkeyAsk(unit, {
634
+ admin: "passkey_add",
635
+ ...(endpoint === undefined ? {} : { endpoint }),
636
+ ...(rpId === undefined ? {} : { rp_id: rpId }),
637
+ ...(name === undefined ? {} : { name }),
638
+ });
639
+ }
640
+
641
+ async function passkeyRemove(args: readonly string[]): Promise<unknown> {
642
+ const [sub, unit] = args;
643
+ if (sub === undefined) {
644
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon passkey remove <sub> [unit]");
645
+ }
646
+ return await passkeyAsk(unit, { admin: "passkey_remove", sub });
647
+ }
648
+
558
649
  /** `ccmsg daemon log`: what one instance wrote down, or what all of them did.
559
650
  *
560
651
  * JSON lines rather than one document, because a log is a stream and `--follow`
@@ -67,6 +67,15 @@ export async function dispatch(
67
67
  return failure(requestId, "invalid_args", problems.join("; "));
68
68
  }
69
69
 
70
+ // The carrier the table names. An op marked `http` sets or reads a cookie,
71
+ // which a frame on an open connection cannot, so it is reachable only where
72
+ // the carrier can do that — and one arriving here is a caller that would be
73
+ // answered without the half of the answer that matters (contract,
74
+ // `OpAttributes.carrier`).
75
+ if (attrs.carrier === "http") {
76
+ return failure(requestId, "bad_request", `${op} is reached over HTTP, not on a connection`);
77
+ }
78
+
70
79
  // 3. the identity `hello` settles, when the op needs one
71
80
  if (attrs.needs_hello && identity.state !== "settled") {
72
81
  return failure(requestId, "hello_required", `${op} needs an identity settled by hello`);