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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|