@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,507 @@
1
+ /**
2
+ * SealedResponse - Sealed response messages for GSTP
3
+ *
4
+ * A SealedResponse wraps a Response with sender information and state
5
+ * continuations for secure, authenticated response messages.
6
+ *
7
+ * Ported from gstp-rust/src/sealed_response.rs
8
+ */
9
+
10
+ import type { ARID, PrivateKeys, Signer } from "@bcts/components";
11
+ import { Envelope, Response, type EnvelopeEncodableValue } from "@bcts/envelope";
12
+ import { SENDER, SENDER_CONTINUATION, RECIPIENT_CONTINUATION } from "@bcts/known-values";
13
+ import { XIDDocument } from "@bcts/xid";
14
+ import { Continuation } from "./continuation";
15
+ import { GstpError } from "./error";
16
+
17
+ /**
18
+ * Interface that defines the behavior of a sealed response.
19
+ *
20
+ * Extends ResponseBehavior with additional methods for managing
21
+ * sender information and state continuations.
22
+ */
23
+ export interface SealedResponseBehavior {
24
+ /**
25
+ * Adds state to the response that the peer may return at some future time.
26
+ */
27
+ withState(state: EnvelopeEncodableValue): SealedResponse;
28
+
29
+ /**
30
+ * Adds optional state to the response.
31
+ */
32
+ withOptionalState(state: EnvelopeEncodableValue | undefined): SealedResponse;
33
+
34
+ /**
35
+ * Adds a continuation previously received from the recipient.
36
+ */
37
+ withPeerContinuation(peerContinuation: Envelope | undefined): SealedResponse;
38
+
39
+ /**
40
+ * Returns the sender of the response.
41
+ */
42
+ sender(): XIDDocument;
43
+
44
+ /**
45
+ * Returns the state to be sent to the peer.
46
+ */
47
+ state(): Envelope | undefined;
48
+
49
+ /**
50
+ * Returns the continuation received from the peer.
51
+ */
52
+ peerContinuation(): Envelope | undefined;
53
+ }
54
+
55
+ /**
56
+ * A sealed response that combines a Response with sender information and
57
+ * state continuations for secure communication.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * import { SealedResponse, ARID } from '@bcts/gstp';
62
+ * import { XIDDocument } from '@bcts/xid';
63
+ *
64
+ * // Create sender XID document
65
+ * const sender = XIDDocument.new();
66
+ * const requestId = ARID.new();
67
+ *
68
+ * // Create a successful sealed response
69
+ * const response = SealedResponse.newSuccess(requestId, sender)
70
+ * .withResult("Operation completed")
71
+ * .withState("next-page-state");
72
+ *
73
+ * // Convert to sealed envelope
74
+ * const envelope = response.toEnvelope(
75
+ * new Date(Date.now() + 60000), // Valid for 60 seconds
76
+ * senderPrivateKey,
77
+ * recipientXIDDocument
78
+ * );
79
+ * ```
80
+ */
81
+ export class SealedResponse implements SealedResponseBehavior {
82
+ private _response: Response;
83
+ private readonly _sender: XIDDocument;
84
+ private _state: Envelope | undefined;
85
+ private _peerContinuation: Envelope | undefined;
86
+
87
+ private constructor(
88
+ response: Response,
89
+ sender: XIDDocument,
90
+ state?: Envelope,
91
+ peerContinuation?: Envelope,
92
+ ) {
93
+ this._response = response;
94
+ this._sender = sender;
95
+ this._state = state;
96
+ this._peerContinuation = peerContinuation;
97
+ }
98
+
99
+ // ============================================================================
100
+ // Static Factory Methods
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Creates a new successful sealed response.
105
+ *
106
+ * @param id - The request ID this response is for
107
+ * @param sender - The sender's XID document
108
+ */
109
+ static newSuccess(id: ARID, sender: XIDDocument): SealedResponse {
110
+ return new SealedResponse(Response.newSuccess(id), sender);
111
+ }
112
+
113
+ /**
114
+ * Creates a new failure sealed response.
115
+ *
116
+ * @param id - The request ID this response is for
117
+ * @param sender - The sender's XID document
118
+ */
119
+ static newFailure(id: ARID, sender: XIDDocument): SealedResponse {
120
+ return new SealedResponse(Response.newFailure(id), sender);
121
+ }
122
+
123
+ /**
124
+ * Creates a new early failure sealed response.
125
+ *
126
+ * An early failure takes place before the message has been decrypted,
127
+ * and therefore the ID and sender public key are not known.
128
+ *
129
+ * @param sender - The sender's XID document
130
+ */
131
+ static newEarlyFailure(sender: XIDDocument): SealedResponse {
132
+ return new SealedResponse(Response.newEarlyFailure(), sender);
133
+ }
134
+
135
+ // ============================================================================
136
+ // SealedResponseBehavior implementation
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Adds state to the response that the peer may return at some future time.
141
+ *
142
+ * @throws Error if called on a failed response
143
+ */
144
+ withState(state: EnvelopeEncodableValue): SealedResponse {
145
+ if (!this._response.isOk()) {
146
+ throw new Error("Cannot set state on a failed response");
147
+ }
148
+ this._state = Envelope.new(state);
149
+ return this;
150
+ }
151
+
152
+ /**
153
+ * Adds optional state to the response.
154
+ */
155
+ withOptionalState(state: EnvelopeEncodableValue | undefined): SealedResponse {
156
+ if (state !== undefined) {
157
+ return this.withState(state);
158
+ }
159
+ this._state = undefined;
160
+ return this;
161
+ }
162
+
163
+ /**
164
+ * Adds a continuation previously received from the recipient.
165
+ */
166
+ withPeerContinuation(peerContinuation: Envelope | undefined): SealedResponse {
167
+ this._peerContinuation = peerContinuation;
168
+ return this;
169
+ }
170
+
171
+ /**
172
+ * Returns the sender of the response.
173
+ */
174
+ sender(): XIDDocument {
175
+ return this._sender;
176
+ }
177
+
178
+ /**
179
+ * Returns the state to be sent to the peer.
180
+ */
181
+ state(): Envelope | undefined {
182
+ return this._state;
183
+ }
184
+
185
+ /**
186
+ * Returns the continuation received from the peer.
187
+ */
188
+ peerContinuation(): Envelope | undefined {
189
+ return this._peerContinuation;
190
+ }
191
+
192
+ // ============================================================================
193
+ // ResponseBehavior implementation
194
+ // ============================================================================
195
+
196
+ /**
197
+ * Sets the result value for a successful response.
198
+ */
199
+ withResult(result: EnvelopeEncodableValue): SealedResponse {
200
+ this._response = this._response.withResult(result);
201
+ return this;
202
+ }
203
+
204
+ /**
205
+ * Sets an optional result value for a successful response.
206
+ * If the result is undefined, the value of the response will be the null envelope.
207
+ */
208
+ withOptionalResult(result: EnvelopeEncodableValue | undefined): SealedResponse {
209
+ this._response = this._response.withOptionalResult(result);
210
+ return this;
211
+ }
212
+
213
+ /**
214
+ * Sets the error value for a failure response.
215
+ */
216
+ withError(error: EnvelopeEncodableValue): SealedResponse {
217
+ this._response = this._response.withError(error);
218
+ return this;
219
+ }
220
+
221
+ /**
222
+ * Sets an optional error value for a failure response.
223
+ * If the error is undefined, the value of the response will be the unknown value.
224
+ */
225
+ withOptionalError(error: EnvelopeEncodableValue | undefined): SealedResponse {
226
+ this._response = this._response.withOptionalError(error);
227
+ return this;
228
+ }
229
+
230
+ /**
231
+ * Returns true if this is a successful response.
232
+ */
233
+ isOk(): boolean {
234
+ return this._response.isOk();
235
+ }
236
+
237
+ /**
238
+ * Returns true if this is a failure response.
239
+ */
240
+ isErr(): boolean {
241
+ return this._response.isErr();
242
+ }
243
+
244
+ /**
245
+ * Returns the ID of the request this response is for, if known.
246
+ */
247
+ id(): ARID | undefined {
248
+ return this._response.id();
249
+ }
250
+
251
+ /**
252
+ * Returns the ID of the request this response is for.
253
+ * @throws Error if the ID is not known
254
+ */
255
+ expectId(): ARID {
256
+ return this._response.expectId();
257
+ }
258
+
259
+ /**
260
+ * Returns the result envelope if this is a successful response.
261
+ * @throws Error if this is a failure response
262
+ */
263
+ result(): Envelope {
264
+ return this._response.result();
265
+ }
266
+
267
+ /**
268
+ * Extracts the result as a specific type.
269
+ */
270
+ extractResult<T>(decoder: (cbor: unknown) => T): T {
271
+ return this._response.extractResult(decoder);
272
+ }
273
+
274
+ /**
275
+ * Returns the error envelope if this is a failure response.
276
+ * @throws Error if this is a successful response
277
+ */
278
+ error(): Envelope {
279
+ return this._response.error();
280
+ }
281
+
282
+ /**
283
+ * Extracts the error as a specific type.
284
+ */
285
+ extractError<T>(decoder: (cbor: unknown) => T): T {
286
+ return this._response.extractError(decoder);
287
+ }
288
+
289
+ // ============================================================================
290
+ // Envelope methods
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Creates an envelope that can be decrypted by zero or one recipient.
295
+ *
296
+ * @param validUntil - Optional expiration date for the continuation
297
+ * @param signer - Optional signer for the envelope
298
+ * @param recipient - Optional recipient XID document for encryption
299
+ * @returns The sealed response as an envelope
300
+ */
301
+ toEnvelope(validUntil?: Date, signer?: Signer, recipient?: XIDDocument): Envelope {
302
+ const recipients: XIDDocument[] = recipient !== undefined ? [recipient] : [];
303
+ return this.toEnvelopeForRecipients(validUntil, signer, recipients);
304
+ }
305
+
306
+ /**
307
+ * Creates an envelope that can be decrypted by zero or more recipients.
308
+ *
309
+ * @param validUntil - Optional expiration date for the continuation
310
+ * @param signer - Optional signer for the envelope
311
+ * @param recipients - Array of recipient XID documents for encryption
312
+ * @returns The sealed response as an envelope
313
+ */
314
+ toEnvelopeForRecipients(
315
+ validUntil?: Date,
316
+ signer?: Signer,
317
+ recipients?: XIDDocument[],
318
+ ): Envelope {
319
+ // Build sender continuation only if state is present
320
+ let senderContinuation: Envelope | undefined;
321
+ if (this._state !== undefined) {
322
+ const continuation = new Continuation(this._state, undefined, validUntil);
323
+
324
+ // Get sender's encryption key (from inception key)
325
+ const senderInceptionKey = this._sender.inceptionKey();
326
+ const senderEncryptionKey = senderInceptionKey?.publicKeys()?.encapsulationPublicKey();
327
+ if (senderEncryptionKey === undefined) {
328
+ throw GstpError.senderMissingEncryptionKey();
329
+ }
330
+
331
+ senderContinuation = continuation.toEnvelope(senderEncryptionKey);
332
+ }
333
+
334
+ // Build the response envelope
335
+ let result = this._response.toEnvelope();
336
+
337
+ // Add sender assertion
338
+ result = result.addAssertion(SENDER, this._sender.toEnvelope());
339
+
340
+ // Add sender continuation if present
341
+ if (senderContinuation !== undefined) {
342
+ result = result.addAssertion(SENDER_CONTINUATION, senderContinuation);
343
+ }
344
+
345
+ // Add peer continuation if present
346
+ if (this._peerContinuation !== undefined) {
347
+ result = result.addAssertion(RECIPIENT_CONTINUATION, this._peerContinuation);
348
+ }
349
+
350
+ // Sign if signer provided (sign() wraps first, then adds signature)
351
+ if (signer !== undefined) {
352
+ result = result.sign(signer);
353
+ }
354
+
355
+ // Encrypt to recipients if provided
356
+ if (recipients !== undefined && recipients.length > 0) {
357
+ const recipientKeys = recipients.map((recipient) => {
358
+ const key = recipient.encryptionKey();
359
+ if (key === undefined) {
360
+ throw GstpError.recipientMissingEncryptionKey();
361
+ }
362
+ return key;
363
+ });
364
+
365
+ result = result.wrap().encryptSubjectToRecipients(recipientKeys);
366
+ }
367
+
368
+ return result;
369
+ }
370
+
371
+ /**
372
+ * Parses a sealed response from an encrypted envelope.
373
+ *
374
+ * @param encryptedEnvelope - The encrypted envelope to parse
375
+ * @param expectedId - Optional expected request ID for validation
376
+ * @param now - Optional current time for continuation validation
377
+ * @param recipientPrivateKey - The recipient's private keys for decryption
378
+ * @returns The parsed sealed response
379
+ */
380
+ static tryFromEncryptedEnvelope(
381
+ encryptedEnvelope: Envelope,
382
+ expectedId: ARID | undefined,
383
+ now: Date | undefined,
384
+ recipientPrivateKey: PrivateKeys,
385
+ ): SealedResponse {
386
+ // Decrypt the envelope
387
+ let signedEnvelope: Envelope;
388
+ try {
389
+ signedEnvelope = encryptedEnvelope.decryptToRecipient(recipientPrivateKey);
390
+ } catch (e) {
391
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
392
+ }
393
+
394
+ // Extract sender from the unwrapped envelope
395
+ let sender: XIDDocument;
396
+ try {
397
+ const unwrapped = signedEnvelope.tryUnwrap();
398
+ const senderEnvelope = unwrapped.objectForPredicate(SENDER);
399
+ if (senderEnvelope === undefined) {
400
+ throw new Error("Missing sender");
401
+ }
402
+ sender = XIDDocument.fromEnvelope(senderEnvelope);
403
+ } catch (e) {
404
+ throw GstpError.xid(e instanceof Error ? e : new Error(String(e)));
405
+ }
406
+
407
+ // Get sender's verification key and verify signature (from inception key)
408
+ const senderInceptionKey = sender.inceptionKey();
409
+ const senderVerificationKey = senderInceptionKey?.publicKeys()?.signingPublicKey();
410
+ if (senderVerificationKey === undefined) {
411
+ throw GstpError.senderMissingVerificationKey();
412
+ }
413
+
414
+ let responseEnvelope: Envelope;
415
+ try {
416
+ // verify() both verifies the signature AND unwraps the envelope
417
+ responseEnvelope = signedEnvelope.verify(senderVerificationKey);
418
+ } catch (e) {
419
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
420
+ }
421
+
422
+ // Get peer continuation (sender_continuation from the response)
423
+ const peerContinuation = responseEnvelope.optionalObjectForPredicate(SENDER_CONTINUATION);
424
+ if (peerContinuation !== undefined) {
425
+ // Verify peer continuation is encrypted
426
+ if (!peerContinuation.subject().isEncrypted()) {
427
+ throw GstpError.peerContinuationNotEncrypted();
428
+ }
429
+ }
430
+
431
+ // Get and decrypt our continuation (recipient_continuation)
432
+ const encryptedContinuation =
433
+ responseEnvelope.optionalObjectForPredicate(RECIPIENT_CONTINUATION);
434
+ let state: Envelope | undefined;
435
+ if (encryptedContinuation !== undefined) {
436
+ const continuation = Continuation.tryFromEnvelope(
437
+ encryptedContinuation,
438
+ expectedId,
439
+ now,
440
+ recipientPrivateKey,
441
+ );
442
+ // Check if state is null
443
+ const stateEnv = continuation.state();
444
+ if (stateEnv.isNull()) {
445
+ state = undefined;
446
+ } else {
447
+ state = stateEnv;
448
+ }
449
+ }
450
+
451
+ // Parse the response
452
+ let response: Response;
453
+ try {
454
+ response = Response.fromEnvelope(responseEnvelope);
455
+ } catch (e) {
456
+ throw GstpError.envelope(e instanceof Error ? e : new Error(String(e)));
457
+ }
458
+
459
+ return new SealedResponse(response, sender, state, peerContinuation);
460
+ }
461
+
462
+ // ============================================================================
463
+ // Display methods
464
+ // ============================================================================
465
+
466
+ /**
467
+ * Returns a string representation of the sealed response.
468
+ */
469
+ toString(): string {
470
+ const stateStr = this._state !== undefined ? this._state.formatFlat() : "None";
471
+ const peerStr = this._peerContinuation !== undefined ? "Some" : "None";
472
+ return `SealedResponse(${this._response.summary()}, state: ${stateStr}, peer_continuation: ${peerStr})`;
473
+ }
474
+
475
+ /**
476
+ * Checks equality with another sealed response.
477
+ */
478
+ equals(other: SealedResponse): boolean {
479
+ if (!this._response.equals(other._response)) {
480
+ return false;
481
+ }
482
+ if (!this._sender.xid().equals(other._sender.xid())) {
483
+ return false;
484
+ }
485
+ // Compare state
486
+ if (this._state === undefined && other._state === undefined) {
487
+ // Both undefined, equal
488
+ } else if (this._state !== undefined && other._state !== undefined) {
489
+ if (!this._state.digest().equals(other._state.digest())) {
490
+ return false;
491
+ }
492
+ } else {
493
+ return false;
494
+ }
495
+ // Compare peer continuation
496
+ if (this._peerContinuation === undefined && other._peerContinuation === undefined) {
497
+ // Both undefined, equal
498
+ } else if (this._peerContinuation !== undefined && other._peerContinuation !== undefined) {
499
+ if (!this._peerContinuation.digest().equals(other._peerContinuation.digest())) {
500
+ return false;
501
+ }
502
+ } else {
503
+ return false;
504
+ }
505
+ return true;
506
+ }
507
+ }