@bcts/gstp 1.0.0-alpha.14

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,580 @@
1
+ /**
2
+ * SealedRequest - Sealed request messages for GSTP
3
+ *
4
+ * A SealedRequest wraps a Request with sender information and state
5
+ * continuations for secure, authenticated request messages.
6
+ *
7
+ * Ported from gstp-rust/src/sealed_request.rs
8
+ */
9
+
10
+ import type { ARID, PrivateKeys, Signer } from "@bcts/components";
11
+ import {
12
+ Envelope,
13
+ Request,
14
+ type Expression,
15
+ type Function,
16
+ type EnvelopeEncodableValue,
17
+ type ParameterID,
18
+ } from "@bcts/envelope";
19
+ import { SENDER, SENDER_CONTINUATION, RECIPIENT_CONTINUATION } from "@bcts/known-values";
20
+ import { XIDDocument } from "@bcts/xid";
21
+ import { Continuation } from "./continuation";
22
+ import { GstpError } from "./error";
23
+
24
+ /**
25
+ * Interface that defines the behavior of a sealed request.
26
+ *
27
+ * Extends RequestBehavior with additional methods for managing
28
+ * sender information and state continuations.
29
+ */
30
+ export interface SealedRequestBehavior {
31
+ /**
32
+ * Adds state to the request that the receiver must return in the response.
33
+ */
34
+ withState(state: EnvelopeEncodableValue): SealedRequest;
35
+
36
+ /**
37
+ * Adds optional state to the request.
38
+ */
39
+ withOptionalState(state: EnvelopeEncodableValue | undefined): SealedRequest;
40
+
41
+ /**
42
+ * Adds a continuation previously received from the recipient.
43
+ */
44
+ withPeerContinuation(peerContinuation: Envelope): SealedRequest;
45
+
46
+ /**
47
+ * Adds an optional continuation previously received from the recipient.
48
+ */
49
+ withOptionalPeerContinuation(peerContinuation: Envelope | undefined): SealedRequest;
50
+
51
+ /**
52
+ * Returns the underlying request.
53
+ */
54
+ request(): Request;
55
+
56
+ /**
57
+ * Returns the sender of the request.
58
+ */
59
+ sender(): XIDDocument;
60
+
61
+ /**
62
+ * Returns the state to be sent to the recipient.
63
+ */
64
+ state(): Envelope | undefined;
65
+
66
+ /**
67
+ * Returns the continuation received from the recipient.
68
+ */
69
+ peerContinuation(): Envelope | undefined;
70
+ }
71
+
72
+ /**
73
+ * A sealed request that combines a Request with sender information and
74
+ * state continuations for secure communication.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * import { SealedRequest, ARID } from '@bcts/gstp';
79
+ * import { XIDDocument } from '@bcts/xid';
80
+ *
81
+ * // Create sender XID document
82
+ * const sender = XIDDocument.new();
83
+ * const requestId = ARID.new();
84
+ *
85
+ * // Create a sealed request
86
+ * const request = SealedRequest.new("getBalance", requestId, sender)
87
+ * .withParameter("account", "alice")
88
+ * .withState("session-state-data")
89
+ * .withNote("Balance check");
90
+ *
91
+ * // Convert to sealed envelope
92
+ * const envelope = request.toEnvelope(
93
+ * new Date(Date.now() + 60000), // Valid for 60 seconds
94
+ * senderPrivateKey,
95
+ * recipientXIDDocument
96
+ * );
97
+ * ```
98
+ */
99
+ export class SealedRequest implements SealedRequestBehavior {
100
+ private _request: Request;
101
+ private readonly _sender: XIDDocument;
102
+ private _state: Envelope | undefined;
103
+ private _peerContinuation: Envelope | undefined;
104
+
105
+ private constructor(
106
+ request: Request,
107
+ sender: XIDDocument,
108
+ state?: Envelope,
109
+ peerContinuation?: Envelope,
110
+ ) {
111
+ this._request = request;
112
+ this._sender = sender;
113
+ this._state = state;
114
+ this._peerContinuation = peerContinuation;
115
+ }
116
+
117
+ /**
118
+ * Creates a new sealed request with the given function, ID, and sender.
119
+ *
120
+ * @param func - The function to call (string name or Function object)
121
+ * @param id - The request ID
122
+ * @param sender - The sender's XID document
123
+ */
124
+ static new(func: string | number | Function, id: ARID, sender: XIDDocument): SealedRequest {
125
+ return new SealedRequest(Request.new(func, id), sender);
126
+ }
127
+
128
+ /**
129
+ * Creates a new sealed request with an expression body.
130
+ *
131
+ * @param body - The expression body
132
+ * @param id - The request ID
133
+ * @param sender - The sender's XID document
134
+ */
135
+ static newWithBody(body: Expression, id: ARID, sender: XIDDocument): SealedRequest {
136
+ return new SealedRequest(Request.newWithBody(body, id), sender);
137
+ }
138
+
139
+ // ============================================================================
140
+ // ExpressionBehavior implementation
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Adds a parameter to the request.
145
+ */
146
+ withParameter(parameter: ParameterID, value: EnvelopeEncodableValue): SealedRequest {
147
+ this._request = this._request.withParameter(parameter, value);
148
+ return this;
149
+ }
150
+
151
+ /**
152
+ * Adds an optional parameter to the request.
153
+ */
154
+ withOptionalParameter(
155
+ parameter: ParameterID,
156
+ value: EnvelopeEncodableValue | undefined,
157
+ ): SealedRequest {
158
+ if (value !== undefined) {
159
+ this._request = this._request.withParameter(parameter, value);
160
+ }
161
+ return this;
162
+ }
163
+
164
+ /**
165
+ * Returns the function of the request.
166
+ */
167
+ function(): Function {
168
+ return this._request.function();
169
+ }
170
+
171
+ /**
172
+ * Returns the expression envelope of the request.
173
+ */
174
+ expressionEnvelope(): Envelope {
175
+ return this._request.expressionEnvelope();
176
+ }
177
+
178
+ /**
179
+ * Returns the object for a parameter.
180
+ */
181
+ objectForParameter(param: ParameterID): Envelope | undefined {
182
+ return this._request.body().getParameter(param);
183
+ }
184
+
185
+ /**
186
+ * Returns all objects for a parameter.
187
+ */
188
+ objectsForParameter(param: ParameterID): Envelope[] {
189
+ const obj = this._request.body().getParameter(param);
190
+ return obj !== undefined ? [obj] : [];
191
+ }
192
+
193
+ /**
194
+ * Extracts an object for a parameter as a specific type.
195
+ */
196
+ extractObjectForParameter<T>(param: ParameterID): T {
197
+ const envelope = this.objectForParameter(param);
198
+ if (envelope === undefined) {
199
+ throw GstpError.envelope(new Error(`Parameter not found: ${param}`));
200
+ }
201
+ return envelope.extractSubject((cbor) => {
202
+ // Extract primitive value from CBOR
203
+ if (cbor.isInteger()) return cbor.toInteger() as T;
204
+ if (cbor.isText()) return cbor.toText() as T;
205
+ if (cbor.isBool()) return cbor.toBool() as T;
206
+ if (cbor.isNumber()) return cbor.toNumber() as T;
207
+ if (cbor.isByteString()) return cbor.toByteString() as T;
208
+ return cbor as T;
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Extracts an optional object for a parameter.
214
+ */
215
+ extractOptionalObjectForParameter<T>(param: ParameterID): T | undefined {
216
+ const envelope = this.objectForParameter(param);
217
+ if (envelope === undefined) {
218
+ return undefined;
219
+ }
220
+ return envelope.extractSubject((cbor) => {
221
+ // Extract primitive value from CBOR
222
+ if (cbor.isInteger()) return cbor.toInteger() as T;
223
+ if (cbor.isText()) return cbor.toText() as T;
224
+ if (cbor.isBool()) return cbor.toBool() as T;
225
+ if (cbor.isNumber()) return cbor.toNumber() as T;
226
+ if (cbor.isByteString()) return cbor.toByteString() as T;
227
+ return cbor as T;
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Extracts all objects for a parameter as a specific type.
233
+ */
234
+ extractObjectsForParameter<T>(param: ParameterID): T[] {
235
+ return this.objectsForParameter(param).map((env) => env.extractSubject((cbor) => cbor as T));
236
+ }
237
+
238
+ // ============================================================================
239
+ // RequestBehavior implementation
240
+ // ============================================================================
241
+
242
+ /**
243
+ * Adds a note to the request.
244
+ */
245
+ withNote(note: string): SealedRequest {
246
+ this._request = this._request.withNote(note);
247
+ return this;
248
+ }
249
+
250
+ /**
251
+ * Adds a date to the request.
252
+ */
253
+ withDate(date: Date): SealedRequest {
254
+ this._request = this._request.withDate(date);
255
+ return this;
256
+ }
257
+
258
+ /**
259
+ * Returns the body of the request.
260
+ */
261
+ body(): Expression {
262
+ return this._request.body();
263
+ }
264
+
265
+ /**
266
+ * Returns the ID of the request.
267
+ */
268
+ id(): ARID {
269
+ return this._request.id();
270
+ }
271
+
272
+ /**
273
+ * Returns the note of the request.
274
+ */
275
+ note(): string {
276
+ return this._request.note();
277
+ }
278
+
279
+ /**
280
+ * Returns the date of the request.
281
+ */
282
+ date(): Date | undefined {
283
+ return this._request.date();
284
+ }
285
+
286
+ // ============================================================================
287
+ // SealedRequestBehavior implementation
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Adds state to the request that the receiver must return in the response.
292
+ */
293
+ withState(state: EnvelopeEncodableValue): SealedRequest {
294
+ this._state = Envelope.new(state);
295
+ return this;
296
+ }
297
+
298
+ /**
299
+ * Adds optional state to the request.
300
+ */
301
+ withOptionalState(state: EnvelopeEncodableValue | undefined): SealedRequest {
302
+ this._state = state !== undefined ? Envelope.new(state) : undefined;
303
+ return this;
304
+ }
305
+
306
+ /**
307
+ * Adds a continuation previously received from the recipient.
308
+ */
309
+ withPeerContinuation(peerContinuation: Envelope): SealedRequest {
310
+ this._peerContinuation = peerContinuation;
311
+ return this;
312
+ }
313
+
314
+ /**
315
+ * Adds an optional continuation previously received from the recipient.
316
+ */
317
+ withOptionalPeerContinuation(peerContinuation: Envelope | undefined): SealedRequest {
318
+ this._peerContinuation = peerContinuation;
319
+ return this;
320
+ }
321
+
322
+ /**
323
+ * Returns the underlying request.
324
+ */
325
+ request(): Request {
326
+ return this._request;
327
+ }
328
+
329
+ /**
330
+ * Returns the sender of the request.
331
+ */
332
+ sender(): XIDDocument {
333
+ return this._sender;
334
+ }
335
+
336
+ /**
337
+ * Returns the state to be sent to the recipient.
338
+ */
339
+ state(): Envelope | undefined {
340
+ return this._state;
341
+ }
342
+
343
+ /**
344
+ * Returns the continuation received from the recipient.
345
+ */
346
+ peerContinuation(): Envelope | undefined {
347
+ return this._peerContinuation;
348
+ }
349
+
350
+ // ============================================================================
351
+ // Conversion methods
352
+ // ============================================================================
353
+
354
+ /**
355
+ * Converts the sealed request to a Request.
356
+ */
357
+ toRequest(): Request {
358
+ return this._request;
359
+ }
360
+
361
+ /**
362
+ * Converts the sealed request to an Expression.
363
+ */
364
+ toExpression(): Expression {
365
+ return this._request.body();
366
+ }
367
+
368
+ // ============================================================================
369
+ // Envelope methods
370
+ // ============================================================================
371
+
372
+ /**
373
+ * Creates an envelope that can be decrypted by zero or one recipient.
374
+ *
375
+ * @param validUntil - Optional expiration date for the continuation
376
+ * @param signer - Optional signer for the envelope
377
+ * @param recipient - Optional recipient XID document for encryption
378
+ * @returns The sealed request as an envelope
379
+ */
380
+ toEnvelope(validUntil?: Date, signer?: Signer, recipient?: XIDDocument): Envelope {
381
+ const recipients: XIDDocument[] = recipient !== undefined ? [recipient] : [];
382
+ return this.toEnvelopeForRecipients(validUntil, signer, recipients);
383
+ }
384
+
385
+ /**
386
+ * Creates an envelope that can be decrypted by zero or more recipients.
387
+ *
388
+ * @param validUntil - Optional expiration date for the continuation
389
+ * @param signer - Optional signer for the envelope
390
+ * @param recipients - Array of recipient XID documents for encryption
391
+ * @returns The sealed request as an envelope
392
+ */
393
+ toEnvelopeForRecipients(
394
+ validUntil?: Date,
395
+ signer?: Signer,
396
+ recipients?: XIDDocument[],
397
+ ): Envelope {
398
+ // Even if no state is provided, requests always include a continuation
399
+ // that at least specifies the required valid response ID.
400
+ const stateEnvelope = this._state ?? Envelope.new(null);
401
+ const continuation = new Continuation(stateEnvelope, this.id(), validUntil);
402
+
403
+ // Get sender's encryption key (from inception key)
404
+ const senderInceptionKey = this._sender.inceptionKey();
405
+ const senderEncryptionKey = senderInceptionKey?.publicKeys()?.encapsulationPublicKey();
406
+ if (senderEncryptionKey === undefined) {
407
+ throw GstpError.senderMissingEncryptionKey();
408
+ }
409
+
410
+ // Create sender continuation (encrypted to sender)
411
+ const senderContinuation = continuation.toEnvelope(senderEncryptionKey);
412
+
413
+ // Build the request envelope
414
+ let result = this._request.toEnvelope();
415
+
416
+ // Add sender assertion
417
+ result = result.addAssertion(SENDER, this._sender.toEnvelope());
418
+
419
+ // Add sender continuation
420
+ result = result.addAssertion(SENDER_CONTINUATION, senderContinuation);
421
+
422
+ // Add peer continuation if present
423
+ if (this._peerContinuation !== undefined) {
424
+ result = result.addAssertion(RECIPIENT_CONTINUATION, this._peerContinuation);
425
+ }
426
+
427
+ // Sign if signer provided (sign() wraps first, then adds signature)
428
+ if (signer !== undefined) {
429
+ result = result.sign(signer);
430
+ }
431
+
432
+ // Encrypt to recipients if provided
433
+ if (recipients !== undefined && recipients.length > 0) {
434
+ const recipientKeys = recipients.map((recipient) => {
435
+ const key = recipient.encryptionKey();
436
+ if (key === undefined) {
437
+ throw GstpError.recipientMissingEncryptionKey();
438
+ }
439
+ return key;
440
+ });
441
+
442
+ result = result.wrap().encryptSubjectToRecipients(recipientKeys);
443
+ }
444
+
445
+ return result;
446
+ }
447
+
448
+ /**
449
+ * Parses a sealed request from an encrypted envelope.
450
+ *
451
+ * @param encryptedEnvelope - The encrypted envelope to parse
452
+ * @param expectedId - Optional expected request ID for validation
453
+ * @param now - Optional current time for continuation validation
454
+ * @param recipient - The recipient's private keys for decryption
455
+ * @returns The parsed sealed request
456
+ */
457
+ static tryFromEnvelope(
458
+ encryptedEnvelope: Envelope,
459
+ expectedId: ARID | undefined,
460
+ now: Date | undefined,
461
+ recipient: PrivateKeys,
462
+ ): SealedRequest {
463
+ // Decrypt the envelope
464
+ let signedEnvelope: Envelope;
465
+ try {
466
+ signedEnvelope = encryptedEnvelope.decryptToRecipient(recipient);
467
+ } catch (e) {
468
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
469
+ }
470
+
471
+ // Extract sender from the unwrapped envelope
472
+ let sender: XIDDocument;
473
+ try {
474
+ const unwrapped = signedEnvelope.tryUnwrap();
475
+ const senderEnvelope = unwrapped.objectForPredicate(SENDER);
476
+ if (senderEnvelope === undefined) {
477
+ throw new Error("Missing sender");
478
+ }
479
+ sender = XIDDocument.fromEnvelope(senderEnvelope);
480
+ } catch (e) {
481
+ throw GstpError.xid(e instanceof Error ? e : new Error(String(e)));
482
+ }
483
+
484
+ // Get sender's verification key and verify signature (from inception key)
485
+ const senderInceptionKey = sender.inceptionKey();
486
+ const senderVerificationKey = senderInceptionKey?.publicKeys()?.signingPublicKey();
487
+ if (senderVerificationKey === undefined) {
488
+ throw GstpError.senderMissingVerificationKey();
489
+ }
490
+
491
+ let requestEnvelope: Envelope;
492
+ try {
493
+ // verify() both verifies the signature AND unwraps the envelope
494
+ requestEnvelope = signedEnvelope.verify(senderVerificationKey);
495
+ } catch (e) {
496
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
497
+ }
498
+
499
+ // Get peer continuation (sender_continuation from the request)
500
+ const peerContinuation = requestEnvelope.optionalObjectForPredicate(SENDER_CONTINUATION);
501
+ if (peerContinuation !== undefined) {
502
+ // Verify peer continuation is encrypted
503
+ if (!peerContinuation.subject().isEncrypted()) {
504
+ throw GstpError.peerContinuationNotEncrypted();
505
+ }
506
+ } else {
507
+ throw GstpError.missingPeerContinuation();
508
+ }
509
+
510
+ // Get and decrypt our continuation (recipient_continuation)
511
+ const encryptedContinuation =
512
+ requestEnvelope.optionalObjectForPredicate(RECIPIENT_CONTINUATION);
513
+ let state: Envelope | undefined;
514
+ if (encryptedContinuation !== undefined) {
515
+ const continuation = Continuation.tryFromEnvelope(
516
+ encryptedContinuation,
517
+ expectedId,
518
+ now,
519
+ recipient,
520
+ );
521
+ state = continuation.state();
522
+ }
523
+
524
+ // Parse the request
525
+ let request: Request;
526
+ try {
527
+ request = Request.fromEnvelope(requestEnvelope);
528
+ } catch (e) {
529
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
530
+ }
531
+
532
+ return new SealedRequest(request, sender, state, peerContinuation);
533
+ }
534
+
535
+ // ============================================================================
536
+ // Display methods
537
+ // ============================================================================
538
+
539
+ /**
540
+ * Returns a string representation of the sealed request.
541
+ */
542
+ toString(): string {
543
+ const stateStr = this._state !== undefined ? this._state.formatFlat() : "None";
544
+ const peerStr = this._peerContinuation !== undefined ? "Some" : "None";
545
+ return `SealedRequest(${this._request.summary()}, state: ${stateStr}, peer_continuation: ${peerStr})`;
546
+ }
547
+
548
+ /**
549
+ * Checks equality with another sealed request.
550
+ */
551
+ equals(other: SealedRequest): boolean {
552
+ if (!this._request.equals(other._request)) {
553
+ return false;
554
+ }
555
+ if (!this._sender.xid().equals(other._sender.xid())) {
556
+ return false;
557
+ }
558
+ // Compare state
559
+ if (this._state === undefined && other._state === undefined) {
560
+ // Both undefined, equal
561
+ } else if (this._state !== undefined && other._state !== undefined) {
562
+ if (!this._state.digest().equals(other._state.digest())) {
563
+ return false;
564
+ }
565
+ } else {
566
+ return false;
567
+ }
568
+ // Compare peer continuation
569
+ if (this._peerContinuation === undefined && other._peerContinuation === undefined) {
570
+ // Both undefined, equal
571
+ } else if (this._peerContinuation !== undefined && other._peerContinuation !== undefined) {
572
+ if (!this._peerContinuation.digest().equals(other._peerContinuation.digest())) {
573
+ return false;
574
+ }
575
+ } else {
576
+ return false;
577
+ }
578
+ return true;
579
+ }
580
+ }