@request-seal/core 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +10 -9
  3. package/dist/adapters/node-http/index.d.ts +8 -1
  4. package/dist/adapters/node-http/index.js +21 -5
  5. package/dist/constants.d.ts +4 -4
  6. package/dist/constants.js +2 -2
  7. package/dist/crypto/algorithm.d.ts +4 -0
  8. package/dist/crypto/algorithm.js +12 -0
  9. package/dist/discovery/body.d.ts +12 -2
  10. package/dist/discovery/body.js +33 -11
  11. package/dist/discovery/cache.d.ts +13 -3
  12. package/dist/discovery/cache.js +103 -39
  13. package/dist/discovery/headers.d.ts +2 -0
  14. package/dist/discovery/headers.js +27 -1
  15. package/dist/discovery/index.d.ts +9 -2
  16. package/dist/discovery/index.js +22 -6
  17. package/dist/discovery/node.d.ts +6 -2
  18. package/dist/discovery/node.js +124 -18
  19. package/dist/discovery/proof.js +2 -2
  20. package/dist/discovery/snapshot.js +3 -0
  21. package/dist/errors/parity.d.ts +29 -3
  22. package/dist/errors/parity.js +32 -3
  23. package/dist/errors/types.d.ts +4 -2
  24. package/dist/errors/types.js +14 -2
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/jose/nested.js +23 -1
  28. package/dist/key_handle.js +23 -1
  29. package/dist/message/index.js +2 -1
  30. package/dist/policy.d.ts +13 -1
  31. package/dist/policy.js +13 -1
  32. package/dist/quorum/evaluation.d.ts +1 -1
  33. package/dist/quorum/evaluation.js +18 -4
  34. package/dist/replay/postgres.d.ts +2 -0
  35. package/dist/replay/postgres.js +6 -0
  36. package/dist/sign.d.ts +22 -1
  37. package/dist/sign.js +28 -6
  38. package/dist/signature-fields.d.ts +5 -1
  39. package/dist/signature-fields.js +9 -1
  40. package/dist/telemetry.d.ts +43 -0
  41. package/dist/telemetry.js +86 -0
  42. package/dist/verify-quorum.js +34 -9
  43. package/dist/verify.d.ts +19 -1
  44. package/dist/verify.js +48 -6
  45. package/dist/web-bot-auth/verify.js +7 -3
  46. package/package.json +13 -1
@@ -0,0 +1,43 @@
1
+ import { type Result } from "./result.ts";
2
+ /** The versioned event names (v1), mirroring the reference telemetry contract. */
3
+ export type TelemetryEventName = "verify" | "verifyQuorum" | "webBotAuthVerify" | "replayClaim" | "custodySign";
4
+ /** What an event reports: the operation's outcome within a closed vocabulary. */
5
+ export type TelemetryOutcome = "ok" | "error" | "claimed" | "already_claimed";
6
+ /** One bounded, low-cardinality observation of a RequestSeal operation. */
7
+ export type TelemetryEvent = Readonly<{
8
+ event: TelemetryEventName;
9
+ outcome: TelemetryOutcome;
10
+ reason: string | null;
11
+ layer: string | null;
12
+ detail: string | null;
13
+ durationMs: number;
14
+ }>;
15
+ /** A caller-owned subscriber; handlers own storage and aggregation. */
16
+ export type TelemetrySubscriber = (event: TelemetryEvent) => void;
17
+ /** A caller-owned attachment; `detach` stops delivery and is idempotent. */
18
+ export type TelemetryHandle = Readonly<{
19
+ /** Stop delivering events to this attachment; calling it again does nothing. */
20
+ detach(): void;
21
+ }>;
22
+ /**
23
+ * Emit one event to every attached subscriber. Telemetry is an optional path:
24
+ * a throwing subscriber degrades silently and never fails the operation it
25
+ * observes. Payloads carry closed vocabulary only — never labels, key IDs,
26
+ * URLs, principals, nonces, bytes, or exception text.
27
+ */
28
+ export declare function emit(event: TelemetryEventName, durationMs: number, outcome: TelemetryOutcome, error?: unknown): void;
29
+ /** Measure an operation and emit its stop event with the result's metadata. */
30
+ export declare function span<T>(event: TelemetryEventName, work: () => Promise<T>, outcome: (result: T) => TelemetryOutcome, errorOf: (result: T) => unknown): Promise<T>;
31
+ /** The captured monotonic clock used for event durations, in milliseconds. */
32
+ export declare function monotonicMs(): number;
33
+ /** Attach a telemetry subscriber; at most sixteen run at once. */
34
+ export declare function attach(input: unknown): Result<TelemetryHandle>;
35
+ /** The v1 event names, as documented in the observability reference. */
36
+ export declare const Telemetry: Readonly<{
37
+ /** The v1 event names. */
38
+ events(): readonly TelemetryEventName[];
39
+ /** Attach a telemetry subscriber; the handle's `detach` stops delivery. */
40
+ attach: typeof attach;
41
+ /** Check the private brand of a telemetry attachment handle. */
42
+ isHandle(value: unknown): value is TelemetryHandle;
43
+ }>;
@@ -0,0 +1,86 @@
1
+ import { err, ok } from "./result.js";
2
+ import { coreError, isRequestSealError, ruleId } from "./errors/types.js";
3
+ const names = Object.freeze([
4
+ "verify",
5
+ "verifyQuorum",
6
+ "webBotAuthVerify",
7
+ "replayClaim",
8
+ "custodySign",
9
+ ]);
10
+ const MAX_SUBSCRIBERS = 16;
11
+ const handles = new WeakSet();
12
+ let subscribers = Object.freeze([]);
13
+ // A captured monotonic clock: replacing the global later cannot change a duration.
14
+ const now = performance.now.bind(performance);
15
+ /**
16
+ * Emit one event to every attached subscriber. Telemetry is an optional path:
17
+ * a throwing subscriber degrades silently and never fails the operation it
18
+ * observes. Payloads carry closed vocabulary only — never labels, key IDs,
19
+ * URLs, principals, nonces, bytes, or exception text.
20
+ */
21
+ export function emit(event, durationMs, outcome, error) {
22
+ if (subscribers.length === 0)
23
+ return;
24
+ const payload = Object.freeze({
25
+ event,
26
+ outcome,
27
+ reason: isRequestSealError(error) ? ruleId(error) : null,
28
+ layer: isRequestSealError(error) && "layer" in error ? error.layer : null,
29
+ detail: isRequestSealError(error) && "detail" in error && typeof error.detail === "string" ? error.detail : null,
30
+ durationMs,
31
+ });
32
+ for (const subscriber of subscribers) {
33
+ try {
34
+ subscriber(payload);
35
+ }
36
+ catch {
37
+ // Degrade: a defective observer is not the observed operation's failure.
38
+ }
39
+ }
40
+ }
41
+ /** Measure an operation and emit its stop event with the result's metadata. */
42
+ export async function span(event, work, outcome, errorOf) {
43
+ const start = now();
44
+ const result = await work();
45
+ emit(event, now() - start, outcome(result), errorOf(result));
46
+ return result;
47
+ }
48
+ /** The captured monotonic clock used for event durations, in milliseconds. */
49
+ export function monotonicMs() { return now(); }
50
+ /** Attach a telemetry subscriber; at most sixteen run at once. */
51
+ export function attach(input) {
52
+ if (typeof input !== "function")
53
+ return err(coreError("invalid_options", "input"));
54
+ if (subscribers.length >= MAX_SUBSCRIBERS)
55
+ return err(coreError("invalid_options", "input"));
56
+ const subscriber = input;
57
+ subscribers = Object.freeze([...subscribers, subscriber]);
58
+ let live = true;
59
+ const handle = Object.freeze({
60
+ detach() {
61
+ if (!live)
62
+ return;
63
+ live = false;
64
+ // Remove exactly one occurrence: two attachments of one function are
65
+ // independently detachable, mirroring the reference's handler ids.
66
+ const index = subscribers.indexOf(subscriber);
67
+ if (index >= 0)
68
+ subscribers = Object.freeze(subscribers.slice(0, index).concat(subscribers.slice(index + 1)));
69
+ },
70
+ });
71
+ handles.add(handle);
72
+ return ok(handle);
73
+ }
74
+ /** The v1 event names, as documented in the observability reference. */
75
+ export const Telemetry = Object.freeze({
76
+ /** The v1 event names. */
77
+ events() {
78
+ return names;
79
+ },
80
+ /** Attach a telemetry subscriber; the handle's `detach` stops delivery. */
81
+ attach,
82
+ /** Check the private brand of a telemetry attachment handle. */
83
+ isHandle(value) {
84
+ return !!value && typeof value === "object" && handles.has(value);
85
+ },
86
+ });
@@ -3,14 +3,18 @@ import { isMessage, Message } from "./message/index.js";
3
3
  import { isQuorum } from "./quorum.js";
4
4
  import { isContext, withDeadline, remainingMs } from "./context.js";
5
5
  import { isAcceptSignature, applicable, } from "./accept-signature.js";
6
- import { dictionaries, parameters, identities, ensure, fail, bounded, } from "./signature-fields.js";
6
+ import { dictionaries, parameters, identities, ensure, fail, envelope, } from "./signature-fields.js";
7
7
  import { verifyCandidate } from "./verify.js";
8
+ import { span } from "./telemetry.js";
8
9
  import { evaluateQuorum } from "./quorum/evaluation.js";
9
10
  import { completeQuorum, } from "./quorum/verification.js";
10
11
  import { custodyError, isRequestSealError } from "./errors/types.js";
11
12
  import { ok, err } from "./result.js";
12
13
  /** Verify bounded HTTP signatures and evaluate the configured quorum policy. */
13
- export async function verifyQuorum(message, quorum, options) {
14
+ export function verifyQuorum(message, quorum, options) {
15
+ return span("verifyQuorum", () => verifyQuorumSpanned(message, quorum, options), r => r.ok ? "ok" : "error", r => r.ok ? undefined : r.error);
16
+ }
17
+ async function verifyQuorumSpanned(message, quorum, options) {
14
18
  try {
15
19
  const a = snapshotOptions(options, ["acceptSignature", "context"]);
16
20
  ensure(a.ok, "invalid_options", "input");
@@ -56,10 +60,30 @@ export async function verifyQuorum(message, quorum, options) {
56
60
  let qualified = false, last;
57
61
  const baseCache = new Map();
58
62
  for (const slot of slots) {
59
- const r = await verifyCandidate(m, slot.policy, {
60
- label: l,
61
- context: c.value,
62
- }, { input, signature: signatures.get(l), baseCache });
63
+ let r;
64
+ try {
65
+ r = await verifyCandidate(m, slot.policy, {
66
+ label: l,
67
+ context: c.value,
68
+ }, { input, signature: signatures.get(l), baseCache });
69
+ }
70
+ catch (fault) {
71
+ // Per-label rescue isolates a defective label as its own
72
+ // nonqualifying internal fact, never a whole-call failure.
73
+ if (isRequestSealError(fault) && fault.kind === "custody")
74
+ return err(fault);
75
+ const faulted = envelope(fault);
76
+ if (faulted.kind !== "core")
77
+ throw fault;
78
+ last = {
79
+ label: l,
80
+ slot: slot.id,
81
+ reason: faulted.reason,
82
+ layer: faulted.layer,
83
+ };
84
+ facts.push(last);
85
+ continue;
86
+ }
63
87
  if (r.ok) {
64
88
  const record = Object.freeze({
65
89
  slot,
@@ -108,8 +132,9 @@ export async function verifyQuorum(message, quorum, options) {
108
132
  const original = candidates.find((c) => c.slot === r.slot && c.label === r.label);
109
133
  results[r.label] = verified.get(original);
110
134
  }
111
- if (remainingMs(c.value) <= 0)
112
- throw custodyError("deadline_exceeded");
135
+ // The wall-clock bound lives in the assignment search: an accepted
136
+ // assignment, including one accepted at deadline exhaustion, completes.
137
+ // The remaining work here is synchronous and bounded.
113
138
  return completeQuorum({
114
139
  profile: { name: "rfc9421" },
115
140
  mode: quorum.mode,
@@ -140,6 +165,6 @@ export async function verifyQuorum(message, quorum, options) {
140
165
  catch (e) {
141
166
  if (isRequestSealError(e) && e.kind === "custody")
142
167
  return err(e);
143
- return err(bounded(e, "invalid_options", "input"));
168
+ return err(envelope(e));
144
169
  }
145
170
  }
package/dist/verify.d.ts CHANGED
@@ -20,7 +20,25 @@ type ParsedCandidate = Readonly<{
20
20
  baseCache: BaseCache;
21
21
  }>;
22
22
  export declare function verifyLabel(message: unknown, policy: unknown, options: unknown, observeIdentity?: (identity: KeyIdentity) => void, parsed?: ParsedCandidate, profile?: ReplayFacts["profile"]): Promise<Result<Verification>>;
23
- /** Verify one HTTP signature under an explicit branded policy. */
23
+ /**
24
+ * Verify one HTTP signature under an explicit branded policy.
25
+ *
26
+ * Checks exactly the named label; it never tries other labels. Success means the
27
+ * cryptography, coverage, freshness, content, and replay layers all passed. It does not
28
+ * authorize: the Verification records `authorization: "not_evaluated"`.
29
+ *
30
+ * @param message - A branded Message carrying the signature.
31
+ * @param policy - A branded Policy from `Policy.create`.
32
+ * @param options - `{label, context?, representation?, digestState?}`. `representation` is a
33
+ * complete retained Body and needs a `"representation"` content policy. `digestState` is a
34
+ * DigestState of the policy's content kind that you fed with a streamed body; verify finishes
35
+ * it. The two are mutually exclusive.
36
+ * @returns `ok(verification)`, or a bounded error such as `core` `crypto.invalid_signature`,
37
+ * `freshness.expired`, `content.digest_mismatch`, `key.unknown_key`, or `replay.replayed`.
38
+ * @see [Signing and verifying guide](https://github.com/baselabs/request-seal-typescript/blob/main/docs/guides/signing-and-verifying.md),
39
+ * [reference](https://github.com/baselabs/request-seal-typescript/blob/main/docs/reference/sign-and-verify.md), and the
40
+ * [error reasons](https://github.com/baselabs/request-seal-typescript/blob/main/docs/reference/errors.md).
41
+ */
24
42
  export declare function verify(message: unknown, policy: unknown, options: unknown): Promise<Result<Verification>>;
25
43
  /** Internal composition of the single-label pipeline; identities stay private. */
26
44
  export declare function verifyCandidate(message: unknown, policy: Policy, options: unknown, parsed?: ParsedCandidate): Promise<Result<Readonly<{
package/dist/verify.js CHANGED
@@ -10,7 +10,8 @@ import { isPublicKey } from "./public_key.js";
10
10
  import { isKeyIdentity, KeyIdentity } from "./key_identity.js";
11
11
  import { build } from "./signature-base/index.js";
12
12
  import { digestField, checkDigest, isDigestState } from "./digest.js";
13
- import { label, dictionaries, validInner, parameters, identities, ensure, fail, bounded, } from "./signature-fields.js";
13
+ import { label, dictionaries, validInner, parameters, identities, ensure, fail, bounded, envelope, } from "./signature-fields.js";
14
+ import { emit, monotonicMs, span } from "./telemetry.js";
14
15
  import { completed, } from "./verification.js";
15
16
  import { createClaim, retainUntil as retention } from "./replay/claim.js";
16
17
  import { createReceipt } from "./replay/receipt.js";
@@ -126,6 +127,19 @@ async function replay(params, algorithm, fresh, policy, context, profile) {
126
127
  }
127
128
  /** Internal shared commit boundary; profile facts are supplied only after authentication. */
128
129
  export async function commitReplay(facts, retainUntil, policy, context) {
130
+ const start = monotonicMs();
131
+ try {
132
+ const result = await commitReplaySpanned(facts, retainUntil, policy, context);
133
+ emit("replayClaim", monotonicMs() - start, "claimed");
134
+ return result;
135
+ }
136
+ catch (error) {
137
+ const replayed = isRequestSealError(error) && error.kind === "core" && error.reason === "replayed";
138
+ emit("replayClaim", monotonicMs() - start, replayed ? "already_claimed" : "error", error);
139
+ throw error;
140
+ }
141
+ }
142
+ async function commitReplaySpanned(facts, retainUntil, policy, context) {
129
143
  const child = withDeadline({ parent: context, timeoutMs: policy.timeoutMs });
130
144
  ensure(child.ok, "store_timeout", "replay");
131
145
  try {
@@ -371,19 +385,47 @@ export async function verifyLabel(message, policy, options, observeIdentity, par
371
385
  profile, content: contentResult.value, freshness: fresh,
372
386
  replay: replayFacts, principal: "unattributed", authorization: "not_evaluated",
373
387
  });
374
- if (result.ok && identity)
375
- observeIdentity(identity);
388
+ if (result.ok && identity) {
389
+ // An extension pipeline step is a caller callback: its fault keeps the
390
+ // profile's deliberate invalid_policy classification, like a failing
391
+ // signer keeps signer_failed.
392
+ try {
393
+ observeIdentity(identity);
394
+ }
395
+ catch {
396
+ fail("invalid_policy", "input");
397
+ }
398
+ }
376
399
  return result;
377
400
  }
378
401
  catch (e) {
379
402
  if (isRequestSealError(e) && e.kind === "custody" && e.reason === "deadline_exceeded")
380
403
  return err(e);
381
- return err(bounded(e, typeof profile.name === "string" ? "invalid_options" : "invalid_policy", "input"));
404
+ return err(envelope(e));
382
405
  }
383
406
  }
384
- /** Verify one HTTP signature under an explicit branded policy. */
407
+ /**
408
+ * Verify one HTTP signature under an explicit branded policy.
409
+ *
410
+ * Checks exactly the named label; it never tries other labels. Success means the
411
+ * cryptography, coverage, freshness, content, and replay layers all passed. It does not
412
+ * authorize: the Verification records `authorization: "not_evaluated"`.
413
+ *
414
+ * @param message - A branded Message carrying the signature.
415
+ * @param policy - A branded Policy from `Policy.create`.
416
+ * @param options - `{label, context?, representation?, digestState?}`. `representation` is a
417
+ * complete retained Body and needs a `"representation"` content policy. `digestState` is a
418
+ * DigestState of the policy's content kind that you fed with a streamed body; verify finishes
419
+ * it. The two are mutually exclusive.
420
+ * @returns `ok(verification)`, or a bounded error such as `core` `crypto.invalid_signature`,
421
+ * `freshness.expired`, `content.digest_mismatch`, `key.unknown_key`, or `replay.replayed`.
422
+ * @see [Signing and verifying guide](https://github.com/baselabs/request-seal-typescript/blob/main/docs/guides/signing-and-verifying.md),
423
+ * [reference](https://github.com/baselabs/request-seal-typescript/blob/main/docs/reference/sign-and-verify.md), and the
424
+ * [error reasons](https://github.com/baselabs/request-seal-typescript/blob/main/docs/reference/errors.md).
425
+ */
385
426
  export function verify(message, policy, options) {
386
- return verifyLabel(message, policy, options);
427
+ // The public entry carries the telemetry span; internal per-label calls do not.
428
+ return span("verify", () => verifyLabel(message, policy, options), r => r.ok ? "ok" : "error", r => r.ok ? undefined : r.error);
387
429
  }
388
430
  /** Internal composition of the single-label pipeline; identities stay private. */
389
431
  export async function verifyCandidate(message, policy, options, parsed) {
@@ -10,11 +10,12 @@ import { isDiscoverySource, sourceOrigin, thumbprintId, } from "../discovery/sou
10
10
  import { isKeySet, isResolution } from "../discovery/body.js";
11
11
  import { isPublicKey } from "../public_key.js";
12
12
  import { coreError, isRequestSealError, } from "../errors/types.js";
13
- import { dictionaries, validInner, parameters, identities, ensure, fail, bounded, } from "../signature-fields.js";
13
+ import { dictionaries, validInner, parameters, identities, ensure, fail, envelope, } from "../signature-fields.js";
14
14
  import { CLOCK_MAX_SECONDS } from "../constants.js";
15
15
  import { retentionEnd } from "../replay/claim.js";
16
16
  import { agents, identifier, originAgent, component, required, nested, schemas, } from "./support.js";
17
17
  import { WEB_BOT_AUTH_PROFILE, completedEnvelope, } from "./verification.js";
18
+ import { span } from "../telemetry.js";
18
19
  const testKeys = new Set([
19
20
  "BHj8s0GPnMEQtkaULIM-PLgEhLBbuGUQ1vMxmBWZzEo",
20
21
  "oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA",
@@ -180,7 +181,10 @@ async function resolve(agent, label, params, p, c) {
180
181
  };
181
182
  }
182
183
  }
183
- export async function verifyWebBotAuth(message, policy, options = {}) {
184
+ export function verifyWebBotAuth(message, policy, options = {}) {
185
+ return span("webBotAuthVerify", () => verifyWebBotAuthSpanned(message, policy, options), r => r.ok ? "ok" : "error", r => r.ok ? undefined : r.error);
186
+ }
187
+ async function verifyWebBotAuthSpanned(message, policy, options = {}) {
184
188
  let owned;
185
189
  try {
186
190
  ensure(isWebBotAuthPolicy(policy), "invalid_policy", "input");
@@ -336,7 +340,7 @@ export async function verifyWebBotAuth(message, policy, options = {}) {
336
340
  catch (e) {
337
341
  if (isRequestSealError(e))
338
342
  return err(e);
339
- return err(bounded(e, "invalid_options", "input"));
343
+ return err(envelope(e));
340
344
  }
341
345
  finally {
342
346
  if (owned)
package/package.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "name": "@request-seal/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Standards-based HTTP message signatures and JOSE for web-standard runtimes.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
+ "keywords": [
8
+ "http-message-signatures",
9
+ "rfc9421",
10
+ "rfc9530",
11
+ "content-digest",
12
+ "web-bot-auth",
13
+ "jose",
14
+ "jws",
15
+ "jwe",
16
+ "webcrypto",
17
+ "replay-protection"
18
+ ],
7
19
  "main": "./dist/index.js",
8
20
  "types": "./dist/index.d.ts",
9
21
  "exports": {