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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVISIONAL SessionPort artifact envelope (format assumptions only).
|
|
3
|
+
*
|
|
4
|
+
* ============================ NOT FROZEN ============================
|
|
5
|
+
* The exact artifact header layout, framing, AAD construction, and format
|
|
6
|
+
* versioning are OPEN (O-02) and this module is a minimal, isolated,
|
|
7
|
+
* provisional versioned envelope built only so the container can round-trip
|
|
8
|
+
* under tests. Do not treat these bytes as a published/frozen format, do not
|
|
9
|
+
* write compatibility claims against them, and resolve the format question
|
|
10
|
+
* with the owner/Oracle Gate 2 before any release.
|
|
11
|
+
* ===================================================================
|
|
12
|
+
*
|
|
13
|
+
* Every format-level assumption lives in this one module so the rest of the
|
|
14
|
+
* crypto code does not bake in framing details.
|
|
15
|
+
*
|
|
16
|
+
* Provisional layout (all integers big-endian where relevant):
|
|
17
|
+
*
|
|
18
|
+
* offset size field
|
|
19
|
+
* 0 8 magic "SESSPORT"
|
|
20
|
+
* 8 1 format version (1)
|
|
21
|
+
* 9 1 KDF algorithm id (2 = crypto_pwhash_ALG_ARGON2ID13)
|
|
22
|
+
* 10 1 allow-listed KDF profile code (1..255)
|
|
23
|
+
* 11 1 cipher id (1 = XChaCha20-Poly1305 secretstream)
|
|
24
|
+
* 12 1 compression id (1 = gzip, RFC 1952)
|
|
25
|
+
* 13 1 flags (0; any other value rejected)
|
|
26
|
+
* 14 2 reserved (0; any other value rejected)
|
|
27
|
+
* 16 16 per-artifact Argon2id salt (must equal crypto_pwhash_SALTBYTES)
|
|
28
|
+
* 32 16 per-artifact identity (O-22; random, authenticated, not secret)
|
|
29
|
+
* 48 24 secretstream header (crypto_secretstream_xchacha20poly1305_HEADERBYTES)
|
|
30
|
+
* 72 .. frames: uint32 BE ciphertext byte length, then ciphertext
|
|
31
|
+
*
|
|
32
|
+
* The owner-selected O-22 identity was appended at offset 32, preserving every
|
|
33
|
+
* prior field offset; the fixed header length therefore changed from 32 to 48
|
|
34
|
+
* bytes. This is a PROVISIONAL layout change, not a backward-compatibility
|
|
35
|
+
* decision: no artifact format has been frozen or released, so no
|
|
36
|
+
* compatibility with any prior provisional bytes is claimed or required.
|
|
37
|
+
*
|
|
38
|
+
* Each frame's AAD is: full 48-byte envelope header || uint32 BE frame
|
|
39
|
+
* sequence (starting at 0) || uint32 BE ciphertext byte length. This binds the
|
|
40
|
+
* header — including the identity — ordering, and framing lengths to the
|
|
41
|
+
* authenticated ciphertext, so tampering with any identity byte fails
|
|
42
|
+
* authentication.
|
|
43
|
+
*
|
|
44
|
+
* Only a valid secretstream final tag terminates a well-formed artifact.
|
|
45
|
+
*/
|
|
46
|
+
import { ARTIFACT_IDENTITY_BYTES } from "../contracts/crypto.ts";
|
|
47
|
+
import { CryptoContainerError } from "./errors.ts";
|
|
48
|
+
|
|
49
|
+
/** ASCII "SESSPORT" — provisional magic bytes. */
|
|
50
|
+
export const ENVELOPE_MAGIC = Uint8Array.from([0x53, 0x45, 0x53, 0x53, 0x50, 0x4f, 0x52, 0x54]);
|
|
51
|
+
|
|
52
|
+
/** Provisional envelope format version. Unknown versions must be rejected. */
|
|
53
|
+
export const ENVELOPE_FORMAT_VERSION = 1;
|
|
54
|
+
|
|
55
|
+
export const ENVELOPE_HEADER_FLAGS_OFFSET = 13;
|
|
56
|
+
export const ENVELOPE_HEADER_RESERVED_OFFSET = 14;
|
|
57
|
+
export const ENVELOPE_HEADER_SALT_OFFSET = 16;
|
|
58
|
+
export const ENVELOPE_HEADER_SALT_BYTES = 16;
|
|
59
|
+
/** O-22 identity appended after the salt; placement remains PROVISIONAL (O-02). */
|
|
60
|
+
export const ENVELOPE_IDENTITY_OFFSET = 32;
|
|
61
|
+
export const ENVELOPE_IDENTITY_BYTES = ARTIFACT_IDENTITY_BYTES;
|
|
62
|
+
/** Fixed header: metadata, salt (16..31), and identity (32..47). */
|
|
63
|
+
export const ENVELOPE_HEADER_BYTES = ENVELOPE_IDENTITY_OFFSET + ENVELOPE_IDENTITY_BYTES;
|
|
64
|
+
export const ENVELOPE_PROFILE_CODE_MIN = 1;
|
|
65
|
+
export const ENVELOPE_PROFILE_CODE_MAX = 255;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wire algorithm ids are fixed by the provisional format and validated
|
|
69
|
+
* against the pinned runtime during factory construction. They are not
|
|
70
|
+
* per-artifact tunables and never select implementation behavior.
|
|
71
|
+
*/
|
|
72
|
+
export const ENVELOPE_KDF_ARGON2ID13 = 2;
|
|
73
|
+
export const ENVELOPE_CIPHER_SECRETSTREAM_XCHACHA20POLY1305 = 1;
|
|
74
|
+
export const ENVELOPE_COMPRESSION_GZIP = 1;
|
|
75
|
+
export const ENVELOPE_FLAGS_NONE = 0;
|
|
76
|
+
|
|
77
|
+
/** Must equal crypto_secretstream_xchacha20poly1305_HEADERBYTES (asserted at runtime). */
|
|
78
|
+
export const ENVELOPE_SECRETSTREAM_HEADER_BYTES = 24;
|
|
79
|
+
/** Must equal crypto_secretstream_xchacha20poly1305_ABYTES (asserted at runtime). */
|
|
80
|
+
export const ENVELOPE_FRAME_OVERHEAD_BYTES = 17;
|
|
81
|
+
|
|
82
|
+
export const FRAME_LENGTH_BYTES = 4;
|
|
83
|
+
export const FRAME_MAX_CIPHERTEXT_BYTES = 0xffffffff;
|
|
84
|
+
export const FRAME_AAD_BYTES = ENVELOPE_HEADER_BYTES + 8;
|
|
85
|
+
|
|
86
|
+
export interface EnvelopeHeaderFields {
|
|
87
|
+
readonly profileCode: number;
|
|
88
|
+
/** Per-artifact KDF salt; copied. */
|
|
89
|
+
readonly salt: Uint8Array;
|
|
90
|
+
/** Per-artifact O-22 identity; must be exactly `ARTIFACT_IDENTITY_BYTES`; copied. */
|
|
91
|
+
readonly artifactIdentity: Uint8Array;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DecodedEnvelopeHeader {
|
|
95
|
+
readonly profileCode: number;
|
|
96
|
+
/** Copy owned by the caller; clear with best-effort zeroing once used. */
|
|
97
|
+
readonly salt: Uint8Array;
|
|
98
|
+
/**
|
|
99
|
+
* Caller-owned copy of the authenticated identity bytes. Opaque, public
|
|
100
|
+
* (not secret); the caller must not mutate the returned copy.
|
|
101
|
+
*/
|
|
102
|
+
readonly artifactIdentity: Uint8Array;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidHeader(message: string): never {
|
|
106
|
+
throw new CryptoContainerError("invalid-header", message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Encodes a fresh 48-byte envelope header. Ownership transfers to the caller. */
|
|
110
|
+
export function encodeEnvelopeHeader(fields: EnvelopeHeaderFields): Uint8Array {
|
|
111
|
+
const { profileCode, salt, artifactIdentity } = fields;
|
|
112
|
+
if (
|
|
113
|
+
!Number.isSafeInteger(profileCode) ||
|
|
114
|
+
profileCode < ENVELOPE_PROFILE_CODE_MIN ||
|
|
115
|
+
profileCode > ENVELOPE_PROFILE_CODE_MAX
|
|
116
|
+
) {
|
|
117
|
+
invalidHeader("policy profile code is outside the encodable range");
|
|
118
|
+
}
|
|
119
|
+
if (!(salt instanceof Uint8Array) || salt.length !== ENVELOPE_HEADER_SALT_BYTES) {
|
|
120
|
+
invalidHeader("KDF salt must be exactly the envelope salt size");
|
|
121
|
+
}
|
|
122
|
+
if (!(artifactIdentity instanceof Uint8Array) || artifactIdentity.length !== ENVELOPE_IDENTITY_BYTES) {
|
|
123
|
+
invalidHeader("artifact identity must be exactly the envelope identity size");
|
|
124
|
+
}
|
|
125
|
+
const out = new Uint8Array(ENVELOPE_HEADER_BYTES);
|
|
126
|
+
out.set(ENVELOPE_MAGIC, 0);
|
|
127
|
+
out[8] = ENVELOPE_FORMAT_VERSION;
|
|
128
|
+
out[9] = ENVELOPE_KDF_ARGON2ID13;
|
|
129
|
+
out[10] = profileCode;
|
|
130
|
+
out[11] = ENVELOPE_CIPHER_SECRETSTREAM_XCHACHA20POLY1305;
|
|
131
|
+
out[12] = ENVELOPE_COMPRESSION_GZIP;
|
|
132
|
+
out[13] = ENVELOPE_FLAGS_NONE;
|
|
133
|
+
out[14] = 0;
|
|
134
|
+
out[15] = 0;
|
|
135
|
+
out.set(salt, ENVELOPE_HEADER_SALT_OFFSET);
|
|
136
|
+
out.set(artifactIdentity, ENVELOPE_IDENTITY_OFFSET);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Strictly decodes and pre-validates an envelope header. Rejects wrong magic,
|
|
142
|
+
* unknown versions/algorithms, and any nonzero flag/reserved byte. Does NOT
|
|
143
|
+
* resolve the profile code; the caller must resolve it through an allow-list
|
|
144
|
+
* before any KDF work.
|
|
145
|
+
*/
|
|
146
|
+
export function decodeEnvelopeHeader(bytes: Uint8Array): DecodedEnvelopeHeader {
|
|
147
|
+
if (!(bytes instanceof Uint8Array) || bytes.length !== ENVELOPE_HEADER_BYTES) {
|
|
148
|
+
invalidHeader("envelope header must be exactly the fixed header size");
|
|
149
|
+
}
|
|
150
|
+
for (let index = 0; index < ENVELOPE_MAGIC.length; index += 1) {
|
|
151
|
+
if (bytes[index] !== ENVELOPE_MAGIC[index]) invalidHeader("unrecognized artifact magic");
|
|
152
|
+
}
|
|
153
|
+
if (bytes[8] !== ENVELOPE_FORMAT_VERSION) {
|
|
154
|
+
throw new CryptoContainerError("unsupported-format-version", "unsupported artifact format version");
|
|
155
|
+
}
|
|
156
|
+
if (bytes[9] !== ENVELOPE_KDF_ARGON2ID13) {
|
|
157
|
+
throw new CryptoContainerError("unsupported-algorithm", "unsupported KDF algorithm id");
|
|
158
|
+
}
|
|
159
|
+
if (bytes[11] !== ENVELOPE_CIPHER_SECRETSTREAM_XCHACHA20POLY1305) {
|
|
160
|
+
throw new CryptoContainerError("unsupported-algorithm", "unsupported cipher id");
|
|
161
|
+
}
|
|
162
|
+
if (bytes[12] !== ENVELOPE_COMPRESSION_GZIP) {
|
|
163
|
+
throw new CryptoContainerError("unsupported-algorithm", "unsupported compression id");
|
|
164
|
+
}
|
|
165
|
+
if (bytes[ENVELOPE_HEADER_FLAGS_OFFSET] !== ENVELOPE_FLAGS_NONE) invalidHeader("unknown header flags are not allowed");
|
|
166
|
+
if (bytes[ENVELOPE_HEADER_RESERVED_OFFSET] !== 0 || bytes[ENVELOPE_HEADER_RESERVED_OFFSET + 1] !== 0) {
|
|
167
|
+
invalidHeader("nonzero reserved header bytes are not allowed");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
profileCode: bytes[10]!,
|
|
171
|
+
salt: bytes.slice(ENVELOPE_HEADER_SALT_OFFSET, ENVELOPE_HEADER_SALT_OFFSET + ENVELOPE_HEADER_SALT_BYTES),
|
|
172
|
+
artifactIdentity: bytes.slice(
|
|
173
|
+
ENVELOPE_IDENTITY_OFFSET,
|
|
174
|
+
ENVELOPE_IDENTITY_OFFSET + ENVELOPE_IDENTITY_BYTES,
|
|
175
|
+
),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function requireUint32(value: number, field: string): number {
|
|
180
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > FRAME_MAX_CIPHERTEXT_BYTES) {
|
|
181
|
+
throw new CryptoContainerError("invalid-frame", `${field} is outside the uint32 framing range`);
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Builds a 4-byte BE frame ciphertext-length prefix. */
|
|
187
|
+
export function encodeFrameLengthPrefix(ciphertextBytes: number): Uint8Array {
|
|
188
|
+
const out = new Uint8Array(FRAME_LENGTH_BYTES);
|
|
189
|
+
new DataView(out.buffer).setUint32(0, requireUint32(ciphertextBytes, "frame ciphertext length"), false);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Reads a 4-byte BE frame ciphertext-length prefix. */
|
|
194
|
+
export function decodeFrameLength(prefix: Uint8Array): number {
|
|
195
|
+
if (!(prefix instanceof Uint8Array) || prefix.length !== FRAME_LENGTH_BYTES) {
|
|
196
|
+
throw new CryptoContainerError("invalid-frame", "frame length prefix must be exactly four bytes");
|
|
197
|
+
}
|
|
198
|
+
return new DataView(prefix.buffer, prefix.byteOffset, FRAME_LENGTH_BYTES).getUint32(0, false);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Encodes a frame prefix plus ciphertext into one fresh buffer. */
|
|
202
|
+
export function encodeFrame(ciphertext: Uint8Array): Uint8Array {
|
|
203
|
+
if (!(ciphertext instanceof Uint8Array)) {
|
|
204
|
+
throw new CryptoContainerError("cipher-failure", "ciphertext must be a byte array");
|
|
205
|
+
}
|
|
206
|
+
const out = new Uint8Array(FRAME_LENGTH_BYTES + ciphertext.length);
|
|
207
|
+
out.set(encodeFrameLengthPrefix(ciphertext.length), 0);
|
|
208
|
+
out.set(ciphertext, FRAME_LENGTH_BYTES);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* AAD for one secretstream frame: envelope header || frame sequence ||
|
|
214
|
+
* ciphertext length. Both sides must construct this identically.
|
|
215
|
+
*/
|
|
216
|
+
export function buildFrameAad(
|
|
217
|
+
envelopeHeader: Uint8Array,
|
|
218
|
+
sequence: number,
|
|
219
|
+
ciphertextBytes: number,
|
|
220
|
+
): Uint8Array {
|
|
221
|
+
if (!(envelopeHeader instanceof Uint8Array) || envelopeHeader.length !== ENVELOPE_HEADER_BYTES) {
|
|
222
|
+
throw new CryptoContainerError("invalid-header", "AAD requires the full fixed envelope header");
|
|
223
|
+
}
|
|
224
|
+
requireUint32(sequence, "frame sequence");
|
|
225
|
+
requireUint32(ciphertextBytes, "frame ciphertext length");
|
|
226
|
+
const aad = new Uint8Array(FRAME_AAD_BYTES);
|
|
227
|
+
aad.set(envelopeHeader, 0);
|
|
228
|
+
const view = new DataView(aad.buffer);
|
|
229
|
+
view.setUint32(ENVELOPE_HEADER_BYTES, sequence, false);
|
|
230
|
+
view.setUint32(ENVELOPE_HEADER_BYTES + 4, ciphertextBytes, false);
|
|
231
|
+
return aad;
|
|
232
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort crypto container errors.
|
|
3
|
+
*
|
|
4
|
+
* PROVISIONAL taxonomy: the user-visible error/report format is still OPEN
|
|
5
|
+
* (O-15). Codes exist so callers and tests can assert fail-closed behavior
|
|
6
|
+
* without depending on message text.
|
|
7
|
+
*
|
|
8
|
+
* Messages must never contain password, key, salt, or plaintext bytes.
|
|
9
|
+
*/
|
|
10
|
+
export type CryptoErrorCode =
|
|
11
|
+
| "invalid-policy-definition"
|
|
12
|
+
| "unknown-policy"
|
|
13
|
+
| "unknown-kdf-profile"
|
|
14
|
+
| "invalid-password"
|
|
15
|
+
| "unsupported-runtime"
|
|
16
|
+
| "invalid-header"
|
|
17
|
+
| "unsupported-format-version"
|
|
18
|
+
| "unsupported-algorithm"
|
|
19
|
+
| "artifact-too-large"
|
|
20
|
+
| "invalid-artifact-source"
|
|
21
|
+
| "truncated-artifact"
|
|
22
|
+
| "invalid-frame"
|
|
23
|
+
| "cipher-failure"
|
|
24
|
+
| "authentication-failed"
|
|
25
|
+
| "unexpected-tag"
|
|
26
|
+
| "missing-final-tag"
|
|
27
|
+
| "trailing-data"
|
|
28
|
+
| "compressed-limit-exceeded"
|
|
29
|
+
| "decompressed-limit-exceeded"
|
|
30
|
+
| "expansion-limit-exceeded"
|
|
31
|
+
| "gzip-error";
|
|
32
|
+
|
|
33
|
+
/** Fail-closed crypto container error carrying a stable machine-readable code. */
|
|
34
|
+
export class CryptoContainerError extends Error {
|
|
35
|
+
readonly code: CryptoErrorCode;
|
|
36
|
+
|
|
37
|
+
constructor(code: CryptoErrorCode, message: string, options?: ErrorOptions) {
|
|
38
|
+
super(message, options);
|
|
39
|
+
this.name = "CryptoContainerError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Preserves an existing container error; wraps anything else with a fallback code. */
|
|
45
|
+
export function asCryptoContainerError(
|
|
46
|
+
error: unknown,
|
|
47
|
+
fallbackCode: CryptoErrorCode,
|
|
48
|
+
fallbackMessage: string,
|
|
49
|
+
): CryptoContainerError {
|
|
50
|
+
if (error instanceof CryptoContainerError) return error;
|
|
51
|
+
return new CryptoContainerError(fallbackCode, fallbackMessage, { cause: error });
|
|
52
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory gzip streaming helpers.
|
|
3
|
+
*
|
|
4
|
+
* Compression still streams through `node:zlib` (fresh output buffers only).
|
|
5
|
+
* Decompression is implemented over pako 3.0.2's public low-level inflate API
|
|
6
|
+
* (`ZStream` with gzip-only `windowBits` 31 and the public `input`/`next_in`/
|
|
7
|
+
* `avail_in`/`output`/`next_out`/`avail_out` fields; no private state, no deep
|
|
8
|
+
* import, no magic-byte scanning) so member boundaries come from exact
|
|
9
|
+
* consumed-input positions:
|
|
10
|
+
*
|
|
11
|
+
* - one or more complete RFC 1952 members are accepted;
|
|
12
|
+
* - every byte after the final member rejects, including zero padding
|
|
13
|
+
* (owner-decided RFC 1952 §2.2 boundary policy);
|
|
14
|
+
* - clean EOF is accepted only after at least one complete member;
|
|
15
|
+
* - the decrypted gzip input is never materialized or copied: at most the
|
|
16
|
+
* current borrowed `ByteSource` chunk is retained, and it is released
|
|
17
|
+
* before the next chunk is pulled, so a producer (e.g. `decryptFrames`)
|
|
18
|
+
* may zero it as soon as the consumer advances;
|
|
19
|
+
* - compressed bytes are counted and checked against `maxCompressedBytes`
|
|
20
|
+
* before each chunk is accepted/processed; output is checked against
|
|
21
|
+
* `maxDecompressedBytes` while decoding (a 1-byte probe at the cap
|
|
22
|
+
* boundary lets an exact fit still reach `Z_STREAM_END`);
|
|
23
|
+
* - output stays private until every member/trailer, exact EOF and all caps
|
|
24
|
+
* pass; any failure returns nothing, and a success reports both the decoded
|
|
25
|
+
* bytes and the total compressed gzip byte count (the same counter the
|
|
26
|
+
* compressed cap is enforced against) so callers can apply expansion-ratio
|
|
27
|
+
* accounting over decrypted gzip bytes rather than envelope bytes;
|
|
28
|
+
* - every initialized inflater is ended with a checked `zlibInflateEnd`; a
|
|
29
|
+
* teardown failure is surfaced only when no primary failure exists;
|
|
30
|
+
* - a no-progress guard aborts instead of hanging.
|
|
31
|
+
*
|
|
32
|
+
* Ownership: borrowed source chunks are never mutated. Module-owned output
|
|
33
|
+
* chunks and the inflater scratch buffer are best-effort zeroed on success
|
|
34
|
+
* and failure; on success the concatenated result is owned by the caller.
|
|
35
|
+
* This is not secure deletion (A-07).
|
|
36
|
+
*/
|
|
37
|
+
/// <reference types="node" />
|
|
38
|
+
import { Readable, type Writable } from "node:stream";
|
|
39
|
+
import { pipeline } from "node:stream/promises";
|
|
40
|
+
import { createGzip } from "node:zlib";
|
|
41
|
+
import {
|
|
42
|
+
Z_BUF_ERROR,
|
|
43
|
+
Z_DATA_ERROR,
|
|
44
|
+
Z_MEM_ERROR,
|
|
45
|
+
Z_NEED_DICT,
|
|
46
|
+
Z_NO_FLUSH,
|
|
47
|
+
Z_OK,
|
|
48
|
+
Z_STREAM_END,
|
|
49
|
+
Z_STREAM_ERROR,
|
|
50
|
+
ZStream,
|
|
51
|
+
zlibInflate,
|
|
52
|
+
zlibInflateEnd,
|
|
53
|
+
zlibInflateInit2,
|
|
54
|
+
} from "pako";
|
|
55
|
+
import type { ByteSource } from "../contracts/crypto.ts";
|
|
56
|
+
import { asCryptoContainerError, CryptoContainerError } from "./errors.ts";
|
|
57
|
+
import { bestEffortZero, bestEffortZeroAll, concatChunks } from "./zero.ts";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Streams `source` through gzip and yields fresh output chunks. The producer
|
|
61
|
+
* yields copies so callers may retain them per the borrowed-chunk contract.
|
|
62
|
+
*/
|
|
63
|
+
export async function* compressStream(
|
|
64
|
+
source: ByteSource,
|
|
65
|
+
level: number | undefined,
|
|
66
|
+
): AsyncGenerator<Uint8Array> {
|
|
67
|
+
const gzip = level === undefined ? createGzip() : createGzip({ level });
|
|
68
|
+
const input = Readable.from(source);
|
|
69
|
+
const finished = pipeline(input, gzip);
|
|
70
|
+
// Attach a handler immediately so an early consumer exit can never surface
|
|
71
|
+
// as an unhandled rejection; the error is re-surfaced below when relevant.
|
|
72
|
+
finished.catch(() => {});
|
|
73
|
+
try {
|
|
74
|
+
for await (const chunk of gzip) {
|
|
75
|
+
yield Uint8Array.from(chunk as Uint8Array);
|
|
76
|
+
}
|
|
77
|
+
await finished;
|
|
78
|
+
} finally {
|
|
79
|
+
input.destroy();
|
|
80
|
+
gzip.destroy();
|
|
81
|
+
await finished.catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function waitForDrain(stream: Writable): Promise<void> {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
stream.off("drain", onDrain);
|
|
89
|
+
stream.off("error", onError);
|
|
90
|
+
stream.off("close", onClose);
|
|
91
|
+
};
|
|
92
|
+
const onDrain = () => {
|
|
93
|
+
cleanup();
|
|
94
|
+
resolve();
|
|
95
|
+
};
|
|
96
|
+
const onError = (error: Error) => {
|
|
97
|
+
cleanup();
|
|
98
|
+
reject(error);
|
|
99
|
+
};
|
|
100
|
+
const onClose = () => {
|
|
101
|
+
cleanup();
|
|
102
|
+
reject(new CryptoContainerError("gzip-error", "gzip stream closed before it could drain"));
|
|
103
|
+
};
|
|
104
|
+
stream.on("drain", onDrain);
|
|
105
|
+
stream.on("error", onError);
|
|
106
|
+
stream.on("close", onClose);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** windowBits = 15 + 16 selects the gzip wrapper (RFC 1952) only. */
|
|
111
|
+
const GZIP_WINDOW_BITS = 31;
|
|
112
|
+
|
|
113
|
+
/** Upper bound for the inflater output scratch; smaller caps shrink it. */
|
|
114
|
+
const GUNZIP_OUTPUT_SCRATCH_BYTES = 64 * 1024;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @internal TEST-ONLY seams for the strict gzip decoder. NOT part of the
|
|
118
|
+
* public crypto contract; production callers must not pass them. The
|
|
119
|
+
* `inflateEnd` override exists because `zlibInflateEnd` cannot naturally fail
|
|
120
|
+
* for a validly initialized inflater, so teardown classification is
|
|
121
|
+
* otherwise untestable.
|
|
122
|
+
*/
|
|
123
|
+
export interface GunzipDecoderTestSeams {
|
|
124
|
+
readonly inflateEnd?: (strm: ZStream) => number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function gzipStatusError(status: number, zlibMessage: string): CryptoContainerError {
|
|
128
|
+
const detail = zlibMessage.length > 0 ? ` (${zlibMessage})` : "";
|
|
129
|
+
if (status === Z_DATA_ERROR) {
|
|
130
|
+
return new CryptoContainerError("gzip-error", `malformed gzip data (zlib status ${status}${detail})`);
|
|
131
|
+
}
|
|
132
|
+
if (status === Z_NEED_DICT) {
|
|
133
|
+
return new CryptoContainerError(
|
|
134
|
+
"gzip-error",
|
|
135
|
+
`gzip stream requires an unsupported preset dictionary (zlib status ${status}${detail})`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (status === Z_STREAM_ERROR) {
|
|
139
|
+
return new CryptoContainerError("gzip-error", `gzip inflater state error (zlib status ${status}${detail})`);
|
|
140
|
+
}
|
|
141
|
+
if (status === Z_MEM_ERROR) {
|
|
142
|
+
return new CryptoContainerError("gzip-error", `gzip inflater ran out of memory (zlib status ${status}${detail})`);
|
|
143
|
+
}
|
|
144
|
+
return new CryptoContainerError("gzip-error", `gzip inflater failed (zlib status ${status}${detail})`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Success result of a strict bounded gunzip decode. On failure nothing is
|
|
149
|
+
* returned at all.
|
|
150
|
+
*/
|
|
151
|
+
export interface GunzipBoundedResult {
|
|
152
|
+
/** Decoded payload bytes; ownership transfers to the caller on success. */
|
|
153
|
+
readonly bytes: Uint8Array;
|
|
154
|
+
/**
|
|
155
|
+
* Total decrypted compressed gzip bytes consumed across every member,
|
|
156
|
+
* counted with the same accumulator the `maxCompressedBytes` cap is
|
|
157
|
+
* enforced against. This is the correct denominator for expansion-ratio
|
|
158
|
+
* accounting (never total envelope bytes).
|
|
159
|
+
*/
|
|
160
|
+
readonly compressedBytes: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Strictly decodes one or more complete gzip members from decrypted plaintext.
|
|
165
|
+
*
|
|
166
|
+
* Failure semantics: malformed/truncated members, any byte after the final
|
|
167
|
+
* member (including zero padding), a missing member, a compressed-input cap
|
|
168
|
+
* violation, a decompressed-output cap violation, an inflater teardown
|
|
169
|
+
* failure, and any source error all reject; nothing is returned. The staged
|
|
170
|
+
* output is best-effort zeroed on every path.
|
|
171
|
+
*
|
|
172
|
+
* Caps: `maxCompressedBytes` is a required positive safe integer checked
|
|
173
|
+
* against every pulled chunk before it is accepted/processed (later chunks
|
|
174
|
+
* are never pulled after a violation). `maxDecompressedBytes` is a
|
|
175
|
+
* non-negative safe integer checked during decode across all members.
|
|
176
|
+
*
|
|
177
|
+
* @internal Exported for the crypto tests; NOT part of the public contract.
|
|
178
|
+
*/
|
|
179
|
+
export async function collectGunzipBounded(
|
|
180
|
+
source: ByteSource,
|
|
181
|
+
maxCompressedBytes: number,
|
|
182
|
+
maxDecompressedBytes: number,
|
|
183
|
+
seams?: GunzipDecoderTestSeams,
|
|
184
|
+
): Promise<GunzipBoundedResult> {
|
|
185
|
+
if (!Number.isSafeInteger(maxCompressedBytes) || maxCompressedBytes < 1) {
|
|
186
|
+
throw new CryptoContainerError("invalid-policy-definition", "maxCompressedBytes must be a positive safe integer");
|
|
187
|
+
}
|
|
188
|
+
if (!Number.isSafeInteger(maxDecompressedBytes) || maxDecompressedBytes < 0) {
|
|
189
|
+
throw new CryptoContainerError(
|
|
190
|
+
"invalid-policy-definition",
|
|
191
|
+
"maxDecompressedBytes must be a non-negative safe integer",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const candidate: unknown = source;
|
|
196
|
+
if (
|
|
197
|
+
candidate === null ||
|
|
198
|
+
typeof candidate !== "object" ||
|
|
199
|
+
typeof (candidate as AsyncIterable<Uint8Array>)[Symbol.asyncIterator] !== "function"
|
|
200
|
+
) {
|
|
201
|
+
throw new CryptoContainerError(
|
|
202
|
+
"invalid-artifact-source",
|
|
203
|
+
"compressed input must be an async iterable of byte chunks",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const iterator = (candidate as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]();
|
|
207
|
+
const inflateEnd = seams?.inflateEnd ?? zlibInflateEnd;
|
|
208
|
+
|
|
209
|
+
const output: Uint8Array[] = [];
|
|
210
|
+
// Bounded inflater scratch. It shrinks for small decompressed caps so the
|
|
211
|
+
// cap is enforced by the buffer size where possible; at/over the cap a
|
|
212
|
+
// 1-byte probe detects overflow without producing unbounded output.
|
|
213
|
+
const scratch = new Uint8Array(Math.min(GUNZIP_OUTPUT_SCRATCH_BYTES, Math.max(1, maxDecompressedBytes)));
|
|
214
|
+
|
|
215
|
+
// The current borrowed chunk and the offset of its first unconsumed byte.
|
|
216
|
+
// `current` is the only compressed input retained; it is dropped before
|
|
217
|
+
// the next pull so the producer may zero it as soon as it is advanced.
|
|
218
|
+
let current: Uint8Array | undefined;
|
|
219
|
+
let currentOffset = 0;
|
|
220
|
+
let compressedBytes = 0;
|
|
221
|
+
let decompressedBytes = 0;
|
|
222
|
+
let sawMember = false;
|
|
223
|
+
let sourceExhausted = false;
|
|
224
|
+
|
|
225
|
+
const releaseCurrent = (): void => {
|
|
226
|
+
current = undefined;
|
|
227
|
+
currentOffset = 0;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Pulls one non-empty chunk; enforces the compressed cap before accepting it. */
|
|
231
|
+
const pullChunk = async (): Promise<boolean> => {
|
|
232
|
+
while (true) {
|
|
233
|
+
const step = await iterator.next();
|
|
234
|
+
if (step.done === true) {
|
|
235
|
+
sourceExhausted = true;
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const value: unknown = step.value;
|
|
239
|
+
if (!(value instanceof Uint8Array)) {
|
|
240
|
+
throw new CryptoContainerError("gzip-error", "compressed source yielded a non-byte chunk");
|
|
241
|
+
}
|
|
242
|
+
if (value.length === 0) continue;
|
|
243
|
+
const seen = compressedBytes + value.length;
|
|
244
|
+
if (seen > maxCompressedBytes) {
|
|
245
|
+
compressedBytes = seen;
|
|
246
|
+
throw new CryptoContainerError(
|
|
247
|
+
"compressed-limit-exceeded",
|
|
248
|
+
"compressed gzip input exceeds the policy limit",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
compressedBytes = seen;
|
|
252
|
+
current = value;
|
|
253
|
+
currentOffset = 0;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Makes at least one unconsumed input byte available for a new member. */
|
|
259
|
+
const prepareMemberInput = async (): Promise<boolean> => {
|
|
260
|
+
if (current !== undefined && currentOffset < current.length) return true;
|
|
261
|
+
releaseCurrent();
|
|
262
|
+
return pullChunk();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Decodes exactly one member starting at the current position. Member end
|
|
267
|
+
* is the exact consumed-input position (`next_in` at `Z_STREAM_END`); any
|
|
268
|
+
* unconsumed suffix stays in the borrowed chunk for the next member or the
|
|
269
|
+
* trailing-byte check.
|
|
270
|
+
*/
|
|
271
|
+
const inflateMember = async (): Promise<void> => {
|
|
272
|
+
const strm = new ZStream();
|
|
273
|
+
let initialized = false;
|
|
274
|
+
let memberComplete = false;
|
|
275
|
+
let primaryFailure: unknown;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const initStatus = zlibInflateInit2(strm, GZIP_WINDOW_BITS);
|
|
279
|
+
if (initStatus !== Z_OK) {
|
|
280
|
+
throw new CryptoContainerError(
|
|
281
|
+
"gzip-error",
|
|
282
|
+
`gzip inflater initialization failed (zlib status ${initStatus})`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
initialized = true;
|
|
286
|
+
strm.input = current!;
|
|
287
|
+
strm.next_in = currentOffset;
|
|
288
|
+
strm.avail_in = current!.length - currentOffset;
|
|
289
|
+
|
|
290
|
+
while (true) {
|
|
291
|
+
if (strm.avail_in === 0) {
|
|
292
|
+
// The borrowed chunk is fully consumed; release it before
|
|
293
|
+
// advancing so the producer may zero it.
|
|
294
|
+
releaseCurrent();
|
|
295
|
+
if (!(await pullChunk())) {
|
|
296
|
+
throw new CryptoContainerError("gzip-error", "gzip stream ended inside a member");
|
|
297
|
+
}
|
|
298
|
+
strm.input = current!;
|
|
299
|
+
strm.next_in = 0;
|
|
300
|
+
strm.avail_in = current!.length;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const remainingOutput = maxDecompressedBytes - decompressedBytes;
|
|
304
|
+
// At/over the cap, decode into a 1-byte probe: any produced byte
|
|
305
|
+
// is an overflow; zero produced with Z_STREAM_END is an exact fit.
|
|
306
|
+
const outSize = remainingOutput <= 0 ? 1 : Math.min(scratch.length, remainingOutput);
|
|
307
|
+
strm.output = scratch;
|
|
308
|
+
strm.next_out = 0;
|
|
309
|
+
strm.avail_out = outSize;
|
|
310
|
+
|
|
311
|
+
const availBefore = strm.avail_in;
|
|
312
|
+
const status = zlibInflate(strm, Z_NO_FLUSH);
|
|
313
|
+
const consumed = availBefore - strm.avail_in;
|
|
314
|
+
const produced = outSize - strm.avail_out;
|
|
315
|
+
|
|
316
|
+
if (produced > 0) {
|
|
317
|
+
if (remainingOutput <= 0) {
|
|
318
|
+
throw new CryptoContainerError(
|
|
319
|
+
"decompressed-limit-exceeded",
|
|
320
|
+
"decompressed payload exceeds the policy limit",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
decompressedBytes += produced;
|
|
324
|
+
output.push(Uint8Array.from(scratch.subarray(0, produced)));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (status === Z_STREAM_END) {
|
|
328
|
+
currentOffset = strm.next_in;
|
|
329
|
+
memberComplete = true;
|
|
330
|
+
// `break` (not `return`) so the checked teardown below still
|
|
331
|
+
// runs before this member is reported as complete.
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
if (status !== Z_OK && status !== Z_BUF_ERROR) {
|
|
335
|
+
throw gzipStatusError(status, strm.msg);
|
|
336
|
+
}
|
|
337
|
+
if (consumed === 0 && produced === 0 && strm.avail_in > 0) {
|
|
338
|
+
throw new CryptoContainerError(
|
|
339
|
+
"gzip-error",
|
|
340
|
+
`gzip inflater made no progress (zlib status ${status}); aborting to avoid a hang`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
primaryFailure = error;
|
|
346
|
+
} finally {
|
|
347
|
+
if (initialized) {
|
|
348
|
+
const endStatus = inflateEnd(strm);
|
|
349
|
+
if (endStatus !== Z_OK && primaryFailure === undefined) {
|
|
350
|
+
primaryFailure = new CryptoContainerError(
|
|
351
|
+
"gzip-error",
|
|
352
|
+
`gzip inflater teardown failed (zlib status ${endStatus})`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (primaryFailure !== undefined) throw primaryFailure;
|
|
358
|
+
if (!memberComplete) {
|
|
359
|
+
// Defensive: the loop only exits via `break` at Z_STREAM_END.
|
|
360
|
+
throw new CryptoContainerError("gzip-error", "gzip inflater did not reach the end of the member");
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
while (true) {
|
|
366
|
+
if (!(await prepareMemberInput())) break;
|
|
367
|
+
await inflateMember();
|
|
368
|
+
sawMember = true;
|
|
369
|
+
}
|
|
370
|
+
if (!sawMember) {
|
|
371
|
+
throw new CryptoContainerError("gzip-error", "compressed input contains no complete gzip member");
|
|
372
|
+
}
|
|
373
|
+
return { bytes: concatChunks(output), compressedBytes };
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw asCryptoContainerError(error, "gzip-error", "gzip decompression failed");
|
|
376
|
+
} finally {
|
|
377
|
+
// Best-effort clear of every module-owned buffer on success and failure:
|
|
378
|
+
// staged plaintext output and the inflater scratch. Borrowed chunks are
|
|
379
|
+
// never cleared here.
|
|
380
|
+
bestEffortZero(scratch);
|
|
381
|
+
bestEffortZeroAll(output);
|
|
382
|
+
if (!sourceExhausted && typeof iterator.return === "function") {
|
|
383
|
+
// Aborted before EOF: let the producer close and release/zero the
|
|
384
|
+
// borrowed chunk that may still be outstanding.
|
|
385
|
+
await iterator.return(undefined).catch(() => {});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort crypto module (PROVISIONAL container implementation).
|
|
3
|
+
*
|
|
4
|
+
* Public surface for the rest of the plugin. The artifact format and all
|
|
5
|
+
* numeric policy values remain OPEN/not frozen; production wiring must supply
|
|
6
|
+
* its own owner-approved policy definitions to
|
|
7
|
+
* `createCheckpointCryptoFactory` and must not assume defaults.
|
|
8
|
+
*/
|
|
9
|
+
export { createCheckpointCryptoFactory } from "./container.ts";
|
|
10
|
+
export type { CryptoFactoryTestSeams } from "./container.ts";
|
|
11
|
+
export { CryptoContainerError } from "./errors.ts";
|
|
12
|
+
export type { CryptoErrorCode } from "./errors.ts";
|
|
13
|
+
export type { CryptoPolicyDefinition, SodiumPolicyBounds } from "./policy.ts";
|