@tiinex/core 0.2.0 → 0.4.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/package.json +8 -6
- package/src/public/index.js +21 -0
- package/src/public/node.js +1 -0
- package/src/release/plan.mjs +18 -5
- package/src/release/run.mjs +7 -5
- package/src/tooling/portable/adapters/cli/cli.command-input.js +3 -0
- package/src/tooling/portable/adapters/cli/cli.common-output.js +23 -0
- package/src/tooling/portable/adapters/cli/cli.handoff-manufacture.js +2 -0
- package/src/tooling/portable/adapters/cli/cli.help.js +18 -2
- package/src/tooling/portable/adapters/cli/cli.run.js +1 -1
- package/src/tooling/portable/adapters/cli/cli.source-frontier-comparison.js +50 -0
- package/src/tooling/portable/adapters/node/handoff.manufacture.js +14 -0
- package/src/tooling/portable/adapters/node/handoff.manufacture.packageParent.js +80 -16
- package/src/tooling/portable/adapters/node/handoff.manufacture.requirements.js +44 -11
- package/src/tooling/portable/adapters/node/handoff.manufacture.scope.js +96 -1
- package/src/tooling/portable/adapters/node/sourceFrontierComparison.js +194 -0
- package/src/tooling/portable/comparison/sourceFrontierComparison.js +501 -0
- package/src/tooling/portable/grounding/grounding.readiness.js +8 -4
- package/src/tooling/portable/grounding/grounding.readiness.support.js +63 -1
- package/src/tooling/portable/handoff/contextAudit.js +21 -1
- package/src/tooling/portable/handoff/materialClosure.descriptor.js +1 -1
- package/src/tooling/portable/handoff/materialClosure.materials.js +1 -1
- package/src/tooling/portable/handoff/recipientV2.artifacts.js +1 -1
- package/src/tooling/portable/handoff/recipientV2.inspect.helpers.js +6 -1
- package/src/tooling/portable/handoff/recipientV2.packageV1.build.js +93 -19
- package/src/tooling/portable/handoff/recipientV2.packageV1.contract.js +30 -1
- package/src/tooling/portable/handoff/recipientV2.packageV1.inspect.helpers.js +8 -2
- package/src/tooling/portable/handoff/recipientV2.packageV1.inspect.js +122 -10
- package/src/tooling/portable/handoff/recipientV2.packageV1.js +2 -1
- package/src/tooling/portable/handoff/recipientV2.packageV1.secure.js +87 -0
- package/src/tooling/portable/handoff/recipientV2.packageV1.shared.js +9 -1
- package/src/tooling/portable/handoff/recipientV2.topology.js +2 -2
- package/src/tooling/portable/handoff/recipientV2.topology.materials.js +9 -1
- package/src/tooling/portable/handoff/transportEnvelopeV1.js +76 -0
- package/src/tooling/portable/index.js +4 -0
- package/src/tooling/portable/operation.catalog.js +8 -0
- package/src/transport/secureTransportV1.js +339 -0
|
@@ -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); }
|