opencode-session-port 0.0.0-stage → 0.1.1
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/dist/tui.js +10276 -0
- package/package.json +48 -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 +17 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password KDF boundary for the provisional crypto container.
|
|
3
|
+
*
|
|
4
|
+
* Pinned `libsodium-wrappers-sumo@0.8.4` default ESM import + `await
|
|
5
|
+
* sodium.ready` (Phase 1 evidence: runtime named exports are not populated;
|
|
6
|
+
* the default object is).
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT (Oracle Gate 1 condition): the `seal`/`openAndStage` Promise
|
|
9
|
+
* boundary permits asynchronous execution but does NOT promise a nonblocking
|
|
10
|
+
* KDF. `crypto_pwhash` is synchronous and may block the event loop. Do not
|
|
11
|
+
* assume worker support and do not wire this into the TUI until the latency
|
|
12
|
+
* and execution strategy are measured (O-21 / O-19 follow-up).
|
|
13
|
+
*/
|
|
14
|
+
import sodium from "libsodium-wrappers-sumo";
|
|
15
|
+
import {
|
|
16
|
+
ENVELOPE_FRAME_OVERHEAD_BYTES,
|
|
17
|
+
ENVELOPE_KDF_ARGON2ID13,
|
|
18
|
+
ENVELOPE_HEADER_SALT_BYTES,
|
|
19
|
+
ENVELOPE_SECRETSTREAM_HEADER_BYTES,
|
|
20
|
+
} from "./envelope.ts";
|
|
21
|
+
import { CryptoContainerError } from "./errors.ts";
|
|
22
|
+
import type { CryptoPolicyDefinition } from "./policy.ts";
|
|
23
|
+
|
|
24
|
+
/** Derives the 32-byte secretstream key from raw password bytes and a salt. */
|
|
25
|
+
export interface PasswordKeyDerivation {
|
|
26
|
+
deriveKey(definition: CryptoPolicyDefinition, password: Uint8Array, salt: Uint8Array): Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unsupported(detail: string): never {
|
|
30
|
+
throw new CryptoContainerError("unsupported-runtime", `unsupported libsodium runtime: ${detail}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Awaits `sodium.ready` and asserts that every primitive/constant this module
|
|
35
|
+
* relies on matches the provisional format assumptions. A mismatch fails
|
|
36
|
+
* closed instead of silently using an unvalidated layout.
|
|
37
|
+
*/
|
|
38
|
+
export async function requireSupportedSodiumRuntime(): Promise<void> {
|
|
39
|
+
await sodium.ready;
|
|
40
|
+
|
|
41
|
+
const requiredFunctions = [
|
|
42
|
+
"crypto_pwhash",
|
|
43
|
+
"crypto_secretstream_xchacha20poly1305_init_push",
|
|
44
|
+
"crypto_secretstream_xchacha20poly1305_init_pull",
|
|
45
|
+
"crypto_secretstream_xchacha20poly1305_push",
|
|
46
|
+
"crypto_secretstream_xchacha20poly1305_pull",
|
|
47
|
+
"randombytes_buf",
|
|
48
|
+
"memzero",
|
|
49
|
+
// O-22: remapped-ID derivation hashes with BLAKE2b-256 through
|
|
50
|
+
// `crypto_generichash`; a runtime without it must fail closed.
|
|
51
|
+
"crypto_generichash",
|
|
52
|
+
] as const;
|
|
53
|
+
for (const name of requiredFunctions) {
|
|
54
|
+
if (typeof sodium[name] !== "function") unsupported(`missing function ${name}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (sodium.crypto_pwhash_ALG_ARGON2ID13 !== ENVELOPE_KDF_ARGON2ID13) {
|
|
58
|
+
unsupported("Argon2id algorithm id does not match the provisional format");
|
|
59
|
+
}
|
|
60
|
+
if (sodium.crypto_pwhash_SALTBYTES !== ENVELOPE_HEADER_SALT_BYTES) {
|
|
61
|
+
unsupported("crypto_pwhash salt size does not match the provisional format");
|
|
62
|
+
}
|
|
63
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES !== ENVELOPE_SECRETSTREAM_HEADER_BYTES) {
|
|
64
|
+
unsupported("secretstream header size does not match the provisional format");
|
|
65
|
+
}
|
|
66
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_ABYTES !== ENVELOPE_FRAME_OVERHEAD_BYTES) {
|
|
67
|
+
unsupported("secretstream ciphertext overhead does not match the provisional format");
|
|
68
|
+
}
|
|
69
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES !== 32) {
|
|
70
|
+
unsupported("secretstream key size does not match the provisional format");
|
|
71
|
+
}
|
|
72
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE !== 0) {
|
|
73
|
+
unsupported("secretstream message tag does not match the provisional format");
|
|
74
|
+
}
|
|
75
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL !== 3) {
|
|
76
|
+
unsupported("secretstream final tag does not match the provisional format");
|
|
77
|
+
}
|
|
78
|
+
// The pinned wrappers report crypto_pwhash_BYTES_MAX as a negative sentinel,
|
|
79
|
+
// so only the lower bound is checked here.
|
|
80
|
+
if (sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES < sodium.crypto_pwhash_BYTES_MIN) {
|
|
81
|
+
unsupported("secretstream key size is below the crypto_pwhash output minimum");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default derivation: Argon2id via `crypto_pwhash` with the definition's
|
|
87
|
+
* allow-listed costs. The returned key is owned by the caller and must be
|
|
88
|
+
* best-effort zeroed after the secretstream state is initialized.
|
|
89
|
+
*/
|
|
90
|
+
export const sodiumArgon2idDerivation: PasswordKeyDerivation = {
|
|
91
|
+
deriveKey(definition, password, salt) {
|
|
92
|
+
return sodium.crypto_pwhash(
|
|
93
|
+
sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES,
|
|
94
|
+
password,
|
|
95
|
+
salt,
|
|
96
|
+
definition.kdf.opsLimit,
|
|
97
|
+
definition.kdf.memLimit,
|
|
98
|
+
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allow-listed crypto policy definitions.
|
|
3
|
+
*
|
|
4
|
+
* Numeric profiles/caps live here as caller-supplied definitions — never in
|
|
5
|
+
* the shared contract and never in the artifact. The artifact only references
|
|
6
|
+
* a profile code that must resolve through the factory's allow-list.
|
|
7
|
+
*
|
|
8
|
+
* NO production profile is defined in this repository yet: Argon2id costs,
|
|
9
|
+
* chunk size, and artifact/compressed/decompressed/expansion/password caps are
|
|
10
|
+
* OPEN (O-01/O-03) and must be benchmarked and owner-approved before any
|
|
11
|
+
* production definition ships. `maxCompressedBytes` is a required distinct
|
|
12
|
+
* caller-supplied limit (never derived from `maxArtifactBytes`); tests
|
|
13
|
+
* construct clearly-marked test-only definitions.
|
|
14
|
+
*/
|
|
15
|
+
import type { CryptoPolicy } from "../contracts/crypto.ts";
|
|
16
|
+
import { CryptoContainerError } from "./errors.ts";
|
|
17
|
+
import {
|
|
18
|
+
ENVELOPE_PROFILE_CODE_MAX,
|
|
19
|
+
ENVELOPE_PROFILE_CODE_MIN,
|
|
20
|
+
FRAME_MAX_CIPHERTEXT_BYTES,
|
|
21
|
+
} from "./envelope.ts";
|
|
22
|
+
|
|
23
|
+
/** One allow-listed policy: numeric KDF costs, framing, and resource caps. */
|
|
24
|
+
export interface CryptoPolicyDefinition {
|
|
25
|
+
/** Stable code-selected profile identifier used by `CheckpointCryptoFactory.create`. */
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** One-byte code stored in the artifact header and resolved against the allow-list. */
|
|
28
|
+
readonly profileCode: number;
|
|
29
|
+
readonly kdf: {
|
|
30
|
+
/** Argon2id ops limit; must be within the pinned libsodium bounds. */
|
|
31
|
+
readonly opsLimit: number;
|
|
32
|
+
/** Argon2id memory limit in bytes; must be within the pinned libsodium bounds. */
|
|
33
|
+
readonly memLimit: number;
|
|
34
|
+
};
|
|
35
|
+
readonly compression: {
|
|
36
|
+
/** zlib level (-1 default, 0..9) or undefined for the library default. */
|
|
37
|
+
readonly level?: number | undefined;
|
|
38
|
+
};
|
|
39
|
+
readonly framing: {
|
|
40
|
+
/** Plaintext bytes per secretstream frame produced while sealing. */
|
|
41
|
+
readonly chunkBytes: number;
|
|
42
|
+
};
|
|
43
|
+
readonly limits: {
|
|
44
|
+
readonly maxPasswordBytes: number;
|
|
45
|
+
readonly maxArtifactBytes: number;
|
|
46
|
+
/**
|
|
47
|
+
* Required cap on the decrypted gzip (compressed) byte count, enforced
|
|
48
|
+
* while streaming before each chunk is accepted. This is a distinct
|
|
49
|
+
* caller-supplied limit, NOT derived from `maxArtifactBytes`; numeric
|
|
50
|
+
* values remain OPEN (O-03).
|
|
51
|
+
*/
|
|
52
|
+
readonly maxCompressedBytes: number;
|
|
53
|
+
readonly maxDecompressedBytes: number;
|
|
54
|
+
readonly maxExpansionRatio: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Runtime bounds taken from the pinned libsodium build (not chosen here). */
|
|
59
|
+
export interface SodiumPolicyBounds {
|
|
60
|
+
readonly opsLimitMin: number;
|
|
61
|
+
readonly memLimitMin: number;
|
|
62
|
+
readonly ciphertextOverheadBytes: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CryptoPolicyRegistry {
|
|
66
|
+
readonly byId: ReadonlyMap<string, CryptoPolicyDefinition>;
|
|
67
|
+
readonly byProfileCode: ReadonlyMap<number, CryptoPolicyDefinition>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function invalid(message: string): never {
|
|
71
|
+
throw new CryptoContainerError("invalid-policy-definition", message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asRecord(value: unknown, field: string): Record<string, unknown> {
|
|
75
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
76
|
+
invalid(`${field} must be an object`);
|
|
77
|
+
}
|
|
78
|
+
return value as Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function requireSafeInteger(value: unknown, field: string, min: number, max: number): number {
|
|
82
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
83
|
+
invalid(`${field} must be a safe integer between ${min} and ${max}`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeDefinition(raw: unknown, bounds: SodiumPolicyBounds): CryptoPolicyDefinition {
|
|
89
|
+
const source = asRecord(raw, "policy definition");
|
|
90
|
+
const id = source["id"];
|
|
91
|
+
if (typeof id !== "string" || id.length === 0) invalid("policy definition id must be a non-empty string");
|
|
92
|
+
const profileCode = requireSafeInteger(
|
|
93
|
+
source["profileCode"],
|
|
94
|
+
"policy definition profileCode",
|
|
95
|
+
ENVELOPE_PROFILE_CODE_MIN,
|
|
96
|
+
ENVELOPE_PROFILE_CODE_MAX,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const kdf = asRecord(source["kdf"], "policy definition kdf");
|
|
100
|
+
// Upper bounds: the pinned 0.8.4 wrappers report the library `_MAX`
|
|
101
|
+
// constants as negative sentinels, so they are not usable here; definitions
|
|
102
|
+
// are caller-supplied (trusted) and only need JS-safe integer validation.
|
|
103
|
+
const opsLimit = requireSafeInteger(kdf["opsLimit"], "kdf.opsLimit", bounds.opsLimitMin, Number.MAX_SAFE_INTEGER);
|
|
104
|
+
const memLimit = requireSafeInteger(kdf["memLimit"], "kdf.memLimit", bounds.memLimitMin, Number.MAX_SAFE_INTEGER);
|
|
105
|
+
|
|
106
|
+
const compression = asRecord(source["compression"], "policy definition compression");
|
|
107
|
+
const level = compression["level"];
|
|
108
|
+
const normalizedLevel = level === undefined ? undefined : requireSafeInteger(level, "compression.level", -1, 9);
|
|
109
|
+
|
|
110
|
+
const framing = asRecord(source["framing"], "policy definition framing");
|
|
111
|
+
const maxChunkBytes = FRAME_MAX_CIPHERTEXT_BYTES - bounds.ciphertextOverheadBytes;
|
|
112
|
+
const chunkBytes = requireSafeInteger(framing["chunkBytes"], "framing.chunkBytes", 1, maxChunkBytes);
|
|
113
|
+
|
|
114
|
+
const limits = asRecord(source["limits"], "policy definition limits");
|
|
115
|
+
const maxPasswordBytes = requireSafeInteger(limits["maxPasswordBytes"], "limits.maxPasswordBytes", 0, Number.MAX_SAFE_INTEGER);
|
|
116
|
+
const maxArtifactBytes = requireSafeInteger(limits["maxArtifactBytes"], "limits.maxArtifactBytes", 1, Number.MAX_SAFE_INTEGER);
|
|
117
|
+
// A gzip stream needs at least one nonempty member, so the compressed cap
|
|
118
|
+
// minimum is 1; a distinct value is required, never derived from the
|
|
119
|
+
// artifact cap (O-03 numeric values remain OPEN).
|
|
120
|
+
const maxCompressedBytes = requireSafeInteger(
|
|
121
|
+
limits["maxCompressedBytes"],
|
|
122
|
+
"limits.maxCompressedBytes",
|
|
123
|
+
1,
|
|
124
|
+
Number.MAX_SAFE_INTEGER,
|
|
125
|
+
);
|
|
126
|
+
const maxDecompressedBytes = requireSafeInteger(
|
|
127
|
+
limits["maxDecompressedBytes"],
|
|
128
|
+
"limits.maxDecompressedBytes",
|
|
129
|
+
0,
|
|
130
|
+
Number.MAX_SAFE_INTEGER,
|
|
131
|
+
);
|
|
132
|
+
const maxExpansionRatio = limits["maxExpansionRatio"];
|
|
133
|
+
if (typeof maxExpansionRatio !== "number" || !Number.isFinite(maxExpansionRatio) || maxExpansionRatio <= 0) {
|
|
134
|
+
invalid("limits.maxExpansionRatio must be a positive finite number");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
id,
|
|
139
|
+
profileCode,
|
|
140
|
+
kdf: Object.freeze({ opsLimit, memLimit }),
|
|
141
|
+
compression: Object.freeze({ level: normalizedLevel }),
|
|
142
|
+
framing: Object.freeze({ chunkBytes }),
|
|
143
|
+
limits: Object.freeze({
|
|
144
|
+
maxPasswordBytes,
|
|
145
|
+
maxArtifactBytes,
|
|
146
|
+
maxCompressedBytes,
|
|
147
|
+
maxDecompressedBytes,
|
|
148
|
+
maxExpansionRatio,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validates and copies caller-supplied definitions into an immutable
|
|
155
|
+
* allow-list. Duplicate ids/profile codes and out-of-range numeric values are
|
|
156
|
+
* rejected (fail closed); an empty allow-list is valid and rejects every
|
|
157
|
+
* `create` call.
|
|
158
|
+
*/
|
|
159
|
+
export function buildCryptoPolicyRegistry(
|
|
160
|
+
definitions: readonly CryptoPolicyDefinition[],
|
|
161
|
+
bounds: SodiumPolicyBounds,
|
|
162
|
+
): CryptoPolicyRegistry {
|
|
163
|
+
if (!Array.isArray(definitions)) invalid("policy definitions must be an array");
|
|
164
|
+
const byId = new Map<string, CryptoPolicyDefinition>();
|
|
165
|
+
const byProfileCode = new Map<number, CryptoPolicyDefinition>();
|
|
166
|
+
for (const raw of definitions) {
|
|
167
|
+
const definition = normalizeDefinition(raw, bounds);
|
|
168
|
+
if (byId.has(definition.id)) invalid("duplicate policy definition id");
|
|
169
|
+
if (byProfileCode.has(definition.profileCode)) invalid("duplicate policy profile code");
|
|
170
|
+
byId.set(definition.id, definition);
|
|
171
|
+
byProfileCode.set(definition.profileCode, definition);
|
|
172
|
+
}
|
|
173
|
+
return { byId, byProfileCode };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolves an explicitly selected policy id; unknown ids fail closed. */
|
|
177
|
+
export function resolvePolicyById(registry: CryptoPolicyRegistry, policy: CryptoPolicy): CryptoPolicyDefinition {
|
|
178
|
+
const id = (policy as { id?: unknown } | null | undefined)?.id;
|
|
179
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
180
|
+
throw new CryptoContainerError("unknown-policy", "an explicit non-empty crypto policy id is required");
|
|
181
|
+
}
|
|
182
|
+
const definition = registry.byId.get(id);
|
|
183
|
+
if (definition === undefined) {
|
|
184
|
+
throw new CryptoContainerError("unknown-policy", "the requested crypto policy id is not allow-listed");
|
|
185
|
+
}
|
|
186
|
+
return definition;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolves the artifact's one-byte profile code strictly through the
|
|
191
|
+
* caller-supplied allow-list. Unknown codes fail closed before any KDF work.
|
|
192
|
+
*/
|
|
193
|
+
export function resolvePolicyByProfileCode(
|
|
194
|
+
registry: CryptoPolicyRegistry,
|
|
195
|
+
profileCode: number,
|
|
196
|
+
): CryptoPolicyDefinition {
|
|
197
|
+
const definition = registry.byProfileCode.get(profileCode);
|
|
198
|
+
if (definition === undefined) {
|
|
199
|
+
throw new CryptoContainerError("unknown-kdf-profile", "artifact references a KDF profile that is not allow-listed");
|
|
200
|
+
}
|
|
201
|
+
return definition;
|
|
202
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort clearing of byte buffers owned by this module.
|
|
3
|
+
*
|
|
4
|
+
* There is NO secure-deletion guarantee (A-07): this only overwrites buffers
|
|
5
|
+
* this module allocated. Caller-owned password material is never modified.
|
|
6
|
+
* libsodium secretstream state objects are released by dropping references;
|
|
7
|
+
* pinned libsodium-wrappers-sumo@0.8.4 exposes no documented generic free
|
|
8
|
+
* helper for them (see Phase 1 evidence / lib-13).
|
|
9
|
+
*/
|
|
10
|
+
import sodium from "libsodium-wrappers-sumo";
|
|
11
|
+
|
|
12
|
+
/** Best-effort zero of a module-owned buffer. */
|
|
13
|
+
export function bestEffortZero(bytes: Uint8Array): void {
|
|
14
|
+
if (bytes.length > 0) sodium.memzero(bytes);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Best-effort zero of all module-owned buffers in a list. */
|
|
18
|
+
export function bestEffortZeroAll(chunks: readonly Uint8Array[]): void {
|
|
19
|
+
for (const chunk of chunks) bestEffortZero(chunk);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Concatenates fresh copies into one new buffer owned by the caller. */
|
|
23
|
+
export function concatChunks(chunks: readonly Uint8Array[]): Uint8Array {
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (const chunk of chunks) total += chunk.length;
|
|
26
|
+
const out = new Uint8Array(total);
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
out.set(chunk, offset);
|
|
30
|
+
offset += chunk.length;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVISIONAL per-artifact remapped-ID derivation (owner-selected O-22 scheme).
|
|
3
|
+
*
|
|
4
|
+
* ============================ NOT FROZEN ============================
|
|
5
|
+
* On 2026-10-09 the owner selected the scheme for implementation: remapped
|
|
6
|
+
* IDs are derived with BLAKE2b-256 (via the pinned
|
|
7
|
+
* `libsodium-wrappers-sumo@0.8.4` `crypto_generichash`) over a versioned,
|
|
8
|
+
* namespace-separated, unambiguously length-prefixed tuple of the artifact
|
|
9
|
+
* identity and the original ID, encoded as `ses_` + 64 lowercase hex for
|
|
10
|
+
* sessions and `msg_` + 64 lowercase hex for messages. The exact byte layout
|
|
11
|
+
* below is a PROVISIONAL, versioned encoding fixed only for this
|
|
12
|
+
* implementation so tests can pin it. It is not a released format, carries no
|
|
13
|
+
* backward-compatibility promise, and makes no host/runtime compatibility
|
|
14
|
+
* claim: exact artifact placement of the identity remains subject to O-02,
|
|
15
|
+
* and host validation of derived IDs remains OPEN (OQ-24).
|
|
16
|
+
*
|
|
17
|
+
* The `v1` version tag is part of the derivation domain. Any future encoding
|
|
18
|
+
* change must use a new tag instead of silently reinterpreting `v1`.
|
|
19
|
+
* =====================================================================
|
|
20
|
+
*
|
|
21
|
+
* Framed derivation input, in order. Every field — including the raw identity
|
|
22
|
+
* and the final variable-length JSON string — is prefixed by its byte length
|
|
23
|
+
* encoded as an 8-byte unsigned big-endian integer:
|
|
24
|
+
*
|
|
25
|
+
* field bytes
|
|
26
|
+
* UTF-8 "SessionPort/remapped-id" 23
|
|
27
|
+
* UTF-8 "v1" 2
|
|
28
|
+
* UTF-8 namespace ("session"/"message") 7
|
|
29
|
+
* raw artifact identity 16
|
|
30
|
+
* UTF-8 of JSON.stringify(originalID) variable
|
|
31
|
+
*
|
|
32
|
+
* `JSON.stringify` is applied to a validated JS string, so arbitrary string
|
|
33
|
+
* values are preserved exactly: lone surrogates are escaped by well-formed
|
|
34
|
+
* JSON.stringify and `TextEncoder` therefore never applies lossy U+FFFD
|
|
35
|
+
* replacement to the original ID. The length prefixes keep the tuple
|
|
36
|
+
* unambiguous despite the variable-length final field.
|
|
37
|
+
*
|
|
38
|
+
* The original ID is never included in any output or error message; only the
|
|
39
|
+
* 32-byte BLAKE2b digest is hex-encoded. There is no fallback or regeneration
|
|
40
|
+
* behavior: invalid inputs fail closed.
|
|
41
|
+
*/
|
|
42
|
+
import sodium from "libsodium-wrappers-sumo";
|
|
43
|
+
import { ARTIFACT_IDENTITY_BYTES, type ArtifactIdentity } from "../contracts/crypto.ts";
|
|
44
|
+
import { requireSupportedSodiumRuntime } from "../crypto/kdf.ts";
|
|
45
|
+
|
|
46
|
+
/** Remap namespaces; each derives in a distinct domain. */
|
|
47
|
+
export type RemappedIdNamespace = "session" | "message";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Synchronous deriver over the provisional O-22 encoding. Construct it once
|
|
51
|
+
* with `createRemappedIdDeriver()` (which awaits/validates pinned sodium
|
|
52
|
+
* readiness); `derive` is pure and synchronous thereafter.
|
|
53
|
+
*/
|
|
54
|
+
export interface RemappedIdDeriver {
|
|
55
|
+
derive(namespace: RemappedIdNamespace, artifactIdentity: ArtifactIdentity, originalID: string): string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fail-closed validation/runtime codes for remapped-ID derivation. */
|
|
59
|
+
export type RemappedIdErrorCode =
|
|
60
|
+
| "invalid-namespace"
|
|
61
|
+
| "invalid-artifact-identity"
|
|
62
|
+
| "invalid-original-id"
|
|
63
|
+
| "derivation-failure";
|
|
64
|
+
|
|
65
|
+
/** Error that never echoes the original ID. */
|
|
66
|
+
export class RemappedIdError extends Error {
|
|
67
|
+
readonly code: RemappedIdErrorCode;
|
|
68
|
+
|
|
69
|
+
constructor(code: RemappedIdErrorCode, message: string) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "RemappedIdError";
|
|
72
|
+
this.code = code;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Provisional domain/version tags; see the module comment before changing. */
|
|
77
|
+
const REMAPPED_ID_DOMAIN = "SessionPort/remapped-id";
|
|
78
|
+
const REMAPPED_ID_VERSION = "v1";
|
|
79
|
+
|
|
80
|
+
const HASH_BYTES = 32;
|
|
81
|
+
const LENGTH_PREFIX_BYTES = 8;
|
|
82
|
+
const OUTPUT_PREFIX: Readonly<Record<RemappedIdNamespace, string>> = Object.freeze({
|
|
83
|
+
session: "ses_",
|
|
84
|
+
message: "msg_",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const textEncoder = new TextEncoder();
|
|
88
|
+
const HEX_DIGITS = "0123456789abcdef";
|
|
89
|
+
|
|
90
|
+
function invalidNamespace(): never {
|
|
91
|
+
throw new RemappedIdError("invalid-namespace", 'namespace must be exactly "session" or "message"');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function invalidIdentity(): never {
|
|
95
|
+
throw new RemappedIdError(
|
|
96
|
+
"invalid-artifact-identity",
|
|
97
|
+
`artifact identity must be a Uint8Array of exactly ${ARTIFACT_IDENTITY_BYTES} bytes`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function invalidOriginalId(): never {
|
|
102
|
+
throw new RemappedIdError("invalid-original-id", "original ID must be a string");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateNamespace(namespace: RemappedIdNamespace): RemappedIdNamespace {
|
|
106
|
+
if (namespace !== "session" && namespace !== "message") invalidNamespace();
|
|
107
|
+
return namespace;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateArtifactIdentity(artifactIdentity: ArtifactIdentity): ArtifactIdentity {
|
|
111
|
+
if (!(artifactIdentity instanceof Uint8Array) || artifactIdentity.length !== ARTIFACT_IDENTITY_BYTES) {
|
|
112
|
+
invalidIdentity();
|
|
113
|
+
}
|
|
114
|
+
return artifactIdentity;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateOriginalId(originalID: string): string {
|
|
118
|
+
if (typeof originalID !== "string") invalidOriginalId();
|
|
119
|
+
return originalID;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 8-byte unsigned big-endian byte-length prefix. */
|
|
123
|
+
function encodeLengthPrefix(byteLength: number): Uint8Array {
|
|
124
|
+
const prefix = new Uint8Array(LENGTH_PREFIX_BYTES);
|
|
125
|
+
new DataView(prefix.buffer).setBigUint64(0, BigInt(byteLength), false);
|
|
126
|
+
return prefix;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One length-prefixed framed field: u64 BE length || raw field bytes. */
|
|
130
|
+
function frameField(field: Uint8Array): Uint8Array {
|
|
131
|
+
const out = new Uint8Array(LENGTH_PREFIX_BYTES + field.length);
|
|
132
|
+
out.set(encodeLengthPrefix(field.length), 0);
|
|
133
|
+
out.set(field, LENGTH_PREFIX_BYTES);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Builds the exact framed derivation input documented in the module comment.
|
|
139
|
+
* All inputs are already validated.
|
|
140
|
+
*/
|
|
141
|
+
function buildDerivationInput(
|
|
142
|
+
namespace: RemappedIdNamespace,
|
|
143
|
+
artifactIdentity: ArtifactIdentity,
|
|
144
|
+
originalID: string,
|
|
145
|
+
): Uint8Array {
|
|
146
|
+
const fields = [
|
|
147
|
+
frameField(textEncoder.encode(REMAPPED_ID_DOMAIN)),
|
|
148
|
+
frameField(textEncoder.encode(REMAPPED_ID_VERSION)),
|
|
149
|
+
frameField(textEncoder.encode(namespace)),
|
|
150
|
+
frameField(artifactIdentity),
|
|
151
|
+
frameField(textEncoder.encode(JSON.stringify(originalID))),
|
|
152
|
+
];
|
|
153
|
+
let total = 0;
|
|
154
|
+
for (const field of fields) total += field.length;
|
|
155
|
+
const out = new Uint8Array(total);
|
|
156
|
+
let offset = 0;
|
|
157
|
+
for (const field of fields) {
|
|
158
|
+
out.set(field, offset);
|
|
159
|
+
offset += field.length;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Lowercase hex over the digest; the original ID is never part of the output. */
|
|
165
|
+
function toLowerHex(bytes: Uint8Array): string {
|
|
166
|
+
let out = "";
|
|
167
|
+
for (const byte of bytes) out += HEX_DIGITS[byte >>> 4]! + HEX_DIGITS[byte & 0x0f]!;
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deriveRemappedId(
|
|
172
|
+
namespace: RemappedIdNamespace,
|
|
173
|
+
artifactIdentity: ArtifactIdentity,
|
|
174
|
+
originalID: string,
|
|
175
|
+
): string {
|
|
176
|
+
const validatedNamespace = validateNamespace(namespace);
|
|
177
|
+
const validatedIdentity = validateArtifactIdentity(artifactIdentity);
|
|
178
|
+
const validatedOriginalId = validateOriginalId(originalID);
|
|
179
|
+
const digest = sodium.crypto_generichash(
|
|
180
|
+
HASH_BYTES,
|
|
181
|
+
buildDerivationInput(validatedNamespace, validatedIdentity, validatedOriginalId),
|
|
182
|
+
null,
|
|
183
|
+
);
|
|
184
|
+
if (!(digest instanceof Uint8Array) || digest.length !== HASH_BYTES) {
|
|
185
|
+
throw new RemappedIdError("derivation-failure", "BLAKE2b-256 produced an unexpected digest size");
|
|
186
|
+
}
|
|
187
|
+
return OUTPUT_PREFIX[validatedNamespace] + toLowerHex(digest);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Awaits/validates pinned sodium readiness (including `crypto_generichash`)
|
|
192
|
+
* and returns the synchronous provisional deriver. Fails closed on an
|
|
193
|
+
* unsupported runtime; there is no fallback deriver.
|
|
194
|
+
*/
|
|
195
|
+
export async function createRemappedIdDeriver(): Promise<RemappedIdDeriver> {
|
|
196
|
+
await requireSupportedSodiumRuntime();
|
|
197
|
+
return {
|
|
198
|
+
derive(namespace, artifactIdentity, originalID) {
|
|
199
|
+
return deriveRemappedId(namespace, artifactIdentity, originalID);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort V1 restore lane (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Pure restore preflight/planning plus the provisional per-artifact remapped-ID
|
|
5
|
+
* derivation (O-22). `planRestore` snapshots and validates the checkpoint
|
|
6
|
+
* payload and local destination, applies the owner-approved canonical-duplicate
|
|
7
|
+
* and reference-remap policy (O-04/O-05), rederives every message ID of every
|
|
8
|
+
* session planned for import (owner clarification 2026-10-10), detects ID
|
|
9
|
+
* collisions and dangling references, and returns a deterministic
|
|
10
|
+
* parent-before-child import order. It performs no host call, file IO, LLM
|
|
11
|
+
* call, or import; broader partial-import retry/reporting semantics remain OPEN
|
|
12
|
+
* (O-06), and derived-ID runtime validation remains OPEN (OQ-24).
|
|
13
|
+
*/
|
|
14
|
+
export { planRestore, RestorePlanError } from "./planner.ts";
|
|
15
|
+
export type {
|
|
16
|
+
PlanRestoreInput,
|
|
17
|
+
RestoreClassification,
|
|
18
|
+
RestorePlan,
|
|
19
|
+
RestorePlanEntry,
|
|
20
|
+
RestorePlanErrorCode,
|
|
21
|
+
RestoreSkipReason,
|
|
22
|
+
} from "./planner.ts";
|
|
23
|
+
export { createRemappedIdDeriver, RemappedIdError } from "./identity.ts";
|
|
24
|
+
export type { RemappedIdDeriver, RemappedIdErrorCode, RemappedIdNamespace } from "./identity.ts";
|