@did-btcr2/method 0.29.0 → 0.32.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 (104) hide show
  1. package/README.md +13 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +8174 -7157
  4. package/dist/browser.mjs +8174 -7157
  5. package/dist/cjs/index.js +1845 -455
  6. package/dist/esm/core/aggregation/transport/factory.js +15 -6
  7. package/dist/esm/core/aggregation/transport/factory.js.map +1 -1
  8. package/dist/esm/core/aggregation/transport/http/client.js +350 -0
  9. package/dist/esm/core/aggregation/transport/http/client.js.map +1 -0
  10. package/dist/esm/core/aggregation/transport/http/envelope.js +126 -0
  11. package/dist/esm/core/aggregation/transport/http/envelope.js.map +1 -0
  12. package/dist/esm/core/aggregation/transport/http/errors.js +11 -0
  13. package/dist/esm/core/aggregation/transport/http/errors.js.map +1 -0
  14. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +45 -0
  15. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js.map +1 -0
  16. package/dist/esm/core/aggregation/transport/http/index.js +12 -0
  17. package/dist/esm/core/aggregation/transport/http/index.js.map +1 -0
  18. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +38 -0
  19. package/dist/esm/core/aggregation/transport/http/nonce-cache.js.map +1 -0
  20. package/dist/esm/core/aggregation/transport/http/protocol.js +28 -0
  21. package/dist/esm/core/aggregation/transport/http/protocol.js.map +1 -0
  22. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +45 -0
  23. package/dist/esm/core/aggregation/transport/http/rate-limiter.js.map +1 -0
  24. package/dist/esm/core/aggregation/transport/http/request-auth.js +100 -0
  25. package/dist/esm/core/aggregation/transport/http/request-auth.js.map +1 -0
  26. package/dist/esm/core/aggregation/transport/http/server.js +481 -0
  27. package/dist/esm/core/aggregation/transport/http/server.js.map +1 -0
  28. package/dist/esm/core/aggregation/transport/http/sse-stream.js +110 -0
  29. package/dist/esm/core/aggregation/transport/http/sse-stream.js.map +1 -0
  30. package/dist/esm/core/aggregation/transport/http/sse-writer.js +25 -0
  31. package/dist/esm/core/aggregation/transport/http/sse-writer.js.map +1 -0
  32. package/dist/esm/core/aggregation/transport/index.js +1 -0
  33. package/dist/esm/core/aggregation/transport/index.js.map +1 -1
  34. package/dist/esm/core/beacon/beacon.js +197 -51
  35. package/dist/esm/core/beacon/beacon.js.map +1 -1
  36. package/dist/esm/core/beacon/cas-beacon.js +3 -3
  37. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  38. package/dist/esm/core/beacon/singleton-beacon.js +3 -3
  39. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  40. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  41. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  42. package/dist/esm/core/updater.js +63 -55
  43. package/dist/esm/core/updater.js.map +1 -1
  44. package/dist/types/core/aggregation/transport/factory.d.ts +22 -7
  45. package/dist/types/core/aggregation/transport/factory.d.ts.map +1 -1
  46. package/dist/types/core/aggregation/transport/http/client.d.ts +48 -0
  47. package/dist/types/core/aggregation/transport/http/client.d.ts.map +1 -0
  48. package/dist/types/core/aggregation/transport/http/envelope.d.ts +64 -0
  49. package/dist/types/core/aggregation/transport/http/envelope.d.ts.map +1 -0
  50. package/dist/types/core/aggregation/transport/http/errors.d.ts +9 -0
  51. package/dist/types/core/aggregation/transport/http/errors.d.ts.map +1 -0
  52. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +32 -0
  53. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts.map +1 -0
  54. package/dist/types/core/aggregation/transport/http/index.d.ts +12 -0
  55. package/dist/types/core/aggregation/transport/http/index.d.ts.map +1 -0
  56. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +26 -0
  57. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts.map +1 -0
  58. package/dist/types/core/aggregation/transport/http/protocol.d.ts +53 -0
  59. package/dist/types/core/aggregation/transport/http/protocol.d.ts.map +1 -0
  60. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +41 -0
  61. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts.map +1 -0
  62. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +50 -0
  63. package/dist/types/core/aggregation/transport/http/request-auth.d.ts.map +1 -0
  64. package/dist/types/core/aggregation/transport/http/server.d.ts +110 -0
  65. package/dist/types/core/aggregation/transport/http/server.d.ts.map +1 -0
  66. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +34 -0
  67. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts.map +1 -0
  68. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +12 -0
  69. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts.map +1 -0
  70. package/dist/types/core/aggregation/transport/index.d.ts +1 -0
  71. package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
  72. package/dist/types/core/aggregation/transport/transport.d.ts +1 -1
  73. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  74. package/dist/types/core/beacon/beacon.d.ts +72 -12
  75. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  76. package/dist/types/core/beacon/cas-beacon.d.ts +3 -3
  77. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  78. package/dist/types/core/beacon/singleton-beacon.d.ts +3 -3
  79. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  80. package/dist/types/core/beacon/smt-beacon.d.ts +3 -3
  81. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  82. package/dist/types/core/updater.d.ts +27 -12
  83. package/dist/types/core/updater.d.ts.map +1 -1
  84. package/package.json +5 -5
  85. package/src/core/aggregation/transport/factory.ts +48 -12
  86. package/src/core/aggregation/transport/http/client.ts +409 -0
  87. package/src/core/aggregation/transport/http/envelope.ts +204 -0
  88. package/src/core/aggregation/transport/http/errors.ts +11 -0
  89. package/src/core/aggregation/transport/http/inbox-buffer.ts +53 -0
  90. package/src/core/aggregation/transport/http/index.ts +11 -0
  91. package/src/core/aggregation/transport/http/nonce-cache.ts +43 -0
  92. package/src/core/aggregation/transport/http/protocol.ts +57 -0
  93. package/src/core/aggregation/transport/http/rate-limiter.ts +75 -0
  94. package/src/core/aggregation/transport/http/request-auth.ts +164 -0
  95. package/src/core/aggregation/transport/http/server.ts +615 -0
  96. package/src/core/aggregation/transport/http/sse-stream.ts +121 -0
  97. package/src/core/aggregation/transport/http/sse-writer.ts +23 -0
  98. package/src/core/aggregation/transport/index.ts +1 -0
  99. package/src/core/aggregation/transport/transport.ts +1 -1
  100. package/src/core/beacon/beacon.ts +255 -64
  101. package/src/core/beacon/cas-beacon.ts +4 -4
  102. package/src/core/beacon/singleton-beacon.ts +4 -4
  103. package/src/core/beacon/smt-beacon.ts +4 -4
  104. package/src/core/updater.ts +113 -67
@@ -0,0 +1,204 @@
1
+ import { canonicalHashBytes } from '@did-btcr2/common';
2
+ import type { CompressedSecp256k1PublicKey, SchnorrKeyPair } from '@did-btcr2/keypair';
3
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
+
5
+ import type { BaseMessage } from '../../messages/base.js';
6
+ import { HttpTransportError } from './errors.js';
7
+ import {
8
+ DEFAULT_CLOCK_SKEW_SEC,
9
+ DEFAULT_NONCE_LEN_BYTES,
10
+ HTTP_ENVELOPE_VERSION,
11
+ type SignedEnvelope,
12
+ } from './protocol.js';
13
+
14
+ /** Any shape acceptable as an envelope payload. `BaseMessage` instances are
15
+ * `toJSON`-normalized before signing so class vs. POJO callers produce the
16
+ * same canonical form. */
17
+ export type EnvelopeMessage = BaseMessage | Record<string, unknown>;
18
+
19
+ export interface SignEnvelopeOptions {
20
+ /** Recipient DID. Omit for broadcasts. */
21
+ to?: string;
22
+ /** Override the random nonce (tests). */
23
+ nonce?: string;
24
+ /** Override the unix-seconds timestamp (tests). */
25
+ timestamp?: number;
26
+ }
27
+
28
+ export interface VerifyEnvelopeOptions {
29
+ /** Reject if `envelope.from` doesn't match. */
30
+ expectedFrom?: string;
31
+ /** Reject if `envelope.to` doesn't match. Pass `undefined` to require a broadcast. */
32
+ expectedTo?: string;
33
+ /** Clock-skew tolerance (seconds). Defaults to {@link DEFAULT_CLOCK_SKEW_SEC}. */
34
+ clockSkewSec?: number;
35
+ /** Clock override (tests). Defaults to `Date.now`. */
36
+ now?: () => number;
37
+ }
38
+
39
+ /**
40
+ * Build a {@link SignedEnvelope} around `message`.
41
+ *
42
+ * Pure function — no I/O beyond `randomBytes` for nonce generation (which
43
+ * uses the platform's cryptographic RNG: `crypto.getRandomValues` in browsers,
44
+ * `node:crypto` in Node). Deterministic when both `nonce` and `timestamp` are
45
+ * supplied via {@link SignEnvelopeOptions}.
46
+ */
47
+ export function signEnvelope(
48
+ message: EnvelopeMessage,
49
+ sender: { did: string; keys: SchnorrKeyPair },
50
+ opts: SignEnvelopeOptions = {},
51
+ ): SignedEnvelope {
52
+ const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
53
+ const nonce = opts.nonce ?? bytesToHex(randomBytes(DEFAULT_NONCE_LEN_BYTES));
54
+ const messageJson = normalizeForWire(normalizeMessage(message)) as Record<string, unknown>;
55
+
56
+ const unsigned: Omit<SignedEnvelope, 'sig'> = {
57
+ v : HTTP_ENVELOPE_VERSION,
58
+ from : sender.did,
59
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
60
+ timestamp,
61
+ nonce,
62
+ message : messageJson,
63
+ };
64
+
65
+ const digest = canonicalHashBytes(unsigned);
66
+ const sig = sender.keys.secretKey.sign(digest, { scheme: 'schnorr' });
67
+
68
+ return { ...unsigned, sig: bytesToHex(sig) };
69
+ }
70
+
71
+ /**
72
+ * Verify a {@link SignedEnvelope} against the sender's compressed secp256k1
73
+ * communication public key. Throws {@link HttpTransportError} on any failure;
74
+ * returns normally on success.
75
+ *
76
+ * Does NOT check nonce uniqueness — replay protection is the caller's
77
+ * responsibility (the server-side transport maintains an LRU cache).
78
+ */
79
+ export function verifyEnvelope(
80
+ envelope: SignedEnvelope,
81
+ senderPk: CompressedSecp256k1PublicKey,
82
+ opts: VerifyEnvelopeOptions = {},
83
+ ): void {
84
+ if(envelope.v !== HTTP_ENVELOPE_VERSION) {
85
+ throw new HttpTransportError(
86
+ `Unsupported envelope version: ${envelope.v}`,
87
+ 'ENVELOPE_VERSION_MISMATCH',
88
+ { version: envelope.v, expected: HTTP_ENVELOPE_VERSION },
89
+ );
90
+ }
91
+
92
+ if(opts.expectedFrom !== undefined && envelope.from !== opts.expectedFrom) {
93
+ throw new HttpTransportError(
94
+ `Envelope from mismatch: expected ${opts.expectedFrom}, got ${envelope.from}`,
95
+ 'ENVELOPE_FROM_MISMATCH',
96
+ { expected: opts.expectedFrom, got: envelope.from },
97
+ );
98
+ }
99
+
100
+ if('expectedTo' in opts && envelope.to !== opts.expectedTo) {
101
+ throw new HttpTransportError(
102
+ `Envelope to mismatch: expected ${opts.expectedTo ?? '<broadcast>'}, got ${envelope.to ?? '<broadcast>'}`,
103
+ 'ENVELOPE_TO_MISMATCH',
104
+ { expected: opts.expectedTo, got: envelope.to },
105
+ );
106
+ }
107
+
108
+ const skewSec = opts.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
109
+ const nowMs = opts.now ? opts.now() : Date.now();
110
+ const nowSec = Math.floor(nowMs / 1000);
111
+ const diff = Math.abs(nowSec - envelope.timestamp);
112
+ if(diff > skewSec) {
113
+ throw new HttpTransportError(
114
+ `Envelope timestamp out of skew: ${diff}s > ${skewSec}s`,
115
+ 'ENVELOPE_TIMESTAMP_SKEW',
116
+ { diff, skewSec, timestamp: envelope.timestamp, now: nowSec },
117
+ );
118
+ }
119
+
120
+ let sigBytes: Uint8Array;
121
+ try {
122
+ sigBytes = hexToBytes(envelope.sig);
123
+ } catch {
124
+ throw new HttpTransportError(
125
+ 'Envelope signature is not valid hex',
126
+ 'ENVELOPE_SIG_HEX',
127
+ );
128
+ }
129
+ if(sigBytes.length !== 64) {
130
+ throw new HttpTransportError(
131
+ `Invalid signature length: ${sigBytes.length} (expected 64)`,
132
+ 'ENVELOPE_SIG_LENGTH',
133
+ { length: sigBytes.length },
134
+ );
135
+ }
136
+
137
+ const { sig: _sig, ...unsigned } = envelope;
138
+ const digest = canonicalHashBytes(unsigned);
139
+
140
+ const ok = senderPk.verify(sigBytes, digest, { scheme: 'schnorr' });
141
+ if(!ok) {
142
+ throw new HttpTransportError(
143
+ 'Envelope signature verification failed',
144
+ 'ENVELOPE_SIG_INVALID',
145
+ );
146
+ }
147
+ }
148
+
149
+ function normalizeMessage(message: EnvelopeMessage): Record<string, unknown> {
150
+ const maybeToJSON = (message as { toJSON?: () => unknown }).toJSON;
151
+ if(typeof maybeToJSON === 'function') {
152
+ return maybeToJSON.call(message) as Record<string, unknown>;
153
+ }
154
+ return message as Record<string, unknown>;
155
+ }
156
+
157
+ /**
158
+ * Recursively replace `Uint8Array` values with `{ __bytes: hex }` sentinel
159
+ * objects so they survive JSON canonicalization / HTTP body serialization.
160
+ * Pairs with {@link reviveFromWire}.
161
+ *
162
+ * Without this, `JSON.stringify` serializes a `Uint8Array` as an index-keyed
163
+ * object (`{"0":1,"1":2,...}`), which `canonicalize` then re-parses into a
164
+ * plain object — the receiver cannot reconstruct the original bytes even
165
+ * though the signature still verifies.
166
+ */
167
+ export function normalizeForWire(value: unknown): unknown {
168
+ if(value instanceof Uint8Array) {
169
+ return { __bytes: bytesToHex(value) };
170
+ }
171
+ if(Array.isArray(value)) {
172
+ return value.map((v) => normalizeForWire(v));
173
+ }
174
+ if(value && typeof value === 'object') {
175
+ const out: Record<string, unknown> = {};
176
+ for(const [k, v] of Object.entries(value as Record<string, unknown>)) {
177
+ out[k] = normalizeForWire(v);
178
+ }
179
+ return out;
180
+ }
181
+ return value;
182
+ }
183
+
184
+ /**
185
+ * Recursively convert `{ __bytes: hex }` sentinels back into `Uint8Array`
186
+ * values. Call on `envelope.message` *after* successful verification and
187
+ * before handing the payload to a runner's handler.
188
+ */
189
+ export function reviveFromWire(value: unknown): unknown {
190
+ if(value && typeof value === 'object' && !Array.isArray(value)) {
191
+ const rec = value as Record<string, unknown>;
192
+ const keys = Object.keys(rec);
193
+ if(keys.length === 1 && keys[0] === '__bytes' && typeof rec.__bytes === 'string') {
194
+ return hexToBytes(rec.__bytes);
195
+ }
196
+ const out: Record<string, unknown> = {};
197
+ for(const [k, v] of Object.entries(rec)) out[k] = reviveFromWire(v);
198
+ return out;
199
+ }
200
+ if(Array.isArray(value)) {
201
+ return value.map((v) => reviveFromWire(v));
202
+ }
203
+ return value;
204
+ }
@@ -0,0 +1,11 @@
1
+ import { TransportAdapterError } from '../error.js';
2
+
3
+ /**
4
+ * Errors raised by the HTTP transport adapter. Extends {@link TransportAdapterError}
5
+ * so callers can catch HTTP-specific failures narrowly or transport failures broadly.
6
+ */
7
+ export class HttpTransportError extends TransportAdapterError {
8
+ constructor(message: string, type: string = 'HttpTransportError', data?: Record<string, any>) {
9
+ super(message, type, { adapter: 'http', ...(data ?? {}) });
10
+ }
11
+ }
@@ -0,0 +1,53 @@
1
+ export interface StoredEvent {
2
+ /** Monotonic ID assigned at append time. Stable across the buffer's lifetime. */
3
+ id: string;
4
+ /** SSE event name. */
5
+ event: string;
6
+ /** SSE data payload (typically a JSON-stringified {@link SignedEnvelope}). */
7
+ data: string;
8
+ }
9
+
10
+ /**
11
+ * Fixed-capacity FIFO ring buffer of SSE events for a single actor's inbox.
12
+ *
13
+ * When a subscriber (re)connects with a `Last-Event-ID` header, the server
14
+ * uses {@link since} to replay everything that arrived while the subscriber
15
+ * was disconnected. Events older than the replay window (evicted from the
16
+ * ring) are unrecoverable — callers should choose `capacity` based on
17
+ * expected message rate × acceptable reconnect window.
18
+ */
19
+ export class InboxBuffer {
20
+ readonly #capacity: number;
21
+ readonly #entries: StoredEvent[] = [];
22
+ #nextId = 1;
23
+
24
+ constructor(capacity = 100) {
25
+ if(capacity < 1) throw new Error(`InboxBuffer capacity must be >= 1; got ${capacity}`);
26
+ this.#capacity = capacity;
27
+ }
28
+
29
+ /** Append an event. Returns the stored record (including its assigned id). */
30
+ append(event: string, data: string): StoredEvent {
31
+ const stored: StoredEvent = { id: String(this.#nextId++), event, data };
32
+ this.#entries.push(stored);
33
+ if(this.#entries.length > this.#capacity) this.#entries.shift();
34
+ return stored;
35
+ }
36
+
37
+ /**
38
+ * Return stored events with id strictly greater than `lastEventId`. If
39
+ * `lastEventId` is unset or unparseable, returns everything currently
40
+ * retained.
41
+ */
42
+ since(lastEventId?: string): StoredEvent[] {
43
+ if(!lastEventId) return this.#entries.slice();
44
+ const boundary = Number(lastEventId);
45
+ if(!Number.isFinite(boundary)) return this.#entries.slice();
46
+ return this.#entries.filter((e) => Number(e.id) > boundary);
47
+ }
48
+
49
+ /** Currently retained event count. */
50
+ size(): number {
51
+ return this.#entries.length;
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ export * from './errors.js';
2
+ export * from './protocol.js';
3
+ export * from './envelope.js';
4
+ export * from './sse-stream.js';
5
+ export * from './sse-writer.js';
6
+ export * from './request-auth.js';
7
+ export * from './nonce-cache.js';
8
+ export * from './rate-limiter.js';
9
+ export * from './inbox-buffer.js';
10
+ export * from './client.js';
11
+ export * from './server.js';
@@ -0,0 +1,43 @@
1
+ export interface NonceCacheConfig {
2
+ /** Max distinct entries to retain before FIFO eviction. Default 10,000. */
3
+ maxEntries?: number;
4
+ }
5
+
6
+ /**
7
+ * Bounded anti-replay cache for `(did, nonce)` pairs.
8
+ *
9
+ * Replay windowing is the caller's responsibility — this cache only detects
10
+ * duplicates. Callers are expected to reject envelopes/headers whose timestamp
11
+ * is outside the clock-skew window *before* consulting the cache, so entries
12
+ * here are always within the protocol's acceptable window.
13
+ *
14
+ * Eviction is strict-FIFO (Map insertion order) once `maxEntries` is reached.
15
+ */
16
+ export class NonceCache {
17
+ readonly #maxEntries: number;
18
+ readonly #entries = new Map<string, number>();
19
+
20
+ constructor(config: NonceCacheConfig = {}) {
21
+ this.#maxEntries = config.maxEntries ?? 10_000;
22
+ }
23
+
24
+ /**
25
+ * Record a nonce. Returns `true` if it was novel (caller should accept the
26
+ * request) or `false` if it was a replay (caller should reject).
27
+ */
28
+ store(did: string, nonce: string, timestampSec: number): boolean {
29
+ const key = `${did}:${nonce}`;
30
+ if(this.#entries.has(key)) return false;
31
+ this.#entries.set(key, timestampSec);
32
+ if(this.#entries.size > this.#maxEntries) {
33
+ const oldest = this.#entries.keys().next();
34
+ if(!oldest.done) this.#entries.delete(oldest.value);
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /** Current cache size. Exposed for observability and tests. */
40
+ size(): number {
41
+ return this.#entries.size;
42
+ }
43
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * On-the-wire version of the HTTP transport envelope. Separate from
3
+ * {@link AGGREGATION_WIRE_VERSION} (which versions the aggregation protocol
4
+ * payloads) so transport-layer changes don't force a protocol bump.
5
+ */
6
+ export const HTTP_ENVELOPE_VERSION = 1;
7
+
8
+ /**
9
+ * HTTP routes served by {@link HttpServerTransport}. The `{did}` placeholder
10
+ * in {@link HTTP_ROUTE.ACTOR_INBOX} is substituted with a URL-safe DID at
11
+ * request time.
12
+ */
13
+ export const HTTP_ROUTE = {
14
+ ADVERTS : '/v1/adverts',
15
+ MESSAGES : '/v1/messages',
16
+ ACTOR_INBOX : '/v1/actors/{did}/inbox',
17
+ WELL_KNOWN : '/v1/.well-known/aggregation',
18
+ } as const;
19
+
20
+ /** Server-Sent Events event names used by the broadcast + inbox streams. */
21
+ export const SSE_EVENT = {
22
+ ADVERT : 'advert',
23
+ MESSAGE : 'message',
24
+ HEARTBEAT : 'heartbeat',
25
+ } as const;
26
+
27
+ /** Default clock-skew tolerance for envelope timestamps (seconds). */
28
+ export const DEFAULT_CLOCK_SKEW_SEC = 60;
29
+
30
+ /** Default length of the per-envelope anti-replay nonce (bytes). */
31
+ export const DEFAULT_NONCE_LEN_BYTES = 16;
32
+
33
+ /**
34
+ * Tamper-evident wrapper around an aggregation {@link BaseMessage}. Every
35
+ * authenticated HTTP request and inbox SSE event carries one of these.
36
+ *
37
+ * The signature is BIP340 Schnorr over the SHA-256 of the JCS-canonicalized
38
+ * envelope **excluding** the `sig` field. Receivers reconstruct the same
39
+ * canonical form, verify the signature against the sender's registered
40
+ * communication public key, and reject outside-skew timestamps.
41
+ */
42
+ export interface SignedEnvelope {
43
+ /** Envelope format version; must equal {@link HTTP_ENVELOPE_VERSION}. */
44
+ v: number;
45
+ /** Sender DID. */
46
+ from: string;
47
+ /** Recipient DID. Omitted for broadcasts (e.g. COHORT_ADVERT). */
48
+ to?: string;
49
+ /** Unix seconds at which the envelope was produced. */
50
+ timestamp: number;
51
+ /** Hex-encoded random nonce, {@link DEFAULT_NONCE_LEN_BYTES} bytes. */
52
+ nonce: string;
53
+ /** Aggregation message payload (plain-JSON form, `toJSON`-normalized). */
54
+ message: Record<string, unknown>;
55
+ /** Hex-encoded 64-byte BIP340 Schnorr signature. */
56
+ sig: string;
57
+ }
@@ -0,0 +1,75 @@
1
+ export interface BucketState {
2
+ tokens: number;
3
+ lastRefillMs: number;
4
+ }
5
+
6
+ /**
7
+ * Pluggable storage backend for rate-limit state. The default
8
+ * {@link InMemoryRateLimitStore} is a plain Map; swap in Redis-backed or
9
+ * SQLite-backed implementations for multi-instance deployments.
10
+ */
11
+ export interface RateLimitStore {
12
+ get(key: string): BucketState | undefined;
13
+ set(key: string, state: BucketState): void;
14
+ }
15
+
16
+ export class InMemoryRateLimitStore implements RateLimitStore {
17
+ readonly #buckets = new Map<string, BucketState>();
18
+
19
+ get(key: string): BucketState | undefined {
20
+ return this.#buckets.get(key);
21
+ }
22
+
23
+ set(key: string, state: BucketState): void {
24
+ this.#buckets.set(key, state);
25
+ }
26
+ }
27
+
28
+ export interface RateLimiterConfig {
29
+ /** Steady-state request rate per key (requests per second). Default 10. */
30
+ rps?: number;
31
+ /** Peak burst allowance (bucket capacity). Default 30. */
32
+ burst?: number;
33
+ /** Optional pluggable store. Defaults to an in-memory Map. */
34
+ store?: RateLimitStore;
35
+ }
36
+
37
+ /**
38
+ * Token-bucket rate limiter keyed by an opaque string (typically a verified
39
+ * sender DID). Tokens refill linearly at `rps` up to `burst`. Each `consume`
40
+ * call atomically debits one token or returns `false` to reject.
41
+ *
42
+ * The limiter is synchronous and deterministic given `nowMs` — tests can
43
+ * drive it with a fixed clock to exercise exact boundaries.
44
+ */
45
+ export class RateLimiter {
46
+ readonly #rps: number;
47
+ readonly #burst: number;
48
+ readonly #store: RateLimitStore;
49
+
50
+ constructor(config: RateLimiterConfig = {}) {
51
+ this.#rps = config.rps ?? 10;
52
+ this.#burst = config.burst ?? 30;
53
+ this.#store = config.store ?? new InMemoryRateLimitStore();
54
+ }
55
+
56
+ /** Consume one token for `key`. Returns `true` if accepted, `false` if throttled. */
57
+ consume(key: string, nowMs: number): boolean {
58
+ const existing = this.#store.get(key);
59
+ const state: BucketState = existing ?? { tokens: this.#burst, lastRefillMs: nowMs };
60
+
61
+ if(existing) {
62
+ const elapsedSec = Math.max(0, (nowMs - existing.lastRefillMs) / 1000);
63
+ state.tokens = Math.min(this.#burst, existing.tokens + elapsedSec * this.#rps);
64
+ state.lastRefillMs = nowMs;
65
+ }
66
+
67
+ if(state.tokens < 1) {
68
+ this.#store.set(key, state);
69
+ return false;
70
+ }
71
+ state.tokens -= 1;
72
+ this.#store.set(key, state);
73
+ return true;
74
+ }
75
+ }
@@ -0,0 +1,164 @@
1
+ import { canonicalHashBytes } from '@did-btcr2/common';
2
+ import type { CompressedSecp256k1PublicKey, SchnorrKeyPair } from '@did-btcr2/keypair';
3
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
+
5
+ import { HttpTransportError } from './errors.js';
6
+ import { DEFAULT_CLOCK_SKEW_SEC, DEFAULT_NONCE_LEN_BYTES, HTTP_ENVELOPE_VERSION } from './protocol.js';
7
+
8
+ /**
9
+ * `Authorization`-header scheme used to authenticate SSE subscription
10
+ * requests. The header value takes the form
11
+ * `BTCR2-Sig v=<n>,did=<did>,ts=<unix>,nonce=<hex>,sig=<hex>`.
12
+ *
13
+ * Used only for GET endpoints (SSE inbox subscribe). POST endpoints carry a
14
+ * full {@link SignedEnvelope} in the request body instead.
15
+ */
16
+ export const REQUEST_AUTH_SCHEME = 'BTCR2-Sig';
17
+
18
+ export interface ParsedRequestAuth {
19
+ /** Transport envelope format version. */
20
+ v: number;
21
+ /** Subscriber DID. */
22
+ did: string;
23
+ /** Unix-seconds timestamp. */
24
+ ts: number;
25
+ /** Hex-encoded anti-replay nonce. */
26
+ nonce: string;
27
+ /** Hex-encoded 64-byte BIP340 signature. */
28
+ sig: string;
29
+ }
30
+
31
+ export interface BuildRequestAuthOptions {
32
+ nonce?: string;
33
+ timestamp?: number;
34
+ }
35
+
36
+ /**
37
+ * Build an `Authorization` header value proving the caller controls the
38
+ * private key for `did`. Covers a specific request path so the signature
39
+ * can't be replayed against a different endpoint.
40
+ */
41
+ export function buildRequestAuth(
42
+ did: string,
43
+ keys: SchnorrKeyPair,
44
+ path: string,
45
+ opts: BuildRequestAuthOptions = {},
46
+ ): string {
47
+ const ts = opts.timestamp ?? Math.floor(Date.now() / 1000);
48
+ const nonce = opts.nonce ?? bytesToHex(randomBytes(DEFAULT_NONCE_LEN_BYTES));
49
+
50
+ const digest = canonicalHashBytes({
51
+ v : HTTP_ENVELOPE_VERSION,
52
+ did,
53
+ ts,
54
+ nonce,
55
+ path,
56
+ });
57
+ const sig = keys.secretKey.sign(digest, { scheme: 'schnorr' });
58
+
59
+ return `${REQUEST_AUTH_SCHEME} v=${HTTP_ENVELOPE_VERSION},did=${did},ts=${ts},nonce=${nonce},sig=${bytesToHex(sig)}`;
60
+ }
61
+
62
+ /**
63
+ * Parse a `BTCR2-Sig` auth header value into its structured fields. Does NOT
64
+ * verify the signature — call {@link verifyRequestAuth} for that.
65
+ */
66
+ export function parseRequestAuth(headerValue: string): ParsedRequestAuth {
67
+ const prefix = `${REQUEST_AUTH_SCHEME} `;
68
+ if(!headerValue.startsWith(prefix)) {
69
+ throw new HttpTransportError(
70
+ `Unexpected auth scheme (want ${REQUEST_AUTH_SCHEME})`,
71
+ 'REQUEST_AUTH_SCHEME',
72
+ );
73
+ }
74
+
75
+ const params: Record<string, string> = {};
76
+ for(const piece of headerValue.slice(prefix.length).split(',')) {
77
+ const eq = piece.indexOf('=');
78
+ if(eq === -1) continue;
79
+ const key = piece.slice(0, eq).trim();
80
+ const val = piece.slice(eq + 1).trim();
81
+ if(key.length > 0) params[key] = val;
82
+ }
83
+
84
+ const v = Number(params.v);
85
+ const ts = Number(params.ts);
86
+ if(!Number.isInteger(v) || !Number.isInteger(ts) || !params.did || !params.nonce || !params.sig) {
87
+ throw new HttpTransportError(
88
+ 'Malformed auth header (missing or invalid field)',
89
+ 'REQUEST_AUTH_MALFORMED',
90
+ { received: Object.keys(params) },
91
+ );
92
+ }
93
+ return { v, did: params.did, ts, nonce: params.nonce, sig: params.sig };
94
+ }
95
+
96
+ export interface VerifyRequestAuthOptions {
97
+ clockSkewSec?: number;
98
+ now?: () => number;
99
+ }
100
+
101
+ /**
102
+ * Parse + verify an auth header. Throws {@link HttpTransportError} on any
103
+ * failure; returns the parsed fields on success.
104
+ *
105
+ * `expectedPath` must match the path the signature commits to. `senderPk`
106
+ * must correspond to the DID the signer claims.
107
+ */
108
+ export function verifyRequestAuth(
109
+ headerValue: string,
110
+ expectedPath: string,
111
+ senderPk: CompressedSecp256k1PublicKey,
112
+ opts: VerifyRequestAuthOptions = {},
113
+ ): ParsedRequestAuth {
114
+ const parsed = parseRequestAuth(headerValue);
115
+
116
+ if(parsed.v !== HTTP_ENVELOPE_VERSION) {
117
+ throw new HttpTransportError(
118
+ `Unsupported auth version: ${parsed.v}`,
119
+ 'REQUEST_AUTH_VERSION_MISMATCH',
120
+ { version: parsed.v, expected: HTTP_ENVELOPE_VERSION },
121
+ );
122
+ }
123
+
124
+ const skewSec = opts.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
125
+ const nowMs = opts.now ? opts.now() : Date.now();
126
+ const nowSec = Math.floor(nowMs / 1000);
127
+ const diff = Math.abs(nowSec - parsed.ts);
128
+ if(diff > skewSec) {
129
+ throw new HttpTransportError(
130
+ `Auth timestamp out of skew: ${diff}s > ${skewSec}s`,
131
+ 'REQUEST_AUTH_TIMESTAMP_SKEW',
132
+ { diff, skewSec },
133
+ );
134
+ }
135
+
136
+ let sigBytes: Uint8Array;
137
+ try {
138
+ sigBytes = hexToBytes(parsed.sig);
139
+ } catch {
140
+ throw new HttpTransportError('Auth signature is not valid hex', 'REQUEST_AUTH_SIG_HEX');
141
+ }
142
+ if(sigBytes.length !== 64) {
143
+ throw new HttpTransportError(
144
+ `Invalid auth signature length: ${sigBytes.length}`,
145
+ 'REQUEST_AUTH_SIG_LENGTH',
146
+ { length: sigBytes.length },
147
+ );
148
+ }
149
+
150
+ const digest = canonicalHashBytes({
151
+ v : parsed.v,
152
+ did : parsed.did,
153
+ ts : parsed.ts,
154
+ nonce : parsed.nonce,
155
+ path : expectedPath,
156
+ });
157
+
158
+ const ok = senderPk.verify(sigBytes, digest, { scheme: 'schnorr' });
159
+ if(!ok) {
160
+ throw new HttpTransportError('Auth signature verification failed', 'REQUEST_AUTH_SIG_INVALID');
161
+ }
162
+
163
+ return parsed;
164
+ }