@tiinex/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +8 -6
  2. package/src/public/index.js +21 -0
  3. package/src/public/node.js +1 -0
  4. package/src/release/plan.mjs +18 -5
  5. package/src/release/run.mjs +7 -5
  6. package/src/tooling/portable/adapters/cli/cli.command-input.js +3 -0
  7. package/src/tooling/portable/adapters/cli/cli.common-output.js +23 -0
  8. package/src/tooling/portable/adapters/cli/cli.handoff-manufacture.js +3 -1
  9. package/src/tooling/portable/adapters/cli/cli.help.js +18 -2
  10. package/src/tooling/portable/adapters/cli/cli.material-policy.js +0 -1
  11. package/src/tooling/portable/adapters/cli/cli.operator-bridge.js +0 -13
  12. package/src/tooling/portable/adapters/cli/cli.run.js +1 -1
  13. package/src/tooling/portable/adapters/cli/cli.source-frontier-comparison.js +50 -0
  14. package/src/tooling/portable/adapters/node/handoff.manufacture.js +14 -0
  15. package/src/tooling/portable/adapters/node/handoff.manufacture.packageParent.js +80 -16
  16. package/src/tooling/portable/adapters/node/handoff.manufacture.requirements.js +44 -11
  17. package/src/tooling/portable/adapters/node/handoff.manufacture.scope.js +96 -1
  18. package/src/tooling/portable/adapters/node/sourceFrontierComparison.js +194 -0
  19. package/src/tooling/portable/comparison/sourceFrontierComparison.js +501 -0
  20. package/src/tooling/portable/grounding/grounding.readiness.js +8 -4
  21. package/src/tooling/portable/grounding/grounding.readiness.support.js +63 -1
  22. package/src/tooling/portable/handoff/contextAudit.js +21 -1
  23. package/src/tooling/portable/handoff/materialClosure.descriptor.js +1 -1
  24. package/src/tooling/portable/handoff/materialClosure.materials.js +1 -1
  25. package/src/tooling/portable/handoff/recipientV2.artifacts.js +1 -1
  26. package/src/tooling/portable/handoff/recipientV2.inspect.helpers.js +6 -1
  27. package/src/tooling/portable/handoff/recipientV2.packageV1.build.js +93 -19
  28. package/src/tooling/portable/handoff/recipientV2.packageV1.contract.js +30 -1
  29. package/src/tooling/portable/handoff/recipientV2.packageV1.inspect.helpers.js +8 -2
  30. package/src/tooling/portable/handoff/recipientV2.packageV1.inspect.js +122 -10
  31. package/src/tooling/portable/handoff/recipientV2.packageV1.js +2 -1
  32. package/src/tooling/portable/handoff/recipientV2.packageV1.secure.js +87 -0
  33. package/src/tooling/portable/handoff/recipientV2.packageV1.shared.js +9 -1
  34. package/src/tooling/portable/handoff/recipientV2.topology.js +2 -2
  35. package/src/tooling/portable/handoff/recipientV2.topology.materials.js +9 -1
  36. package/src/tooling/portable/handoff/transportEnvelopeV1.js +76 -0
  37. package/src/tooling/portable/index.js +4 -0
  38. package/src/tooling/portable/operation.catalog.js +8 -0
  39. package/src/tooling/portable/operation.catalog.package.js +0 -8
  40. package/src/transport/secureTransportV1.js +339 -0
  41. package/src/tooling/portable/handoff/sourceFrontierComparison.js +0 -186
@@ -0,0 +1,339 @@
1
+ import { toUint8Array, utf8Bytes } from '../export/package.bytes.js';
2
+
3
+ export const SECURE_TRANSPORT_V1_PROFILE = deepFreeze({
4
+ profileId: 'tiinex.password.pbkdf2-hmac-sha256.aes-256-kw.aes-256-gcm.v1',
5
+ profileVersion: 1,
6
+ contentEncryptionAlgorithm: 'AES-256-GCM',
7
+ contentEncryptionParameters: {
8
+ keyBits: 256,
9
+ nonceBytes: 12,
10
+ tagBits: 128,
11
+ binaryFraming: 'webcrypto-ciphertext-concatenated-tag'
12
+ },
13
+ nonceOrIvEncoding: 'base64url-no-padding',
14
+ payloadAuthenticationRule: 'authenticated-encryption-required',
15
+ securityMetadataAuthenticationRule: 'authenticate-profile-and-workspace-binding',
16
+ recipientChangePayloadRule: 'protected-payload-bytes-unchanged',
17
+ kdfAlgorithm: 'PBKDF2-HMAC-SHA-256',
18
+ kdfParameters: {
19
+ iterations: 600000,
20
+ saltBytes: 16,
21
+ derivedKeyBits: 256,
22
+ passwordEncoding: 'utf8-no-normalization'
23
+ },
24
+ keyWrapAlgorithm: 'AES-256-KW',
25
+ keyWrapParameters: {
26
+ wrappingKeyBits: 256,
27
+ wrappedKeyFormat: 'raw',
28
+ contentKeyAlgorithm: 'AES-256-GCM'
29
+ },
30
+ kdfSaltEncoding: 'base64url-no-padding',
31
+ wrappedContentKeyEncoding: 'base64url-no-padding',
32
+ slotKind: 'password',
33
+ slotVerificationRule: 'unwrap-then-authenticate-protected-payload'
34
+ });
35
+
36
+ export const SECURE_TRANSPORT_V1_ENVELOPE_CONTRACT = deepFreeze({
37
+ envelopePurpose: 'password-sealed-workspace-transport',
38
+ envelopeVersion: 1,
39
+ plaintextRepresentationKind: 'exact-workspace-byte-tree-archive',
40
+ protectedNameTree: 'sealed',
41
+ workspaceBindingMethod: 'sha256-exact-visible-workspace-artifact-bytes',
42
+ contentKeyScope: 'fresh-random-per-protected-workspace',
43
+ openRule: 'any-one-qualified-password-slot',
44
+ wrongPasswordResult: 'locked',
45
+ unsupportedProfileResult: 'unsupported',
46
+ malformedMetadataResult: 'failed',
47
+ authenticationFailureResult: 'failed',
48
+ missingAuthorizedSlotResult: 'locked',
49
+ secretPersistence: 'runtime-only',
50
+ plaintextPersistence: 'transient-or-explicit-destination-only',
51
+ recoveryRule: 'no-hidden-bypass',
52
+ failurePolicy: 'fail-closed',
53
+ multiWorkspaceIsolation: 'independent-envelope-content-key-and-recipient-set',
54
+ payloadByteIntegrityOwner: 'external-payload',
55
+ envelopeRootIntegrityMeaning: 'metadata-continuity-only',
56
+ cryptographicAuthenticationMeaning: 'encrypted-representation-and-profile-binding-only',
57
+ providerStateWhileLocked: 'inactive',
58
+ postOpenQualification: 'normal-workspace-representation-and-schema-integrity',
59
+ semanticAuthority: 'none',
60
+ outerVisibleMaterial: 'non-secret-envelope-profile-and-slot-metadata',
61
+ mustRemainSealed: 'workspace-internal-paths-tree-and-plaintext-bytes',
62
+ forbiddenDurableSecrets: 'passwords-derived-keys-and-plaintext-content-key',
63
+ privacyPolicyOwner: 'privacy-boundary-when-needed'
64
+ });
65
+
66
+ export function qualifySecureTransportV1Envelope(input = {}) {
67
+ const findings = [];
68
+ const envelope = input && typeof input === 'object' ? input : {};
69
+ for (const [key, expected] of Object.entries(SECURE_TRANSPORT_V1_ENVELOPE_CONTRACT)) exactField(envelope, key, expected, findings);
70
+ const profile = envelope.profile && typeof envelope.profile === 'object' ? envelope.profile : {};
71
+ const supportedProfile = String(profile.profileId || '') === SECURE_TRANSPORT_V1_PROFILE.profileId && Number(profile.profileVersion || 0) === SECURE_TRANSPORT_V1_PROFILE.profileVersion;
72
+ if (!String(profile.profileId || '') || !positiveInteger(profile.profileVersion)) findings.push(problem('invalid', 'secure-transport.profile.identity-invalid', 'Transport profile identity/version is missing or malformed.'));
73
+ if (String(profile.profileId || '') && positiveInteger(profile.profileVersion) && !supportedProfile) findings.push(problem('unsupported', 'secure-transport.profile.unsupported', 'Transport profile is not supported by this Core runtime.', { profileId: String(profile.profileId || ''), profileVersion: Number(profile.profileVersion || 0) }));
74
+ if (supportedProfile) {
75
+ for (const key of ['contentEncryptionAlgorithm', 'contentEncryptionParameters', 'nonceOrIvEncoding', 'payloadAuthenticationRule', 'securityMetadataAuthenticationRule', 'recipientChangePayloadRule']) exactField(profile, key, SECURE_TRANSPORT_V1_PROFILE[key], findings);
76
+ }
77
+ if (!strictBase64Url(String(profile.nonceOrIv || ''), SECURE_TRANSPORT_V1_PROFILE.contentEncryptionParameters.nonceBytes)) findings.push(problem('invalid', 'secure-transport.profile.nonce-invalid', 'Transport profile nonce/IV must be exact base64url-no-padding bytes for the supported profile.'));
78
+ if (!/^[0-9a-f]{64}$/.test(String(envelope.workspaceBindingValue || ''))) findings.push(problem('invalid', 'secure-transport.workspace-binding.invalid', 'Workspace Binding Value must be lowercase SHA-256 hex.'));
79
+ const slots = Array.isArray(envelope.passwordRecipientSlots) ? envelope.passwordRecipientSlots : [];
80
+ if (!slots.length) findings.push(problem('invalid', 'secure-transport.slots.missing', 'At least one password recipient slot is required.'));
81
+ const ids = new Set();
82
+ for (const slot of slots) {
83
+ const slotId = String(slot?.slotId || '').trim();
84
+ if (!slotId || ids.has(slotId)) findings.push(problem('invalid', 'secure-transport.slot.id-invalid', 'Password recipient slot ids must be non-empty and unique.', { slotId }));
85
+ ids.add(slotId);
86
+ if (supportedProfile) {
87
+ const slotExpected = {
88
+ slotKind: SECURE_TRANSPORT_V1_PROFILE.slotKind,
89
+ kdfAlgorithm: SECURE_TRANSPORT_V1_PROFILE.kdfAlgorithm,
90
+ kdfSaltEncoding: SECURE_TRANSPORT_V1_PROFILE.kdfSaltEncoding,
91
+ kdfParameters: SECURE_TRANSPORT_V1_PROFILE.kdfParameters,
92
+ keyWrapAlgorithm: SECURE_TRANSPORT_V1_PROFILE.keyWrapAlgorithm,
93
+ keyWrapParameters: SECURE_TRANSPORT_V1_PROFILE.keyWrapParameters,
94
+ wrappedContentKeyEncoding: SECURE_TRANSPORT_V1_PROFILE.wrappedContentKeyEncoding,
95
+ slotVerificationRule: SECURE_TRANSPORT_V1_PROFILE.slotVerificationRule
96
+ };
97
+ for (const [key, expected] of Object.entries(slotExpected)) exactField(slot || {}, key, expected, findings);
98
+ }
99
+ if (!strictBase64Url(String(slot?.kdfSalt || ''), SECURE_TRANSPORT_V1_PROFILE.kdfParameters.saltBytes)) findings.push(problem('invalid', 'secure-transport.slot.salt-invalid', 'Password recipient slot salt must be exact base64url-no-padding bytes for the supported profile.', { slotId }));
100
+ if (!strictBase64Url(String(slot?.wrappedContentKey || ''), 40)) findings.push(problem('invalid', 'secure-transport.slot.wrapped-key-invalid', 'Wrapped content key must be a 40-byte RFC 3394 AES-KW result encoded as base64url-no-padding.', { slotId }));
101
+ if (Object.prototype.hasOwnProperty.call(slot || {}, 'password') || Object.prototype.hasOwnProperty.call(slot || {}, 'derivedKey') || Object.prototype.hasOwnProperty.call(slot || {}, 'contentKey')) findings.push(problem('invalid', 'secure-transport.slot.secret-field-forbidden', 'Durable envelope metadata must not contain password or key material.', { slotId }));
102
+ }
103
+ const invalid = findings.some((item) => item.kind === 'invalid');
104
+ const unsupported = !invalid && findings.some((item) => item.kind === 'unsupported');
105
+ return deepFreeze({ state: invalid ? 'invalid' : unsupported ? 'unsupported' : 'qualified', findings });
106
+ }
107
+
108
+ export async function sealPasswordWorkspacePayload(input = {}) {
109
+ const runtimeCrypto = resolveCrypto(input.crypto);
110
+ const plaintext = toUint8Array(input.plaintext);
111
+ const binding = String(input.workspaceBindingValue || '');
112
+ const recipients = normalizeRecipients(input.recipients);
113
+ if (!/^[0-9a-f]{64}$/.test(binding)) return failed('secure-transport.workspace-binding.invalid', 'Workspace Binding Value must be lowercase SHA-256 hex.');
114
+ if (!recipients.length) return failed('secure-transport.recipients.missing', 'At least one password recipient is required.');
115
+ if (hasEmptyPasswordRecipient(recipients)) return failed('secure-transport.password.empty', 'Password recipient slots require a non-empty password.');
116
+ const nonce = randomBytes(runtimeCrypto, SECURE_TRANSPORT_V1_PROFILE.contentEncryptionParameters.nonceBytes);
117
+ const contentKey = await runtimeCrypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
118
+ const envelopeBase = makeEnvelopeBase(binding, nonce);
119
+ const aad = authenticatedMetadataBytes(envelopeBase);
120
+ const encrypted = new Uint8Array(await runtimeCrypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: 128 }, contentKey, plaintext));
121
+ const slots = [];
122
+ for (const recipient of recipients) slots.push(await createSlot(runtimeCrypto, contentKey, recipient));
123
+ const envelope = deepFreeze({ ...envelopeBase, passwordRecipientSlots: Object.freeze(slots) });
124
+ const qualification = qualifySecureTransportV1Envelope(envelope);
125
+ if (qualification.state !== 'qualified') return deepFreeze({ state: 'failed', reason: 'generated-envelope-unqualified', findings: qualification.findings });
126
+ return deepFreeze({ state: 'sealed', protectedPayload: encrypted, envelope, profile: SECURE_TRANSPORT_V1_PROFILE, boundary: 'Ciphertext and non-secret opening metadata only; passwords, derived keys, wrapping keys, and plaintext content keys are not returned.' });
127
+ }
128
+
129
+ export async function openPasswordWorkspacePayload(input = {}) {
130
+ const qualification = qualifySecureTransportV1Envelope(input.envelope || {});
131
+ if (qualification.state === 'unsupported') return deepFreeze({ state: 'unsupported', reason: 'unsupported-profile', findings: qualification.findings });
132
+ if (qualification.state !== 'qualified') return deepFreeze({ state: 'failed', reason: 'malformed-metadata', findings: qualification.findings });
133
+ const runtimeCrypto = resolveCrypto(input.crypto);
134
+ const payload = toUint8Array(input.protectedPayload);
135
+ const password = normalizePassword(input.password);
136
+ const requestedSlotId = String(input.slotId || '').trim();
137
+ const allSlots = input.envelope.passwordRecipientSlots || [];
138
+ const slots = requestedSlotId ? allSlots.filter((slot) => String(slot.slotId || '') === requestedSlotId) : allSlots;
139
+ if (!slots.length) return deepFreeze({ state: 'locked', reason: requestedSlotId ? 'missing-authorized-slot' : 'no-password-slots' });
140
+ if (password.length === 0) return deepFreeze({ state: 'locked', reason: 'wrong-password' });
141
+ const nonce = decodeBase64Url(input.envelope.profile.nonceOrIv);
142
+ const aad = authenticatedMetadataBytes(input.envelope);
143
+ let unwrapped = false;
144
+ for (const slot of slots) {
145
+ let contentKey;
146
+ try {
147
+ const wrappingKey = await deriveWrappingKey(runtimeCrypto, password, slot);
148
+ contentKey = await runtimeCrypto.subtle.unwrapKey('raw', decodeBase64Url(slot.wrappedContentKey), wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
149
+ unwrapped = true;
150
+ } catch {
151
+ continue;
152
+ }
153
+ try {
154
+ const plaintext = new Uint8Array(await runtimeCrypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: 128 }, contentKey, payload));
155
+ return deepFreeze({ state: 'opened', plaintext, slotId: String(slot.slotId || ''), workspaceBindingValue: String(input.envelope.workspaceBindingValue || ''), boundary: 'Authenticated plaintext is transient return data; caller chooses whether to persist an explicit destination.' });
156
+ } catch {
157
+ return deepFreeze({ state: 'failed', reason: 'authentication-failed' });
158
+ }
159
+ }
160
+ return deepFreeze({ state: unwrapped ? 'failed' : 'locked', reason: unwrapped ? 'authentication-failed' : 'wrong-password' });
161
+ }
162
+
163
+ export async function replacePasswordWorkspaceRecipients(input = {}) {
164
+ const qualification = qualifySecureTransportV1Envelope(input.envelope || {});
165
+ if (qualification.state === 'unsupported') return deepFreeze({ state: 'unsupported', reason: 'unsupported-profile', findings: qualification.findings });
166
+ if (qualification.state !== 'qualified') return deepFreeze({ state: 'failed', reason: 'malformed-metadata', findings: qualification.findings });
167
+ const runtimeCrypto = resolveCrypto(input.crypto);
168
+ const recipients = normalizeRecipients(input.recipients);
169
+ if (!recipients.length) return failed('secure-transport.recipients.missing', 'At least one replacement password recipient is required.');
170
+ if (hasEmptyPasswordRecipient(recipients)) return failed('secure-transport.password.empty', 'Replacement password recipient slots require a non-empty password.');
171
+ const payload = toUint8Array(input.protectedPayload);
172
+ const password = normalizePassword(input.authorizationPassword ?? input.password);
173
+ if (password.length === 0) return deepFreeze({ state: 'locked', reason: 'wrong-password' });
174
+ const nonce = decodeBase64Url(input.envelope.profile.nonceOrIv);
175
+ const aad = authenticatedMetadataBytes(input.envelope);
176
+ let authorizedKey = null;
177
+ for (const slot of input.envelope.passwordRecipientSlots || []) {
178
+ try {
179
+ const wrappingKey = await deriveWrappingKey(runtimeCrypto, password, slot);
180
+ const key = await runtimeCrypto.subtle.unwrapKey('raw', decodeBase64Url(slot.wrappedContentKey), wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, true, ['decrypt']);
181
+ await runtimeCrypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: 128 }, key, payload);
182
+ authorizedKey = key;
183
+ break;
184
+ } catch { /* try another slot */ }
185
+ }
186
+ if (!authorizedKey) return deepFreeze({ state: 'locked', reason: 'wrong-password' });
187
+ const slots = [];
188
+ for (const recipient of recipients) slots.push(await createSlot(runtimeCrypto, authorizedKey, recipient));
189
+ const envelope = deepFreeze({ ...input.envelope, passwordRecipientSlots: Object.freeze(slots) });
190
+ const post = qualifySecureTransportV1Envelope(envelope);
191
+ if (post.state !== 'qualified') return deepFreeze({ state: 'failed', reason: 'generated-envelope-unqualified', findings: post.findings });
192
+ return deepFreeze({ state: 'rewrapped', protectedPayload: payload, envelope, boundary: 'Recipient-slot replacement authenticates the existing payload and preserves its exact bytes.' });
193
+ }
194
+
195
+ export function secureTransportV1AuthenticatedMetadata(input = {}) {
196
+ return new TextDecoder().decode(authenticatedMetadataBytes(input));
197
+ }
198
+
199
+ function makeEnvelopeBase(workspaceBindingValue, nonce) {
200
+ return deepFreeze({
201
+ ...SECURE_TRANSPORT_V1_ENVELOPE_CONTRACT,
202
+ workspaceBindingValue,
203
+ profile: deepFreeze({
204
+ profileId: SECURE_TRANSPORT_V1_PROFILE.profileId,
205
+ profileVersion: SECURE_TRANSPORT_V1_PROFILE.profileVersion,
206
+ contentEncryptionAlgorithm: SECURE_TRANSPORT_V1_PROFILE.contentEncryptionAlgorithm,
207
+ contentEncryptionParameters: SECURE_TRANSPORT_V1_PROFILE.contentEncryptionParameters,
208
+ nonceOrIvEncoding: SECURE_TRANSPORT_V1_PROFILE.nonceOrIvEncoding,
209
+ nonceOrIv: encodeBase64Url(nonce),
210
+ payloadAuthenticationRule: SECURE_TRANSPORT_V1_PROFILE.payloadAuthenticationRule,
211
+ securityMetadataAuthenticationRule: SECURE_TRANSPORT_V1_PROFILE.securityMetadataAuthenticationRule,
212
+ recipientChangePayloadRule: SECURE_TRANSPORT_V1_PROFILE.recipientChangePayloadRule
213
+ })
214
+ });
215
+ }
216
+
217
+ async function createSlot(runtimeCrypto, contentKey, recipient) {
218
+ const salt = randomBytes(runtimeCrypto, SECURE_TRANSPORT_V1_PROFILE.kdfParameters.saltBytes);
219
+ const slotTemplate = {
220
+ slotId: recipient.slotId,
221
+ slotKind: SECURE_TRANSPORT_V1_PROFILE.slotKind,
222
+ kdfAlgorithm: SECURE_TRANSPORT_V1_PROFILE.kdfAlgorithm,
223
+ kdfSaltEncoding: SECURE_TRANSPORT_V1_PROFILE.kdfSaltEncoding,
224
+ kdfSalt: encodeBase64Url(salt),
225
+ kdfParameters: SECURE_TRANSPORT_V1_PROFILE.kdfParameters,
226
+ keyWrapAlgorithm: SECURE_TRANSPORT_V1_PROFILE.keyWrapAlgorithm,
227
+ keyWrapParameters: SECURE_TRANSPORT_V1_PROFILE.keyWrapParameters,
228
+ wrappedContentKeyEncoding: SECURE_TRANSPORT_V1_PROFILE.wrappedContentKeyEncoding,
229
+ wrappedContentKey: '',
230
+ slotVerificationRule: SECURE_TRANSPORT_V1_PROFILE.slotVerificationRule,
231
+ ...(recipient.recipientHint ? { recipientHint: recipient.recipientHint } : {})
232
+ };
233
+ const wrappingKey = await deriveWrappingKey(runtimeCrypto, recipient.password, slotTemplate);
234
+ const wrapped = new Uint8Array(await runtimeCrypto.subtle.wrapKey('raw', contentKey, wrappingKey, 'AES-KW'));
235
+ return deepFreeze({ ...slotTemplate, wrappedContentKey: encodeBase64Url(wrapped) });
236
+ }
237
+
238
+ async function deriveWrappingKey(runtimeCrypto, password, slot) {
239
+ const passwordKey = await runtimeCrypto.subtle.importKey('raw', utf8Bytes(password), 'PBKDF2', false, ['deriveKey']);
240
+ return runtimeCrypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt: decodeBase64Url(slot.kdfSalt), iterations: SECURE_TRANSPORT_V1_PROFILE.kdfParameters.iterations }, passwordKey, { name: 'AES-KW', length: 256 }, false, ['wrapKey', 'unwrapKey']);
241
+ }
242
+
243
+ function authenticatedMetadataBytes(envelope = {}) {
244
+ const profile = envelope.profile || {};
245
+ const bound = {
246
+ envelopePurpose: envelope.envelopePurpose,
247
+ envelopeVersion: envelope.envelopeVersion,
248
+ plaintextRepresentationKind: envelope.plaintextRepresentationKind,
249
+ protectedNameTree: envelope.protectedNameTree,
250
+ workspaceBindingMethod: envelope.workspaceBindingMethod,
251
+ workspaceBindingValue: envelope.workspaceBindingValue,
252
+ contentKeyScope: envelope.contentKeyScope,
253
+ profileId: profile.profileId,
254
+ profileVersion: profile.profileVersion,
255
+ contentEncryptionAlgorithm: profile.contentEncryptionAlgorithm,
256
+ contentEncryptionParameters: profile.contentEncryptionParameters,
257
+ nonceOrIvEncoding: profile.nonceOrIvEncoding,
258
+ nonceOrIv: profile.nonceOrIv,
259
+ payloadAuthenticationRule: profile.payloadAuthenticationRule,
260
+ securityMetadataAuthenticationRule: profile.securityMetadataAuthenticationRule,
261
+ recipientChangePayloadRule: profile.recipientChangePayloadRule
262
+ };
263
+ return utf8Bytes(stableJson(bound));
264
+ }
265
+
266
+ function normalizeRecipients(input) {
267
+ const source = Array.isArray(input) ? input : [];
268
+ const seen = new Set();
269
+ const out = [];
270
+ for (const item of source) {
271
+ const slotId = String(item?.slotId || '').trim();
272
+ if (!slotId || seen.has(slotId)) continue;
273
+ seen.add(slotId);
274
+ out.push({ slotId, password: normalizePassword(item?.password), recipientHint: String(item?.recipientHint || '').trim() });
275
+ }
276
+ return out;
277
+ }
278
+ function normalizePassword(value) { return String(value ?? ''); }
279
+ function hasEmptyPasswordRecipient(recipients = []) { return recipients.some((recipient) => recipient.password.length === 0); }
280
+ function resolveCrypto(candidate) {
281
+ const value = candidate || globalThis.crypto;
282
+ if (!value?.subtle || typeof value.getRandomValues !== 'function') throw new Error('secure-transport.webcrypto-unavailable');
283
+ return value;
284
+ }
285
+ function randomBytes(runtimeCrypto, length) { const bytes = new Uint8Array(length); runtimeCrypto.getRandomValues(bytes); return bytes; }
286
+ function positiveInteger(value) { return Number.isInteger(Number(value)) && Number(value) > 0; }
287
+ function exactField(object, key, expected, findings) {
288
+ const observed = object?.[key];
289
+ if (stableJson(observed) !== stableJson(expected)) findings.push(problem('invalid', `secure-transport.field.${key}.invalid`, 'Secure Transport V1 closed-domain field is missing or invalid.', { field: key, expected, observed }));
290
+ }
291
+ function problem(kind, code, message, extra = {}) { return deepFreeze({ kind, code, message, ...extra }); }
292
+ function failed(code, message) { return deepFreeze({ state: 'failed', reason: code, findings: [problem('invalid', code, message)] }); }
293
+ function strictBase64Url(value, expectedBytes) {
294
+ if (!/^[A-Za-z0-9_-]+$/.test(value) || value.includes('=')) return false;
295
+ try { return decodeBase64Url(value).byteLength === expectedBytes && encodeBase64Url(decodeBase64Url(value)) === value; } catch { return false; }
296
+ }
297
+ function encodeBase64Url(bytesInput) {
298
+ const bytes = toUint8Array(bytesInput);
299
+ let binary = '';
300
+ for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
301
+ const base64 = typeof btoa === 'function' ? btoa(binary) : encodeBase64(binary);
302
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
303
+ }
304
+ function decodeBase64Url(value) {
305
+ const text = String(value || '');
306
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) throw new Error('invalid-base64url');
307
+ const base64 = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4);
308
+ if (typeof atob === 'function') {
309
+ const binary = atob(base64);
310
+ const out = new Uint8Array(binary.length);
311
+ for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
312
+ return out;
313
+ }
314
+ return decodeBase64(base64);
315
+ }
316
+ function encodeBase64(binary) {
317
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
318
+ let out = '';
319
+ for (let index = 0; index < binary.length; index += 3) {
320
+ const a = binary.charCodeAt(index), b = index + 1 < binary.length ? binary.charCodeAt(index + 1) : 0, c = index + 2 < binary.length ? binary.charCodeAt(index + 2) : 0;
321
+ const n = (a << 16) | (b << 8) | c;
322
+ out += alphabet[(n >>> 18) & 63] + alphabet[(n >>> 12) & 63] + (index + 1 < binary.length ? alphabet[(n >>> 6) & 63] : '=') + (index + 2 < binary.length ? alphabet[n & 63] : '=');
323
+ }
324
+ return out;
325
+ }
326
+ function decodeBase64(base64) {
327
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
328
+ const clean = String(base64 || '').replace(/=+$/g, '');
329
+ let buffer = 0, bits = 0; const out = [];
330
+ for (const char of clean) {
331
+ const index = alphabet.indexOf(char); if (index < 0) throw new Error('invalid-base64');
332
+ buffer = (buffer << 6) | index; bits += 6;
333
+ if (bits >= 8) { bits -= 8; out.push((buffer >>> bits) & 0xff); }
334
+ }
335
+ return Uint8Array.from(out);
336
+ }
337
+ function stableJson(value) { return JSON.stringify(sortJson(value)); }
338
+ function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); }
339
+ function deepFreeze(value) { if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value; for (const child of Object.values(value)) deepFreeze(child); return Object.freeze(value); }
@@ -1,186 +0,0 @@
1
- import path from 'node:path';
2
- import { access, readFile, readdir } from 'node:fs/promises';
3
- import { sha256Hex } from '../../../export/package.bytes.js';
4
- import { inspectStoredWorkspaceArchive } from './workspaceByteProvider.js';
5
- import { parseHandoffPackageV1 } from './recipientV2.packageV1.contract.js';
6
- import { currentSchemaId, decodeUtf8, deepFreeze, dedupeFindings } from './recipientV2.packageV1.shared.js';
7
- import { finding } from './recipientV2.topology.materials.js';
8
-
9
- export async function compareSourceFrontiers(input = {}) {
10
- const leftKind = String(input.leftKind || '').trim();
11
- const rightKind = String(input.rightKind || '').trim();
12
- const leftRoot = String(input.left || '').trim();
13
- const rightPath = String(input.right || '').trim();
14
- const workspaceId = String(input.rightSelect || input.leftId || '').trim();
15
- const findings = [];
16
-
17
- if (leftKind !== 'local-workspace') findings.push(finding('error', 'portable.source-frontier.left-kind-invalid', 'Source frontier comparison requires a local workspace on the left side.', { observed: leftKind }));
18
- if (rightKind !== 'handoff-package') findings.push(finding('error', 'portable.source-frontier.right-kind-invalid', 'Source frontier comparison requires a handoff package on the right side.', { observed: rightKind }));
19
- if (!leftRoot) findings.push(finding('error', 'portable.source-frontier.left-missing', 'Source frontier comparison requires a local workspace root.', { side: 'left' }));
20
- if (!rightPath) findings.push(finding('error', 'portable.source-frontier.right-missing', 'Source frontier comparison requires a handoff package path.', { side: 'right' }));
21
- if (!workspaceId) findings.push(finding('error', 'portable.source-frontier.workspace-id-missing', 'Source frontier comparison requires a selected workspace id.', { side: 'right-select' }));
22
- if (findings.length) return blocked(findings);
23
-
24
- try {
25
- await access(leftRoot);
26
- } catch {
27
- findings.push(finding('error', 'portable.source-frontier.left-unavailable', 'The local workspace root is not available.', { path: leftRoot }));
28
- return blocked(findings);
29
- }
30
-
31
- let packageBytes;
32
- try {
33
- packageBytes = await readFile(rightPath);
34
- } catch {
35
- findings.push(finding('error', 'portable.source-frontier.right-unavailable', 'The handoff package path is not available.', { path: rightPath }));
36
- return blocked(findings);
37
- }
38
-
39
- const packageArchive = inspectStoredWorkspaceArchive(packageBytes, { ownedBytes: true });
40
- if (packageArchive.state !== 'qualified') {
41
- findings.push(finding('error', 'portable.source-frontier.package-unqualified', 'The supplied handoff package is not a qualified stored ZIP archive.', { path: rightPath }));
42
- findings.push(...packageArchive.findings);
43
- return blocked(findings);
44
- }
45
-
46
- const rootEntry = (packageArchive.entries || []).find((entry) => currentSchemaId(decodeUtf8(entry.data)) === 'tiinex.handoff.package.v1') || null;
47
- if (!rootEntry) {
48
- findings.push(finding('error', 'portable.source-frontier.package-root-missing', 'The handoff package does not expose a readable package-v1 root artifact.', { path: rightPath }));
49
- return blocked(findings);
50
- }
51
-
52
- const packageRoot = parseHandoffPackageV1(decodeUtf8(rootEntry.data));
53
- const binding = (packageRoot.workspaces || []).find((item) => String(item.workspaceId || '') === workspaceId) || null;
54
- if (!binding) {
55
- findings.push(finding('error', 'portable.source-frontier.workspace-missing', 'The selected workspace id is not bound by the supplied handoff package.', { workspaceId }));
56
- return blocked(findings);
57
- }
58
- if (!binding.snapshotPath) {
59
- findings.push(finding('error', 'portable.source-frontier.snapshot-path-missing', 'The selected workspace binding does not declare a snapshot path.', { workspaceId }));
60
- return blocked(findings);
61
- }
62
-
63
- const packageEntries = indexEntries(packageArchive.entries || []);
64
- const snapshotEntry = packageEntries.get(binding.snapshotPath) || null;
65
- if (!snapshotEntry) {
66
- findings.push(finding('error', 'portable.source-frontier.snapshot-missing', 'The selected workspace snapshot is not present in the supplied handoff package.', { workspaceId, path: binding.snapshotPath }));
67
- return blocked(findings);
68
- }
69
-
70
- const snapshotArchive = inspectStoredWorkspaceArchive(snapshotEntry.data, { ownedBytes: true });
71
- if (snapshotArchive.state !== 'qualified') {
72
- findings.push(finding('error', 'portable.source-frontier.snapshot-unqualified', 'The selected workspace snapshot is not a qualified stored ZIP archive.', { workspaceId, path: binding.snapshotPath }));
73
- findings.push(...snapshotArchive.findings);
74
- return blocked(findings);
75
- }
76
-
77
- const localFiles = await collectLocalFiles(leftRoot);
78
- const snapshotFiles = collectArchiveFiles(snapshotArchive.entries || []);
79
- const comparison = compareFileSets(localFiles, snapshotFiles);
80
- const state = comparison.counts.total === 0 ? 'exact' : 'changed';
81
-
82
- return deepFreeze({
83
- schema: 'tiinex.portable.source-frontier.compare.v1',
84
- status: 'ready',
85
- state,
86
- mode: 'two-way',
87
- workspaces: Object.freeze([
88
- Object.freeze({
89
- workspaceId,
90
- state,
91
- delta: Object.freeze({
92
- counts: Object.freeze(comparison.counts),
93
- added: Object.freeze(comparison.added),
94
- removed: Object.freeze(comparison.removed),
95
- byteChanged: Object.freeze(comparison.byteChanged)
96
- })
97
- })
98
- ]),
99
- findings: Object.freeze([]),
100
- boundary: 'Compares one local workspace root against the exact carried workspace snapshot bytes bound by a recipient-facing handoff package. It reports only file addition, removal, and byte-change counts and fails closed when the selected workspace binding or snapshot archive is unavailable.'
101
- });
102
- }
103
-
104
- function blocked(findings = []) {
105
- const all = dedupeFindings(findings);
106
- return deepFreeze({
107
- schema: 'tiinex.portable.source-frontier.compare.v1',
108
- status: 'blocked',
109
- state: 'blocked',
110
- mode: 'two-way',
111
- workspaces: Object.freeze([]),
112
- findings: Object.freeze(all),
113
- boundary: 'Source frontier comparison failed closed before any comparison result was emitted.'
114
- });
115
- }
116
-
117
- async function collectLocalFiles(root, current = root, prefix = '') {
118
- const files = [];
119
- const entries = await readdir(current, { withFileTypes: true });
120
- for (const entry of entries) {
121
- if (!prefix && entry.name === '.git') continue;
122
- const relative = normalizePath(prefix ? `${prefix}/${entry.name}` : entry.name);
123
- const absolute = path.join(current, entry.name);
124
- if (entry.isDirectory()) {
125
- const nested = await collectLocalFiles(root, absolute, relative);
126
- files.push(...nested);
127
- continue;
128
- }
129
- if (!entry.isFile()) continue;
130
- const data = await readFile(absolute);
131
- files.push(Object.freeze({ path: relative, bytes: data.byteLength, sha256: sha256Hex(data) }));
132
- }
133
- return files.sort((a, b) => a.path.localeCompare(b.path));
134
- }
135
-
136
- function collectArchiveFiles(entries = []) {
137
- return entries
138
- .filter((entry) => !isIgnoredPath(entry.path || ''))
139
- .map((entry) => Object.freeze({ path: normalizePath(entry.path || ''), bytes: Number(entry.bytes || 0), sha256: String(entry.sha256 || '').toLowerCase() }))
140
- .filter((entry) => entry.path)
141
- .sort((a, b) => a.path.localeCompare(b.path));
142
- }
143
-
144
- function compareFileSets(left = [], right = []) {
145
- const leftByPath = new Map(left.map((entry) => [entry.path, entry]));
146
- const rightByPath = new Map(right.map((entry) => [entry.path, entry]));
147
- const paths = [...new Set([...leftByPath.keys(), ...rightByPath.keys()])].sort();
148
- const added = [];
149
- const removed = [];
150
- const byteChanged = [];
151
-
152
- for (const filePath of paths) {
153
- const local = leftByPath.get(filePath) || null;
154
- const remote = rightByPath.get(filePath) || null;
155
- if (!local && remote) { added.push(filePath); continue; }
156
- if (local && !remote) { removed.push(filePath); continue; }
157
- if (local && remote && (local.bytes !== remote.bytes || local.sha256 !== remote.sha256)) byteChanged.push(filePath);
158
- }
159
-
160
- return {
161
- added,
162
- removed,
163
- byteChanged,
164
- counts: {
165
- added: added.length,
166
- removed: removed.length,
167
- byteChanged: byteChanged.length,
168
- total: added.length + removed.length + byteChanged.length
169
- }
170
- };
171
- }
172
-
173
- function indexEntries(entries = []) {
174
- const index = new Map();
175
- for (const entry of entries) index.set(normalizePath(entry.path || ''), entry);
176
- return index;
177
- }
178
-
179
- function normalizePath(value = '') {
180
- return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/g, '').split('/').filter((part) => part && part !== '.').join('/');
181
- }
182
-
183
- function isIgnoredPath(value = '') {
184
- const normalized = normalizePath(value);
185
- return !normalized || normalized === '.git' || normalized.startsWith('.git/');
186
- }