opencode-session-port 0.0.0-stage → 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/package.json +56 -4
  4. package/src/core/contracts/crypto.ts +66 -0
  5. package/src/core/contracts/index.ts +13 -0
  6. package/src/core/contracts/json.ts +142 -0
  7. package/src/core/contracts/session.ts +12 -0
  8. package/src/core/crypto/container.ts +686 -0
  9. package/src/core/crypto/envelope.ts +232 -0
  10. package/src/core/crypto/errors.ts +52 -0
  11. package/src/core/crypto/gzip-stream.ts +388 -0
  12. package/src/core/crypto/index.ts +13 -0
  13. package/src/core/crypto/kdf.ts +101 -0
  14. package/src/core/crypto/policy.ts +202 -0
  15. package/src/core/crypto/zero.ts +33 -0
  16. package/src/core/restore/identity.ts +202 -0
  17. package/src/core/restore/index.ts +24 -0
  18. package/src/core/restore/planner.ts +1122 -0
  19. package/src/core/sessions/collector.ts +323 -0
  20. package/src/core/sessions/errors.ts +37 -0
  21. package/src/core/sessions/guards.ts +11 -0
  22. package/src/core/sessions/index.ts +29 -0
  23. package/src/core/sessions/policy.ts +80 -0
  24. package/src/core/sessions/port.ts +88 -0
  25. package/src/core/sessions/serialize.ts +15 -0
  26. package/src/core/sessions/unsettled.ts +35 -0
  27. package/src/integrations/omo/index.ts +28 -0
  28. package/src/tui/adapters/host-client.ts +254 -0
  29. package/src/tui/adapters/index.ts +72 -0
  30. package/src/tui/adapters/location-resolver.ts +281 -0
  31. package/src/tui/adapters/project-destinations.ts +271 -0
  32. package/src/tui/adapters/session-id-inventory.ts +378 -0
  33. package/src/tui/adapters/session-import.ts +189 -0
  34. package/src/tui/adapters/session-read.ts +115 -0
  35. package/src/tui/artifacts/files.ts +285 -0
  36. package/src/tui/orchestration/checkpoint.ts +160 -0
  37. package/src/tui/orchestration/index.ts +55 -0
  38. package/src/tui/orchestration/restore-auth.ts +94 -0
  39. package/src/tui/orchestration/restore-execution.ts +381 -0
  40. package/src/tui/orchestration/restore-preflight.ts +223 -0
  41. package/src/tui/orchestration/shared.ts +75 -0
  42. package/src/tui/ui/password-dialog.tsx +251 -0
  43. package/src/tui/ui/password-flow.ts +288 -0
  44. package/src/tui/ui/restore-project-selector.ts +62 -0
  45. package/src/tui/ui/restore-report-dialog.tsx +251 -0
  46. package/src/tui/ui/restore-report-flow.ts +94 -0
  47. package/src/tui/ui/sessionport-commands.tsx +65 -0
  48. package/src/tui/wiring/composition.ts +354 -0
  49. package/src/tui/wiring/experimental-profile.ts +86 -0
  50. package/src/tui/wiring/index.ts +51 -0
  51. package/src/tui/wiring/install.ts +108 -0
  52. package/src/tui/wiring/reports.ts +340 -0
  53. package/src/tui.ts +34 -0
  54. package/tsconfig.json +18 -0
  55. package/tui.ts +12 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * PROVISIONAL SessionPort encrypted container.
3
+ *
4
+ * Scope and boundaries:
5
+ * - Factory construction requires caller-supplied, allow-listed policy
6
+ * definitions; there is NO default policy and no fallback. The artifact can
7
+ * only reference a one-byte profile code that must resolve through that
8
+ * allow-list, and resolution/bounds checks happen before any KDF work.
9
+ * - `seal` streams gzip-compressed plaintext through XChaCha20-Poly1305
10
+ * secretstream frames; `openAndStage` consumes the whole artifact and only
11
+ * returns an `AuthenticatedPayload` after every chunk, the required final
12
+ * tag, clean gzip termination, and all policy caps pass. No partial
13
+ * plaintext stream/callback/file/temp artifact is exposed, and failures
14
+ * return nothing.
15
+ * - `seal` enforces the same byte accounting as restore before it can emit
16
+ * the final-tagged frame: source plaintext bytes against
17
+ * `maxDecompressedBytes` (incrementally, aborting compression early),
18
+ * compressed gzip output bytes against `maxCompressedBytes`, and the final
19
+ * decompressed/compressed gzip ratio against `maxExpansionRatio`. A
20
+ * violation rejects with no final tag, so no valid artifact is ever
21
+ * produced for a payload that the same policy could not open.
22
+ * - O-22: `seal` generates one fresh random 128-bit artifact identity per
23
+ * artifact with pinned `randombytes_buf(16)`; the provisional header carries
24
+ * it at offset 32 (fixed header length 48), and the complete header is bound
25
+ * into every frame AAD, so identity tampering fails authentication.
26
+ * `openAndStage` decodes the identity with the header but surfaces it to the
27
+ * caller only after full authentication, strict gzip end-of-input, and every
28
+ * bound check have passed. The identity is public artifact data and must
29
+ * never be reused as the Argon2 salt. Its placement remains PROVISIONAL
30
+ * (O-02): this is not a format freeze or a backward-compatibility approval.
31
+ * - The envelope/framing is PROVISIONAL (O-02) and isolated in
32
+ * `envelope.ts`; do not freeze or publish artifacts from it yet.
33
+ * - `seal` is streaming: if it fails partway (e.g., a mid-stream artifact,
34
+ * compressed, or decompressed cap violation or source/zlib error), earlier
35
+ * ciphertext chunks may already have been yielded, but the final-tagged
36
+ * frame is never emitted. This module never writes files, and callers must
37
+ * discard any partial output on error so no partial artifact is kept.
38
+ * - Module-owned plaintext copies (seal-side frame chunks — including frame
39
+ * copies split from one compressed chunk that are never encrypted because a
40
+ * later frame fails or the consumer cancels — decrypt-side
41
+ * `secretstream_pull` messages after the consumer advances, collected gzip
42
+ * output, and the gzip inflater scratch buffer) are best-effort cleared on
43
+ * success AND failure paths; this is not secure deletion (A-07). The
44
+ * strict gzip decoder consumes the borrowed decrypted chunks in place and
45
+ * never retains or copies the compressed input.
46
+ * - `decryptFrames` yields module-owned `secretstream_pull` plaintext under the
47
+ * shared borrowed-chunk rules: a chunk is valid while the consumer processes
48
+ * it and is best-effort cleared as soon as the consumer requests the next
49
+ * chunk, or in `finally` on error/early exit. A successfully pulled message
50
+ * is tracked before tag validation, so a message rejected for an unexpected
51
+ * tag is cleared even though it is never yielded. Consumers must copy a
52
+ * chunk before advancing the iterator (the internal gunzip feeder already
53
+ * does).
54
+ *
55
+ * Known follow-ups:
56
+ * - `crypto_pwhash` is synchronous and may block (see `kdf.ts`). The
57
+ * Promise-based API does not promise a nonblocking KDF; execution strategy
58
+ * is not wired to the TUI in this phase.
59
+ * - `openAndStage` resolves the artifact profile through the whole factory
60
+ * allow-list. Whether an instance should additionally be restricted to the
61
+ * policy selected at `create` time is an OPEN question for Oracle Gate 2.
62
+ * - secretstream state tokens are released by dropping references; pinned
63
+ * 0.8.4 has no documented generic free helper. Only module-owned byte
64
+ * buffers are best-effort zeroed; no secure-delete claim (A-07).
65
+ */
66
+ import sodium from "libsodium-wrappers-sumo";
67
+ import { ARTIFACT_IDENTITY_BYTES } from "../contracts/crypto.ts";
68
+ import type {
69
+ AuthenticatedPayload,
70
+ ByteSource,
71
+ CheckpointCrypto,
72
+ CheckpointCryptoFactory,
73
+ CryptoPolicy,
74
+ PasswordMaterial,
75
+ } from "../contracts/crypto.ts";
76
+ import {
77
+ buildFrameAad,
78
+ decodeEnvelopeHeader,
79
+ decodeFrameLength,
80
+ ENVELOPE_FRAME_OVERHEAD_BYTES,
81
+ ENVELOPE_HEADER_BYTES,
82
+ ENVELOPE_SECRETSTREAM_HEADER_BYTES,
83
+ encodeEnvelopeHeader,
84
+ encodeFrame,
85
+ FRAME_LENGTH_BYTES,
86
+ FRAME_MAX_CIPHERTEXT_BYTES,
87
+ } from "./envelope.ts";
88
+ import { CryptoContainerError } from "./errors.ts";
89
+ import { collectGunzipBounded, compressStream } from "./gzip-stream.ts";
90
+ import {
91
+ type PasswordKeyDerivation,
92
+ requireSupportedSodiumRuntime,
93
+ sodiumArgon2idDerivation,
94
+ } from "./kdf.ts";
95
+ import {
96
+ buildCryptoPolicyRegistry,
97
+ type CryptoPolicyDefinition,
98
+ type CryptoPolicyRegistry,
99
+ resolvePolicyById,
100
+ resolvePolicyByProfileCode,
101
+ } from "./policy.ts";
102
+ import { bestEffortZero } from "./zero.ts";
103
+
104
+ /** Secretstream state shapes, derived structurally from the pinned typings. */
105
+ type PushState = Parameters<typeof sodium.crypto_secretstream_xchacha20poly1305_push>[0];
106
+ /** @internal Exported only so the crypto tests can drive `decryptFrames` directly. */
107
+ export type PullState = Parameters<typeof sodium.crypto_secretstream_xchacha20poly1305_pull>[0];
108
+
109
+ /**
110
+ * Internal/test-only seams. NOT part of the public crypto contract and not
111
+ * for TUI/plugin use. They exist so tests can observe that allow-list
112
+ * resolution and bounds checks happen before any KDF work.
113
+ */
114
+ export interface CryptoFactoryTestSeams {
115
+ readonly derivation?: PasswordKeyDerivation;
116
+ }
117
+
118
+ /**
119
+ * Builds a fail-closed factory over an explicit allow-list of policy
120
+ * definitions. An empty allow-list is valid and rejects every `create`.
121
+ */
122
+ export async function createCheckpointCryptoFactory(
123
+ definitions: readonly CryptoPolicyDefinition[],
124
+ seams?: CryptoFactoryTestSeams,
125
+ ): Promise<CheckpointCryptoFactory> {
126
+ await requireSupportedSodiumRuntime();
127
+ const registry = buildCryptoPolicyRegistry(definitions, {
128
+ opsLimitMin: sodium.crypto_pwhash_OPSLIMIT_MIN,
129
+ memLimitMin: sodium.crypto_pwhash_MEMLIMIT_MIN,
130
+ ciphertextOverheadBytes: sodium.crypto_secretstream_xchacha20poly1305_ABYTES,
131
+ });
132
+ const derivation = seams?.derivation ?? sodiumArgon2idDerivation;
133
+ return {
134
+ async create(policy: CryptoPolicy): Promise<CheckpointCrypto> {
135
+ // Unknown/absent policy ids fail closed here, before any crypto work.
136
+ resolvePolicyById(registry, policy);
137
+ return {
138
+ seal: (plaintext, password) => seal(registry, derivation, plaintext, password, policy),
139
+ openAndStage: (artifact, password) => openAndStage(registry, derivation, artifact, password),
140
+ };
141
+ },
142
+ };
143
+ }
144
+
145
+ function validatePassword(definition: CryptoPolicyDefinition, password: PasswordMaterial): void {
146
+ if (!(password instanceof Uint8Array) || password.length === 0) {
147
+ throw new CryptoContainerError("invalid-password", "password material must be a non-empty byte array");
148
+ }
149
+ if (password.length > definition.limits.maxPasswordBytes) {
150
+ throw new CryptoContainerError("invalid-password", "password material exceeds the policy limit");
151
+ }
152
+ }
153
+
154
+ async function seal(
155
+ registry: CryptoPolicyRegistry,
156
+ derivation: PasswordKeyDerivation,
157
+ plaintext: ByteSource,
158
+ password: PasswordMaterial,
159
+ policy: CryptoPolicy,
160
+ ): Promise<ByteSource> {
161
+ await requireSupportedSodiumRuntime();
162
+ // Re-resolve (cheap) so `seal` never uses a stale definition if the
163
+ // registry were ever swapped; the selected policy is mandatory.
164
+ const definition = resolvePolicyById(registry, policy);
165
+ validatePassword(definition, password);
166
+
167
+ const minimumArtifactBytes =
168
+ ENVELOPE_HEADER_BYTES +
169
+ ENVELOPE_SECRETSTREAM_HEADER_BYTES +
170
+ FRAME_LENGTH_BYTES +
171
+ ENVELOPE_FRAME_OVERHEAD_BYTES;
172
+ if (definition.limits.maxArtifactBytes < minimumArtifactBytes) {
173
+ throw new CryptoContainerError("artifact-too-large", "policy artifact cap cannot hold even one envelope frame");
174
+ }
175
+
176
+ const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
177
+ // O-22: one fresh random 128-bit identity per artifact, generated with the
178
+ // pinned runtime and authenticated via the header AAD. Public artifact
179
+ // data — deliberately independent of the Argon2 salt.
180
+ const artifactIdentity = sodium.randombytes_buf(ARTIFACT_IDENTITY_BYTES);
181
+ let key: Uint8Array | undefined;
182
+ try {
183
+ key = derivation.deriveKey(definition, password, salt);
184
+ if (!(key instanceof Uint8Array) || key.length !== sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES) {
185
+ throw new CryptoContainerError("cipher-failure", "key derivation returned an unexpected key size");
186
+ }
187
+ const initiated = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
188
+ const envelopeHeader = encodeEnvelopeHeader({
189
+ profileCode: definition.profileCode,
190
+ salt,
191
+ artifactIdentity,
192
+ });
193
+ return sealArtifactStream(definition, initiated.state, envelopeHeader, initiated.header, plaintext);
194
+ } finally {
195
+ if (key !== undefined) bestEffortZero(key);
196
+ bestEffortZero(salt);
197
+ }
198
+ }
199
+
200
+ /** Buffers bytes into fixed-size plaintext chunks owned by this module. */
201
+ class PlainChunkBuffer {
202
+ readonly #chunkBytes: number;
203
+ #buffer: Uint8Array;
204
+ #length = 0;
205
+
206
+ constructor(chunkBytes: number) {
207
+ this.#chunkBytes = chunkBytes;
208
+ this.#buffer = new Uint8Array(chunkBytes);
209
+ }
210
+
211
+ push(chunk: Uint8Array): Uint8Array[] {
212
+ const out: Uint8Array[] = [];
213
+ let offset = 0;
214
+ while (offset < chunk.length) {
215
+ const take = Math.min(this.#chunkBytes - this.#length, chunk.length - offset);
216
+ this.#buffer.set(chunk.subarray(offset, offset + take), this.#length);
217
+ this.#length += take;
218
+ offset += take;
219
+ if (this.#length === this.#chunkBytes) {
220
+ out.push(this.#buffer.slice());
221
+ this.#length = 0;
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** Returns the remaining buffered bytes as a fresh copy. */
228
+ takeRemainder(): Uint8Array {
229
+ const remainder = this.#buffer.slice(0, this.#length);
230
+ this.#length = 0;
231
+ return remainder;
232
+ }
233
+
234
+ /** Best-effort clears the internal plaintext buffer. */
235
+ clear(): void {
236
+ bestEffortZero(this.#buffer);
237
+ this.#length = 0;
238
+ }
239
+ }
240
+
241
+ interface EncryptedFrame {
242
+ readonly frame: Uint8Array;
243
+ readonly ciphertextBytes: number;
244
+ }
245
+
246
+ function encryptFrame(
247
+ state: PushState,
248
+ plaintextChunk: Uint8Array,
249
+ envelopeHeader: Uint8Array,
250
+ sequence: number,
251
+ tag: number,
252
+ ): EncryptedFrame {
253
+ const ciphertextBytes = plaintextChunk.length + sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
254
+ if (ciphertextBytes > FRAME_MAX_CIPHERTEXT_BYTES) {
255
+ throw new CryptoContainerError("cipher-failure", "plaintext chunk exceeds the framing range");
256
+ }
257
+ const aad = buildFrameAad(envelopeHeader, sequence, ciphertextBytes);
258
+ const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(state, plaintextChunk, aad, tag);
259
+ if (!(ciphertext instanceof Uint8Array) || ciphertext.length !== ciphertextBytes) {
260
+ throw new CryptoContainerError("cipher-failure", "secretstream push produced an unexpected ciphertext size");
261
+ }
262
+ return { frame: encodeFrame(ciphertext), ciphertextBytes };
263
+ }
264
+
265
+ /**
266
+ * Encrypts one module-owned plaintext frame copy and best-effort clears that
267
+ * copy immediately after encryption succeeds — and also when push/encoding
268
+ * throws. The copy is therefore never held across a `yield` or after an
269
+ * error.
270
+ */
271
+ function encryptFrameAndClearPlaintext(
272
+ state: PushState,
273
+ plaintextChunk: Uint8Array,
274
+ envelopeHeader: Uint8Array,
275
+ sequence: number,
276
+ tag: number,
277
+ ): EncryptedFrame {
278
+ try {
279
+ return encryptFrame(state, plaintextChunk, envelopeHeader, sequence, tag);
280
+ } finally {
281
+ bestEffortZero(plaintextChunk);
282
+ }
283
+ }
284
+
285
+ async function* sealArtifactStream(
286
+ definition: CryptoPolicyDefinition,
287
+ pushState: PushState,
288
+ envelopeHeader: Uint8Array,
289
+ secretstreamHeader: Uint8Array,
290
+ plaintext: ByteSource,
291
+ ): AsyncGenerator<Uint8Array> {
292
+ const maxArtifactBytes = definition.limits.maxArtifactBytes;
293
+ const maxCompressedBytes = definition.limits.maxCompressedBytes;
294
+ const maxDecompressedBytes = definition.limits.maxDecompressedBytes;
295
+ const maxExpansionRatio = definition.limits.maxExpansionRatio;
296
+ const tagMessage = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
297
+ const tagFinal = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
298
+
299
+ let written = envelopeHeader.length + secretstreamHeader.length;
300
+ if (written > maxArtifactBytes) {
301
+ throw new CryptoContainerError("artifact-too-large", "artifact exceeds the policy size limit");
302
+ }
303
+ yield envelopeHeader;
304
+ yield secretstreamHeader;
305
+
306
+ // Count source plaintext bytes as they stream into gzip so the
307
+ // decompressed cap is enforced incrementally: an over-limit source aborts
308
+ // compression early and can never reach the final-tagged frame below.
309
+ let plaintextBytes = 0;
310
+ const countedPlaintext: ByteSource = (async function* () {
311
+ for await (const chunk of plaintext) {
312
+ if (!(chunk instanceof Uint8Array)) {
313
+ throw new CryptoContainerError("invalid-artifact-source", "plaintext source yielded a non-byte chunk");
314
+ }
315
+ plaintextBytes += chunk.length;
316
+ if (plaintextBytes > maxDecompressedBytes) {
317
+ throw new CryptoContainerError(
318
+ "decompressed-limit-exceeded",
319
+ "source plaintext exceeds the policy decompressed limit",
320
+ );
321
+ }
322
+ yield chunk;
323
+ }
324
+ })();
325
+
326
+ const chunks = new PlainChunkBuffer(definition.framing.chunkBytes);
327
+ let sequence = 0;
328
+ let compressedBytes = 0;
329
+ try {
330
+ try {
331
+ for await (const gzipChunk of compressStream(countedPlaintext, definition.compression.level)) {
332
+ try {
333
+ // Count compressed output before buffering it: the compressed
334
+ // cap is enforced incrementally and no over-limit chunk is
335
+ // ever buffered or encrypted.
336
+ compressedBytes += gzipChunk.length;
337
+ if (compressedBytes > maxCompressedBytes) {
338
+ throw new CryptoContainerError(
339
+ "compressed-limit-exceeded",
340
+ "compressed gzip output exceeds the policy limit",
341
+ );
342
+ }
343
+ // `chunks.push` may split one compressed chunk into several
344
+ // module-owned plaintext copies. Every emitted copy is tracked:
345
+ // `encryptFrameAndClearPlaintext` clears the one being
346
+ // encrypted immediately, and the `finally` below clears every
347
+ // copy that was never processed when the batch is abandoned —
348
+ // push/framing failure, artifact-cap failure, or consumer
349
+ // cancellation at the `yield`.
350
+ const batch = chunks.push(gzipChunk);
351
+ let next = 0;
352
+ try {
353
+ while (next < batch.length) {
354
+ const plaintextChunk = batch[next]!;
355
+ const encrypted = encryptFrameAndClearPlaintext(
356
+ pushState,
357
+ plaintextChunk,
358
+ envelopeHeader,
359
+ sequence,
360
+ tagMessage,
361
+ );
362
+ next += 1;
363
+ written += FRAME_LENGTH_BYTES + encrypted.ciphertextBytes;
364
+ if (written > maxArtifactBytes) {
365
+ throw new CryptoContainerError("artifact-too-large", "artifact exceeds the policy size limit");
366
+ }
367
+ sequence += 1;
368
+ yield encrypted.frame;
369
+ }
370
+ } finally {
371
+ // Clear every copy not yet handed to encryption. Copies
372
+ // already processed were cleared by the helper (clearing
373
+ // one of those again would be harmless but is not needed).
374
+ for (let index = next; index < batch.length; index += 1) {
375
+ bestEffortZero(batch[index]!);
376
+ }
377
+ }
378
+ } finally {
379
+ bestEffortZero(gzipChunk);
380
+ }
381
+ }
382
+ } catch (error) {
383
+ // If the source counter tripped, surface the deterministic
384
+ // decompressed-cap code no matter how the stream runtime wrapped
385
+ // the failure; otherwise preserve the original error (stream and
386
+ // cipher errors keep their existing behavior).
387
+ if (plaintextBytes > maxDecompressedBytes) {
388
+ throw new CryptoContainerError(
389
+ "decompressed-limit-exceeded",
390
+ "source plaintext exceeds the policy decompressed limit",
391
+ );
392
+ }
393
+ throw error;
394
+ }
395
+
396
+ // Fail-closed re-checks before the final-tagged frame is produced. The
397
+ // streaming checks above already cover the normal paths; these guarantee
398
+ // that no valid final-tagged artifact is ever emitted when any policy
399
+ // byte bound is exceeded, even if a stream runtime ever swallowed the
400
+ // mid-stream abort. The expansion ratio is only meaningful on the final
401
+ // totals (a running ratio is not monotonic), and it uses the same
402
+ // accounting as restore: decompressed plaintext bytes divided by the
403
+ // compressed gzip output bytes — never envelope bytes.
404
+ if (plaintextBytes > maxDecompressedBytes) {
405
+ throw new CryptoContainerError(
406
+ "decompressed-limit-exceeded",
407
+ "source plaintext exceeds the policy decompressed limit",
408
+ );
409
+ }
410
+ if (compressedBytes <= 0 || plaintextBytes / compressedBytes > maxExpansionRatio) {
411
+ throw new CryptoContainerError(
412
+ "expansion-limit-exceeded",
413
+ "decompressed/compressed byte ratio exceeds the policy expansion limit",
414
+ );
415
+ }
416
+
417
+ // Exactly one final-tagged frame terminates the artifact; it is empty
418
+ // when the gzip output ended exactly on a chunk boundary.
419
+ const remainder = chunks.takeRemainder();
420
+ const finalFrame = encryptFrameAndClearPlaintext(pushState, remainder, envelopeHeader, sequence, tagFinal);
421
+ written += FRAME_LENGTH_BYTES + finalFrame.ciphertextBytes;
422
+ if (written > maxArtifactBytes) {
423
+ throw new CryptoContainerError("artifact-too-large", "artifact exceeds the policy size limit");
424
+ }
425
+ yield finalFrame.frame;
426
+ } finally {
427
+ chunks.clear();
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Pull-based reader over a one-shot `ByteSource` with a consumed-bytes cap.
433
+ *
434
+ * @internal Exported only so the crypto tests can drive `decryptFrames`
435
+ * directly; NOT part of the public crypto contract (`index.ts`).
436
+ */
437
+ export class ArtifactReader {
438
+ readonly #iterator: AsyncIterator<Uint8Array>;
439
+ #chunk: Uint8Array | undefined;
440
+ #offset = 0;
441
+ #done = false;
442
+ #consumedBytes = 0;
443
+
444
+ constructor(source: ByteSource) {
445
+ const candidate: unknown = source;
446
+ if (
447
+ candidate === null ||
448
+ typeof candidate !== "object" ||
449
+ typeof (candidate as AsyncIterable<Uint8Array>)[Symbol.asyncIterator] !== "function"
450
+ ) {
451
+ throw new CryptoContainerError("invalid-artifact-source", "artifact must be an async iterable of byte chunks");
452
+ }
453
+ this.#iterator = (candidate as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]();
454
+ }
455
+
456
+ get consumedBytes(): number {
457
+ return this.#consumedBytes;
458
+ }
459
+
460
+ async #fill(): Promise<boolean> {
461
+ while (this.#chunk === undefined || this.#offset >= this.#chunk.length) {
462
+ if (this.#done) return false;
463
+ const step = await this.#iterator.next();
464
+ if (step.done === true) {
465
+ this.#done = true;
466
+ this.#chunk = undefined;
467
+ return false;
468
+ }
469
+ const value: unknown = step.value;
470
+ if (!(value instanceof Uint8Array)) {
471
+ throw new CryptoContainerError("invalid-artifact-source", "artifact source yielded a non-byte chunk");
472
+ }
473
+ if (value.length === 0) continue;
474
+ this.#chunk = value;
475
+ this.#offset = 0;
476
+ }
477
+ return true;
478
+ }
479
+
480
+ /**
481
+ * Reads exactly `byteCount` bytes. Returns `undefined` only on clean EOF at
482
+ * a field boundary; a partial field throws `truncated-artifact`. Enforces a
483
+ * running consumed-bytes cap.
484
+ */
485
+ async readExactly(byteCount: number, maxConsumedBytes: number): Promise<Uint8Array | undefined> {
486
+ if (!Number.isSafeInteger(byteCount) || byteCount < 0) {
487
+ throw new CryptoContainerError("invalid-frame", "invalid fixed-size read length");
488
+ }
489
+ const out = new Uint8Array(byteCount);
490
+ let filled = 0;
491
+ while (filled < byteCount) {
492
+ const available = await this.#fill();
493
+ if (!available) {
494
+ if (filled === 0) return undefined;
495
+ throw new CryptoContainerError("truncated-artifact", "artifact ended inside a fixed-size field");
496
+ }
497
+ const take = Math.min(byteCount - filled, this.#chunk!.length - this.#offset);
498
+ out.set(this.#chunk!.subarray(this.#offset, this.#offset + take), filled);
499
+ this.#offset += take;
500
+ filled += take;
501
+ this.#consumedBytes += take;
502
+ if (this.#consumedBytes > maxConsumedBytes) {
503
+ throw new CryptoContainerError("artifact-too-large", "artifact exceeds the policy size limit");
504
+ }
505
+ }
506
+ return out;
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Yields authenticated plaintext chunks in order. Enforces frame bounds,
512
+ * exact sequencing, the required final tag, and rejects trailing data after
513
+ * the final tag. Any failure destroys the consumer side with the original
514
+ * error so no partial output can be returned.
515
+ *
516
+ * Ownership: each yielded `pulled.message` is module-owned (a fresh buffer
517
+ * from the pinned wrapper). It follows the borrowed-chunk rules: it stays
518
+ * valid while the consumer processes/copies it and is best-effort zeroed as
519
+ * soon as the consumer requests the next chunk, and in `finally` when the
520
+ * iterator is closed early or fails. A successfully pulled message is tracked
521
+ * immediately after `pull` returns (before the tag branch that can throw), so
522
+ * an unexpected-tag rejection still clears that exact message in `finally`.
523
+ *
524
+ * @internal Exported only so the crypto tests can drive cancellation and
525
+ * error lifecycles directly; NOT part of the public crypto contract.
526
+ */
527
+ export async function* decryptFrames(
528
+ reader: ArtifactReader,
529
+ definition: CryptoPolicyDefinition,
530
+ pullState: PullState,
531
+ envelopeHeader: Uint8Array,
532
+ ): AsyncGenerator<Uint8Array> {
533
+ const abytes = sodium.crypto_secretstream_xchacha20poly1305_ABYTES;
534
+ const tagMessage = sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
535
+ const tagFinal = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
536
+ const maxCiphertextBytes = definition.framing.chunkBytes + abytes;
537
+ const maxArtifactBytes = definition.limits.maxArtifactBytes;
538
+
539
+ let sequence = 0;
540
+ let sawFinalTag = false;
541
+ // The most recently pulled plaintext buffer, recorded as soon as `pull`
542
+ // succeeds and before any tag branch that can throw; cleared on the next
543
+ // request (consumer has advanced), when an unexpected tag rejects it, or on
544
+ // early exit/error.
545
+ let handedOff: Uint8Array | undefined;
546
+
547
+ try {
548
+ while (true) {
549
+ if (handedOff !== undefined) {
550
+ // The consumer has advanced past the previous chunk; it is no
551
+ // longer allowed to read it, so clear it before any awaits.
552
+ bestEffortZero(handedOff);
553
+ handedOff = undefined;
554
+ }
555
+
556
+ const lengthPrefix = await reader.readExactly(FRAME_LENGTH_BYTES, maxArtifactBytes);
557
+ if (lengthPrefix === undefined) {
558
+ if (!sawFinalTag) {
559
+ throw new CryptoContainerError("missing-final-tag", "artifact ended without a final secretstream tag");
560
+ }
561
+ return;
562
+ }
563
+ if (sawFinalTag) {
564
+ throw new CryptoContainerError("trailing-data", "artifact contains bytes after the final tag");
565
+ }
566
+
567
+ const ciphertextBytes = decodeFrameLength(lengthPrefix);
568
+ if (ciphertextBytes < abytes || ciphertextBytes > maxCiphertextBytes) {
569
+ throw new CryptoContainerError("invalid-frame", "frame ciphertext length is outside the policy bounds");
570
+ }
571
+ const ciphertext = await reader.readExactly(ciphertextBytes, maxArtifactBytes);
572
+ if (ciphertext === undefined) {
573
+ throw new CryptoContainerError("truncated-artifact", "artifact ended inside a frame");
574
+ }
575
+
576
+ const aad = buildFrameAad(envelopeHeader, sequence, ciphertextBytes);
577
+ const pulled = sodium.crypto_secretstream_xchacha20poly1305_pull(pullState, ciphertext, aad);
578
+ if (pulled === false) {
579
+ throw new CryptoContainerError("authentication-failed", "secretstream chunk failed authentication");
580
+ }
581
+
582
+ // `pull` already succeeded, so `pulled.message` is module-owned
583
+ // plaintext: record it as tracked BEFORE either tag branch below,
584
+ // since the unexpected-tag branch can throw. Otherwise that throw
585
+ // would abort with the message untracked and `finally` could not
586
+ // clear it. Tracking does not change handoff semantics: the buffer
587
+ // is only yielded to the consumer below and is still cleared when
588
+ // the consumer advances (top of loop) or on early exit/error.
589
+ handedOff = pulled.message;
590
+
591
+ if (pulled.tag === tagFinal) {
592
+ sawFinalTag = true;
593
+ } else if (pulled.tag !== tagMessage) {
594
+ throw new CryptoContainerError("unexpected-tag", "unexpected secretstream tag");
595
+ }
596
+ sequence += 1;
597
+ yield handedOff;
598
+ }
599
+ } finally {
600
+ if (handedOff !== undefined) bestEffortZero(handedOff);
601
+ }
602
+ }
603
+
604
+ async function openAndStage(
605
+ registry: CryptoPolicyRegistry,
606
+ derivation: PasswordKeyDerivation,
607
+ artifact: ByteSource,
608
+ password: PasswordMaterial,
609
+ ): Promise<AuthenticatedPayload> {
610
+ await requireSupportedSodiumRuntime();
611
+ if (!(password instanceof Uint8Array) || password.length === 0) {
612
+ throw new CryptoContainerError("invalid-password", "password material must be a non-empty byte array");
613
+ }
614
+
615
+ const reader = new ArtifactReader(artifact);
616
+
617
+ // The header is fixed-size; read it under no policy cap yet, then resolve
618
+ // the profile through the caller-supplied allow-list BEFORE any KDF work.
619
+ const headerBytes = await reader.readExactly(ENVELOPE_HEADER_BYTES, Number.MAX_SAFE_INTEGER);
620
+ if (headerBytes === undefined) {
621
+ throw new CryptoContainerError("truncated-artifact", "artifact ended before the envelope header");
622
+ }
623
+ const header = decodeEnvelopeHeader(headerBytes);
624
+ const definition = resolvePolicyByProfileCode(registry, header.profileCode);
625
+
626
+ // All cheap bounds checks happen before the expensive KDF.
627
+ if (password.length > definition.limits.maxPasswordBytes) {
628
+ throw new CryptoContainerError("invalid-password", "password material exceeds the policy limit");
629
+ }
630
+ if (reader.consumedBytes > definition.limits.maxArtifactBytes) {
631
+ throw new CryptoContainerError("artifact-too-large", "artifact exceeds the policy size limit");
632
+ }
633
+ const secretstreamHeader = await reader.readExactly(
634
+ ENVELOPE_SECRETSTREAM_HEADER_BYTES,
635
+ definition.limits.maxArtifactBytes,
636
+ );
637
+ if (secretstreamHeader === undefined) {
638
+ throw new CryptoContainerError("truncated-artifact", "artifact ended before the secretstream header");
639
+ }
640
+
641
+ let pullState: PullState | undefined;
642
+ let key: Uint8Array | undefined;
643
+ try {
644
+ key = derivation.deriveKey(definition, password, header.salt);
645
+ if (!(key instanceof Uint8Array) || key.length !== sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES) {
646
+ throw new CryptoContainerError("cipher-failure", "key derivation returned an unexpected key size");
647
+ }
648
+ pullState = sodium.crypto_secretstream_xchacha20poly1305_init_pull(secretstreamHeader, key);
649
+ } finally {
650
+ if (key !== undefined) bestEffortZero(key);
651
+ bestEffortZero(header.salt);
652
+ }
653
+ if (pullState === undefined) {
654
+ throw new CryptoContainerError("cipher-failure", "secretstream pull state was not initialized");
655
+ }
656
+
657
+ const decryptedChunks = decryptFrames(reader, definition, pullState, headerBytes);
658
+ // Both caps are required policy fields: the distinct compressed-input cap
659
+ // is enforced while streaming the decrypted gzip bytes, and the
660
+ // decompressed cap during decode. Neither is derived from maxArtifactBytes.
661
+ const { bytes: payload, compressedBytes } = await collectGunzipBounded(
662
+ decryptedChunks,
663
+ definition.limits.maxCompressedBytes,
664
+ definition.limits.maxDecompressedBytes,
665
+ );
666
+
667
+ // The expansion ratio is decompressed payload bytes divided by the
668
+ // decrypted compressed gzip bytes — NOT by total envelope bytes (headers,
669
+ // frame prefixes, and AEAD tags must not dilute the bound). This is the
670
+ // same accounting the seal side applies to source plaintext over gzip
671
+ // output, so a same-policy seal/open pair agrees on the boundary. The
672
+ // decompressed cap above remains the primary guard.
673
+ if (compressedBytes <= 0 || payload.length / compressedBytes > definition.limits.maxExpansionRatio) {
674
+ bestEffortZero(payload);
675
+ throw new CryptoContainerError(
676
+ "expansion-limit-exceeded",
677
+ "decompressed payload exceeds the policy expansion ratio",
678
+ );
679
+ }
680
+
681
+ // `header.artifactIdentity` is a caller-owned copy decoded from the
682
+ // authenticated header. This is the only return path, and it is reached
683
+ // only after the full stream, the required final tag, strict gzip
684
+ // end-of-input, and every bound check have passed.
685
+ return { bytes: payload, artifactIdentity: header.artifactIdentity };
686
+ }