delegation-authority-compat.mjs
367 lines 11.5 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 1 day ago
1 /**
2 * Marker-aware delegation authority compatibility reader (RHF-b-KN0).
3 *
4 * Absent marker → legacy file stores. Present marker → envelope-only reads with
5 * fail-closed validation. Unknown/missing/mismatched marker or envelope → unavailable.
6 *
7 * @see ~/scooling/docs/reviews/2026-08-27-retail-helper-finish.md §B2 migration step 1
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import { createHash } from 'crypto';
13
14 export const DELEGATION_AUTHORITY_MARKER_SCHEMA = 'knowtation.delegation_authority_marker/v1';
15 export const DELEGATION_AUTHORITY_ENVELOPE_SCHEMA = 'knowtation.delegation_authority_envelope/v1';
16 export const DELEGATION_AUTHORITY_UNAVAILABLE = 'DELEGATION_AUTHORITY_UNAVAILABLE';
17
18 const MARKER_SCHEMA_VERSION = 1;
19 const ENVELOPE_SCHEMA_VERSION = 1;
20 const SHA256_PREFIX = 'sha256:';
21
22 /**
23 * @param {string} vaultId
24 * @returns {string}
25 */
26 export function delegationAuthorityMarkerBlobKey(vaultId) {
27 return `delegation/authority/v1/${encodeURIComponent(vaultId)}/marker`;
28 }
29
30 /**
31 * @param {string} vaultId
32 * @returns {string}
33 */
34 export function delegationAuthorityMarkerFileName(vaultId) {
35 return `hub_delegation_authority_marker_${vaultId}.json`;
36 }
37
38 /**
39 * @param {unknown} value
40 * @returns {boolean}
41 */
42 function isNonEmptyString(value) {
43 return typeof value === 'string' && value.trim().length > 0;
44 }
45
46 /**
47 * @param {unknown} value
48 * @returns {boolean}
49 */
50 function isSha256Prefixed(value) {
51 return isNonEmptyString(value) && String(value).startsWith(SHA256_PREFIX);
52 }
53
54 /**
55 * Canonical JSON hash excluding `state_hash`.
56 *
57 * @param {object} envelope
58 * @returns {string}
59 */
60 export function computeDelegationAuthorityStateHash(envelope) {
61 const clone = { ...envelope };
62 delete clone.state_hash;
63 /** @type {Record<string, unknown>} */
64 const sorted = {};
65 for (const key of Object.keys(clone).sort()) {
66 sorted[key] = clone[key];
67 }
68 const canonical = JSON.stringify(sorted);
69 return `${SHA256_PREFIX}${createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
70 }
71
72 /**
73 * @param {object} marker
74 * @param {string} vaultId
75 * @returns {{ ok: true } | { ok: false, reason: string }}
76 */
77 export function validateDelegationAuthorityMarker(marker, vaultId) {
78 if (!marker || typeof marker !== 'object') return { ok: false, reason: 'marker missing' };
79 if (marker.schema !== DELEGATION_AUTHORITY_MARKER_SCHEMA) {
80 return { ok: false, reason: 'marker schema mismatch' };
81 }
82 if (marker.vault_id !== vaultId) return { ok: false, reason: 'marker vault mismatch' };
83 if (marker.envelope_schema_version !== MARKER_SCHEMA_VERSION) {
84 return { ok: false, reason: 'marker envelope version unknown' };
85 }
86 if (!isNonEmptyString(marker.envelope_key)) return { ok: false, reason: 'marker envelope_key missing' };
87 if (!isNonEmptyString(marker.lineage_id)) return { ok: false, reason: 'marker lineage missing' };
88 if (!isSha256Prefixed(marker.origin_snapshot_hash)) {
89 return { ok: false, reason: 'marker origin hash invalid' };
90 }
91 return { ok: true };
92 }
93
94 /**
95 * @param {object} envelope
96 * @param {object} marker
97 * @param {string} vaultId
98 * @returns {{ ok: true } | { ok: false, reason: string }}
99 */
100 export function validateDelegationAuthorityEnvelope(envelope, marker, vaultId) {
101 if (!envelope || typeof envelope !== 'object') return { ok: false, reason: 'envelope missing' };
102 if (envelope.schema !== DELEGATION_AUTHORITY_ENVELOPE_SCHEMA) {
103 return { ok: false, reason: 'envelope schema mismatch' };
104 }
105 if (envelope.schema_version !== ENVELOPE_SCHEMA_VERSION) {
106 return { ok: false, reason: 'envelope version unknown' };
107 }
108 if (envelope.vault_id !== vaultId) return { ok: false, reason: 'envelope vault mismatch' };
109 if (envelope.lineage_id !== marker.lineage_id) {
110 return { ok: false, reason: 'envelope lineage mismatch' };
111 }
112 if (envelope.origin_snapshot_hash !== marker.origin_snapshot_hash) {
113 return { ok: false, reason: 'envelope origin hash mismatch' };
114 }
115 if (typeof envelope.revision !== 'number' || envelope.revision < 0) {
116 return { ok: false, reason: 'envelope revision invalid' };
117 }
118 if (!isSha256Prefixed(envelope.state_hash)) {
119 return { ok: false, reason: 'envelope state hash invalid' };
120 }
121 const expected = computeDelegationAuthorityStateHash(envelope);
122 if (expected !== envelope.state_hash) {
123 return { ok: false, reason: 'envelope state hash mismatch' };
124 }
125 if (envelope.previous_state_hash != null && !isSha256Prefixed(envelope.previous_state_hash)) {
126 return { ok: false, reason: 'envelope previous hash invalid' };
127 }
128 for (const field of ['identities_by_id', 'consents_by_id', 'grants_by_id']) {
129 const map = envelope[field];
130 if (map == null) continue;
131 if (typeof map !== 'object' || Array.isArray(map)) {
132 return { ok: false, reason: `${field} invalid` };
133 }
134 }
135 return { ok: true };
136 }
137
138 /**
139 * @param {{
140 * dataDir: string,
141 * vaultId: string,
142 * blobStore?: { get?: (key: string, opts?: { type?: string }) => Promise<string|null> } | null,
143 * }} input
144 * @returns {Promise<string|null>}
145 */
146 async function readMarkerRaw(input) {
147 const { dataDir, vaultId, blobStore } = input;
148 const blobKey = delegationAuthorityMarkerBlobKey(vaultId);
149 if (blobStore && typeof blobStore.get === 'function') {
150 try {
151 const fromBlob = await blobStore.get(blobKey, { type: 'text' });
152 if (typeof fromBlob === 'string' && fromBlob.trim()) return fromBlob;
153 } catch {
154 return null;
155 }
156 }
157 const fp = path.join(dataDir, delegationAuthorityMarkerFileName(vaultId));
158 try {
159 if (!fs.existsSync(fp)) return null;
160 const raw = fs.readFileSync(fp, 'utf8');
161 return raw.trim() ? raw : null;
162 } catch {
163 return null;
164 }
165 }
166
167 /**
168 * @param {{
169 * dataDir: string,
170 * vaultId: string,
171 * envelopeKey: string,
172 * blobStore?: { get?: (key: string, opts?: { type?: string }) => Promise<string|null> } | null,
173 * }} input
174 * @returns {Promise<string|null>}
175 */
176 async function readEnvelopeRaw(input) {
177 const { dataDir, vaultId, envelopeKey, blobStore } = input;
178 if (blobStore && typeof blobStore.get === 'function') {
179 try {
180 const fromBlob = await blobStore.get(envelopeKey, { type: 'text' });
181 if (typeof fromBlob === 'string' && fromBlob.trim()) return fromBlob;
182 } catch {
183 return null;
184 }
185 }
186 const localName = path.basename(envelopeKey);
187 const fp = path.join(dataDir, localName || `hub_delegation_authority_envelope_${vaultId}.json`);
188 try {
189 if (!fs.existsSync(fp)) return null;
190 const raw = fs.readFileSync(fp, 'utf8');
191 return raw.trim() ? raw : null;
192 } catch {
193 return null;
194 }
195 }
196
197 /**
198 * Resolve whether authority reads use legacy stores or a validated envelope.
199 *
200 * @param {{
201 * dataDir: string,
202 * vaultId: string,
203 * blobStore?: { get?: (key: string, opts?: { type?: string }) => Promise<string|null> } | null,
204 * }} input
205 * @returns {Promise<
206 * | { ok: true, mode: 'legacy' }
207 * | { ok: true, mode: 'envelope', marker: object, envelope: object }
208 * | { ok: false, code: typeof DELEGATION_AUTHORITY_UNAVAILABLE }
209 * >}
210 */
211 /**
212 * Synchronous local-filesystem authority read mode (legacy delegation index paths).
213 *
214 * @param {{ dataDir: string, vaultId: string }} input
215 * @returns {
216 * | { ok: true, mode: 'legacy' }
217 * | { ok: true, mode: 'envelope', marker: object, envelope: object }
218 * | { ok: false, code: typeof DELEGATION_AUTHORITY_UNAVAILABLE }
219 * }
220 */
221 export function resolveDelegationAuthorityReadModeSync(input) {
222 const vaultId = typeof input.vaultId === 'string' ? input.vaultId.trim() : '';
223 if (!vaultId) {
224 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
225 }
226
227 const fp = path.join(input.dataDir, delegationAuthorityMarkerFileName(vaultId));
228 if (!fs.existsSync(fp)) {
229 return { ok: true, mode: 'legacy' };
230 }
231
232 let markerRaw;
233 try {
234 markerRaw = fs.readFileSync(fp, 'utf8');
235 } catch {
236 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
237 }
238 if (!markerRaw.trim()) {
239 return { ok: true, mode: 'legacy' };
240 }
241
242 let marker;
243 try {
244 marker = JSON.parse(markerRaw);
245 } catch {
246 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
247 }
248
249 const markerValid = validateDelegationAuthorityMarker(marker, vaultId);
250 if (!markerValid.ok) {
251 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
252 }
253
254 const localName = path.basename(marker.envelope_key);
255 const envelopeFp = path.join(input.dataDir, localName || `hub_delegation_authority_envelope_${vaultId}.json`);
256 if (!fs.existsSync(envelopeFp)) {
257 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
258 }
259
260 let envelopeRaw;
261 try {
262 envelopeRaw = fs.readFileSync(envelopeFp, 'utf8');
263 } catch {
264 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
265 }
266
267 let envelope;
268 try {
269 envelope = JSON.parse(envelopeRaw);
270 } catch {
271 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
272 }
273
274 const envelopeValid = validateDelegationAuthorityEnvelope(envelope, marker, vaultId);
275 if (!envelopeValid.ok) {
276 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
277 }
278
279 return { ok: true, mode: 'envelope', marker, envelope };
280 }
281
282 export async function resolveDelegationAuthorityReadMode(input) {
283 const vaultId = typeof input.vaultId === 'string' ? input.vaultId.trim() : '';
284 if (!vaultId) {
285 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
286 }
287
288 const markerRaw = await readMarkerRaw(input);
289 if (!markerRaw) {
290 return { ok: true, mode: 'legacy' };
291 }
292
293 let marker;
294 try {
295 marker = JSON.parse(markerRaw);
296 } catch {
297 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
298 }
299
300 const markerValid = validateDelegationAuthorityMarker(marker, vaultId);
301 if (!markerValid.ok) {
302 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
303 }
304
305 const envelopeRaw = await readEnvelopeRaw({
306 dataDir: input.dataDir,
307 vaultId,
308 envelopeKey: marker.envelope_key,
309 blobStore: input.blobStore ?? null,
310 });
311 if (!envelopeRaw) {
312 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
313 }
314
315 let envelope;
316 try {
317 envelope = JSON.parse(envelopeRaw);
318 } catch {
319 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
320 }
321
322 const envelopeValid = validateDelegationAuthorityEnvelope(envelope, marker, vaultId);
323 if (!envelopeValid.ok) {
324 return { ok: false, code: DELEGATION_AUTHORITY_UNAVAILABLE };
325 }
326
327 return { ok: true, mode: 'envelope', marker, envelope };
328 }
329
330 /**
331 * @param {object|null|undefined} envelope
332 * @param {string} agentId
333 * @returns {object|null}
334 */
335 export function getEnvelopeStoredIdentity(envelope, agentId) {
336 if (!envelope || typeof envelope !== 'object') return null;
337 const map = envelope.identities_by_id;
338 if (!map || typeof map !== 'object') return null;
339 const record = map[agentId];
340 return record && typeof record === 'object' ? record : null;
341 }
342
343 /**
344 * @param {object|null|undefined} envelope
345 * @param {string} consentId
346 * @returns {object|null}
347 */
348 export function getEnvelopeStoredConsent(envelope, consentId) {
349 if (!envelope || typeof envelope !== 'object') return null;
350 const map = envelope.consents_by_id;
351 if (!map || typeof map !== 'object') return null;
352 const record = map[consentId];
353 return record && typeof record === 'object' ? record : null;
354 }
355
356 /**
357 * @param {object|null|undefined} envelope
358 * @param {string} grantId
359 * @returns {object|null}
360 */
361 export function getEnvelopeStoredGrant(envelope, grantId) {
362 if (!envelope || typeof envelope !== 'object') return null;
363 const map = envelope.grants_by_id;
364 if (!map || typeof map !== 'object') return null;
365 const record = map[grantId];
366 return record && typeof record === 'object' ? record : null;
367 }
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 1 day ago