/** * Concatenated into /boot/init.js before init-main.js by scripts/bundle-kernel-init.mjs. * kernel.ext.d: topological sort, signer pins, multisig gate, runKernelExtDropins. * Depends on: BARE_OS_BOOT_TXN_STATE (boot preamble), bareOsPearMultisigShapeOk (00-pear-multisig-shape), * semverGte from 20-init-boot-policy.js; bootStructuredLog/bootDryRun from init-main.js (same AsyncFunction body; hoisted). * @see kernel/lib/init/STRUCTURE.md */ /** * @param {{ file: string, extId: string, scripts: string[], dependsOn: string[], signaturePointer?: string }[]} entries * @returns {{ ok: true, ordered: typeof entries } | { ok: false, cycleExtIds: string[], cycleEdges: string[] }} */ function topologicalOrderKernelExtEntries(entries) { const sorted = [...entries].sort((a, b) => a.file.localeCompare(b.file)) const idToFile = new Map() for (const e of sorted) { if (!idToFile.has(e.extId)) idToFile.set(e.extId, e.file) } /** @type {Map} */ const adj = new Map() /** @type {Map} */ const indeg = new Map() for (const e of sorted) indeg.set(e.file, 0) for (const e of sorted) { for (const dep of e.dependsOn) { const from = idToFile.get(dep) if (!from || from === e.file) continue if (!adj.has(from)) adj.set(from, []) adj.get(from).push(e.file) indeg.set(e.file, (indeg.get(e.file) || 0) + 1) } } /** @type {string[]} */ const q = sorted .map((e) => e.file) .filter((f) => (indeg.get(f) || 0) === 0) .sort() /** @type {string[]} */ const out = [] while (q.length) { const f = q.shift() out.push(f) for (const to of adj.get(f) || []) { indeg.set(to, (indeg.get(to) || 0) - 1) if (indeg.get(to) === 0) { q.push(to) q.sort() } } } if (out.length !== sorted.length) { const stuck = sorted.filter((e) => !out.includes(e.file)) const stuckIds = new Set(stuck.map((e) => e.extId)) /** @type {string[]} */ const cycleEdges = [] for (const e of stuck) { for (const dep of e.dependsOn) { if (stuckIds.has(dep)) cycleEdges.push(String(e.extId) + ' -> dependsOn:' + String(dep)) } } cycleEdges.sort() return { ok: false, cycleExtIds: stuck.map((e) => e.extId), cycleEdges: cycleEdges.slice(0, 48) } } const byFile = new Map(sorted.map((e) => [e.file, e])) return { ok: true, ordered: out.map((f) => byFile.get(f)).filter(Boolean) } } /** * @param {{ extId: string, dependsOn: string[] }[]} entries * @returns {number} */ function kernelExtDependencyDepth(entries) { const idToEntry = new Map(entries.map((e) => [e.extId, e])) const memo = new Map() /** * @param {string} id * @param {Set} stack */ function depth(id, stack) { if (memo.has(id)) return memo.get(id) if (stack.has(id)) return 99 const e = idToEntry.get(id) if (!e) return 0 stack.add(id) let d = 0 for (const dep of e.dependsOn) { d = Math.max(d, depth(dep, stack) + 1) } stack.delete(id) memo.set(id, d) return d } let max = 0 for (const e of entries) { max = Math.max(max, depth(e.extId, new Set())) } return max } /** * Merge extension signer pin maps from boot policy env mirrors (V2–V5; later JSON wins per key). * @param {Record | null | undefined} env * @returns {Record} */ function mergeExtensionSignerPinsFromEnv(env) { if (!env || typeof env !== 'object') return {} const keys = [ 'BARE_OS_BOOT_POLICY_EXTENSION_SIGNER_PINS_V2_JSON', 'BARE_OS_BOOT_POLICY_EXTENSION_SIGNER_PINS_V3_JSON', 'BARE_OS_BOOT_POLICY_EXTENSION_SIGNER_PINS_V4_JSON', 'BARE_OS_BOOT_POLICY_EXTENSION_SIGNER_PINS_V5_JSON' ] /** @type {Record} */ const out = {} for (const k of keys) { const raw = String(env[k] ?? '').trim() if (!raw) continue try { const o = JSON.parse(raw) if (o && typeof o === 'object' && !Array.isArray(o)) { for (const [ik, iv] of Object.entries(o)) { out[String(ik)] = /** @type {string | string[]} */ (iv) } } } catch { /* ignore malformed JSON */ } } return out } /** * When boot.policy pins an extension id to Ed25519 key(s), verify detached signature over script bytes. * @param {Record} ctx * @param {{ extId: string, file: string, signaturePointer?: string }} ent * @param {string} imgPath * @param {Record} pins * @param {boolean} strictPol * @returns {Promise} true if the script may run */ async function verifyKernelExtSignerPinsForScript( ctx, ent, imgPath, pins, strictPol ) { const id = String(ent.extId || '').trim() const need = pins[id] if (need == null) return true /** @type {string[]} */ const pubHexList = Array.isArray(need) ? need.map((x) => String(x) .trim() .toLowerCase() .replace(/^0x/, '') ) : [String(need).trim().toLowerCase().replace(/^0x/, '')] const validKeys = pubHexList.filter( (h) => h.length === 64 && /^[0-9a-f]+$/.test(h) ) if (!validKeys.length) { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinBadKey', `[kernel.ext.d] extensionSignerPins for "${id}" must be 64-char hex pubkey(s)` ) return !strictPol } const sigPtr = ent.signaturePointer ? String(ent.signaturePointer).trim() : '' if (!sigPtr.startsWith('/')) { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinNoSigPath', `[kernel.ext.d] "${id}": signaturePointer (absolute path to signature) required when extensionSignerPins lists this id` ) return !strictPol } const { drive, b4a } = ctx if (!drive || typeof drive.get !== 'function' || !b4a) { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinNoDrive', '[kernel.ext.d] signer pin verify requires ctx.drive.get and ctx.b4a' ) return !strictPol } let scriptBuf let sigBuf try { scriptBuf = await drive.get(imgPath) sigBuf = await drive.get(sigPtr) } catch (e) { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinRead', `[kernel.ext.d] signer pin read: ${(e && e.message) || String(e)}` ) return !strictPol } if (!scriptBuf || !scriptBuf.byteLength || !sigBuf || !sigBuf.byteLength) { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinMissingBytes', `[kernel.ext.d] "${id}": script or signature file missing/empty for pin verify` ) return !strictPol } const verifyFn = ctx.bareOsVerifyBootManifestSignature if (typeof verifyFn !== 'function') { bootStructuredLog( ctx, 'error', 'kernelExt.signerPinNoVerify', '[kernel.ext.d] ctx.bareOsVerifyBootManifestSignature unavailable; cannot enforce extensionSignerPins' ) return !strictPol } for (const pk of validKeys) { try { if (verifyFn.call(ctx, scriptBuf, sigBuf, pk) === true) return true } catch { /* try next pubkey */ } } bootStructuredLog( ctx, 'error', 'kernelExt.signerPinVerifyFailed', `[kernel.ext.d] "${id}": Ed25519 verify failed for pinned key(s)` ) return !strictPol } /** * Append one NDJSON line to `/run/bare-os/kernel-ext-audit.ndjson` (strict boot diagnostics). * @param {Record} ctx * @param {Record} row */ async function appendKernelExtAuditNdjson(ctx, row) { if ( !ctx.vfs || typeof ctx.vfs.readFile !== 'function' || typeof ctx.vfs.writeFile !== 'function' || !ctx.b4a ) { return } const line = JSON.stringify({ schema: 1, type: 'kernelExtAudit', atMs: Date.now(), sessionId: String((ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''), ...row }) + '\n' try { let prev = '' try { prev = ctx.b4a.toString( await ctx.vfs.readFile('/run/bare-os/kernel-ext-audit.ndjson') ) } catch { /* absent */ } const maxBytes = 96 * 1024 let next = prev + line if (next.length > maxBytes) next = next.slice(-maxBytes) await ctx.vfs.writeFile( '/run/bare-os/kernel-ext-audit.ndjson', ctx.b4a.from(next) ) } catch { /* ignore */ } } /** * Optional `/etc/bare-os/kernel.ext.d/*.json` with `{ "scripts": ["/lib/bare-os/extensions/foo.js"] }`. * @param {Record} ctx * @param {{ incremental?: boolean, ranScripts?: string[] }} [opts] * @returns {Promise} */ async function runKernelExtDropins(ctx, opts = {}) { const incremental = opts.incremental === true const ranScripts = opts.ranScripts const { drive } = ctx const run = ctx.bareOsRunImageScript if (typeof run !== 'function') return true if (!ctx.bareOsLoadedKernelExtScripts) { ctx.bareOsLoadedKernelExtScripts = new Set() } /** @type {Set} */ const loadedSet = /** @type {Set} */ (ctx.bareOsLoadedKernelExtScripts) const denyRaw = String( ctx.env?.BARE_OS_BOOT_POLICY_DENY_KERNEL_EXT_IDS || '' ).trim() const deny = new Set( denyRaw .split(',') .map((s) => s.trim()) .filter(Boolean) ) const strictPol = ctx.env?.BARE_OS_BOOT_POLICY_STRICT === '1' || ctx.env?.BARE_OS_BOOT_POLICY_STRICT === 'true' const extSignerPins = mergeExtensionSignerPinsFromEnv(ctx.env) const multisigExtGate = ctx.env?.BARE_OS_EXTENSION_MULTISIG_VERIFY === '1' || ctx.env?.BARE_OS_EXTENSION_MULTISIG_VERIFY === 'true' || ctx.env?.BARE_OS_BOOT_POLICY_REQUIRE_PEAR_MULTISIG === '1' || ctx.env?.BARE_OS_BOOT_POLICY_REQUIRE_PEAR_MULTISIG === 'true' if (multisigExtGate) { const requireFile = ctx.env?.BARE_OS_BOOT_POLICY_REQUIRE_PEAR_MULTISIG === '1' || ctx.env?.BARE_OS_BOOT_POLICY_REQUIRE_PEAR_MULTISIG === 'true' try { const mbuf = await drive.get('/etc/bare-os/pear.multisig.json') if (!mbuf || mbuf.byteLength === 0) { if (strictPol && requireFile) { bootStructuredLog( ctx, 'error', 'kernelExt.multisigMissing', '[kernel.ext.d] strict boot requires /etc/bare-os/pear.multisig.json (BARE_OS_BOOT_POLICY_REQUIRE_PEAR_MULTISIG)' ) if (typeof ctx.bareOsRequestBooterExit === 'function') ctx.bareOsRequestBooterExit(1) return false } } else { const mj = JSON.parse(ctx.b4a.toString(mbuf)) if (!bareOsPearMultisigShapeOk(mj)) { bootStructuredLog( ctx, 'error', 'kernelExt.multisigInvalid', '[kernel.ext.d] pear.multisig.json must be { signers: string[], quorum: number } with 1 ≤ quorum ≤ signers.length' ) if (strictPol) { if (typeof ctx.bareOsRequestBooterExit === 'function') ctx.bareOsRequestBooterExit(1) return false } } } } catch (e) { bootStructuredLog( ctx, 'warn', 'kernelExt.multisigRead', '[kernel.ext.d] pear.multisig.json: ' + ((e && e.message) || String(e)) ) if (strictPol && requireFile) { if (typeof ctx.bareOsRequestBooterExit === 'function') ctx.bareOsRequestBooterExit(1) return false } } } const maxDepthRaw = String( ctx.env?.BARE_OS_BOOT_POLICY_MAX_KERNEL_EXT_DEPTH || '' ).trim() const maxDepthCap = maxDepthRaw && Number.parseInt(maxDepthRaw, 10) > 0 ? Math.min(32, Number.parseInt(maxDepthRaw, 10)) : 0 /** @type {string[]} */ const names = [] try { for await (const n of drive.readdir('/etc/bare-os/kernel.ext.d')) names.push(n) } catch { return true } names.sort() /** @type {{ file: string, extId: string, scripts: string[], dependsOn: string[], beforeIds: string[], conflictsWith: string[], signaturePointer?: string, provides: { name: string, version: string }[] }[]} */ const collected = [] /** @type {Map} */ const extIdFiles = new Map() for (const name of names) { if (!name.endsWith('.json')) continue const p = `/etc/bare-os/kernel.ext.d/${name}` try { const buf = await drive.get(p) if (!buf) continue const pol = JSON.parse(ctx.b4a.toString(buf)) if (!pol || typeof pol !== 'object') continue const extId = pol.id != null ? String(pol.id).trim() : name.replace(/\.json$/i, '') if (deny.has(extId)) { bootStructuredLog( ctx, 'error', 'kernelExt.denyId', `[kernel.ext.d] denied by policy id: ${extId}` ) await appendKernelExtAuditNdjson(ctx, { event: 'deny_id', extId, dropin: name }) continue } const scripts = pol.scripts if (!Array.isArray(scripts)) continue /** @type {string[]} */ let dependsOn = Array.isArray(pol.dependsOn) ? pol.dependsOn.map((d) => String(d).trim()).filter(Boolean).slice(0, 16) : [] if (Array.isArray(pol.requires)) { dependsOn = dependsOn.concat( pol.requires.map((d) => String(d).trim()).filter(Boolean).slice(0, 16) ) } if (Array.isArray(pol.after)) { dependsOn = dependsOn.concat( pol.after.map((d) => String(d).trim()).filter(Boolean).slice(0, 16) ) } const beforeIds = Array.isArray(pol.before) ? pol.before.map((d) => String(d).trim()).filter(Boolean).slice(0, 16) : [] const sig = typeof pol.signaturePointer === 'string' ? pol.signaturePointer.trim().slice(0, 512) : undefined const minExtCtx = typeof pol.minCtxApiVersion === 'string' ? pol.minCtxApiVersion.trim() : '' if (minExtCtx) { const haveS = typeof ctx.bareOsCtxApiVersion === 'string' ? ctx.bareOsCtxApiVersion.trim() : String(ctx.bareOsCtxApiVersion || '').trim() if (!haveS || !semverGte(haveS, minExtCtx)) { bootStructuredLog( ctx, 'error', 'kernelExt.minCtxApiVersion', `[kernel.ext.d] ${name}: minCtxApiVersion ${minExtCtx} not satisfied (have ${haveS || 'none'})` ) if (strictPol) return false continue } } const conflictsWith = Array.isArray(pol.conflictsWith) ? pol.conflictsWith.map((d) => String(d).trim()).filter(Boolean).slice(0, 16) : [] /** @type {{ name: string, version: string }[]} */ const provides = Array.isArray(pol.provides) ? pol.provides .map((x) => { if (!x || typeof x !== 'object') return null const nm = String(x.name || '').trim() const ver = String(x.version ?? x.semver ?? '').trim() if (!nm || !ver) return null return { name: nm, version: ver } }) .filter(Boolean) .slice(0, 16) : [] collected.push({ file: name, extId, scripts: scripts.map((s) => String(s).trim()).filter(Boolean), dependsOn, beforeIds, conflictsWith, provides, signaturePointer: sig || undefined }) if (!extIdFiles.has(extId)) extIdFiles.set(extId, []) extIdFiles.get(extId).push(name) } catch (e) { bootStructuredLog( ctx, 'error', 'kernelExt.dropinParse', `kernel.ext.d/${name}: ` + ((e && e.message) || String(e)) ) } } /** @type {string[]} */ const duplicateExtIds = [] for (const [eid, files] of extIdFiles) { if (files.length > 1) duplicateExtIds.push(eid) } duplicateExtIds.sort() if (duplicateExtIds.length) { bootStructuredLog( ctx, 'error', 'kernelExt.duplicateId', `[kernel.ext.d] duplicate extension id(s): ${duplicateExtIds.join(', ')} — each id must appear in at most one drop-in` ) if (strictPol) { await appendKernelExtAuditNdjson(ctx, { event: 'duplicate_ext_id', duplicateExtIds }) return false } } /** @param {string} ver */ function bareOsKernelExtSemverValid(ver) { const s = String(ver || '').trim() return /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test( s ) } /** @type {string[]} */ const providesInvalidSemver = [] for (const e of collected) { for (const pr of e.provides || []) { if (!bareOsKernelExtSemverValid(pr.version)) { providesInvalidSemver.push(`${e.extId}:${pr.name}@${pr.version}`) } } } providesInvalidSemver.sort() if (providesInvalidSemver.length) { bootStructuredLog( ctx, 'warn', 'kernelExt.providesInvalidSemver', `[kernel.ext.d] provides[].version must look like semver (major.minor.patch): ${providesInvalidSemver.join('; ')}` ) if (strictPol) { await appendKernelExtAuditNdjson(ctx, { event: 'provides_invalid_semver', items: providesInvalidSemver }) return false } } /** @type {Map} */ const provideNameToOwner = new Map() /** @type {string[]} */ const providesVersionConflicts = [] for (const e of collected) { for (const pr of e.provides || []) { const prev = provideNameToOwner.get(pr.name) if (prev && prev.version !== pr.version) { providesVersionConflicts.push( `${pr.name}: ${prev.extId}@${prev.version} vs ${e.extId}@${pr.version}` ) } else if (!prev) { provideNameToOwner.set(pr.name, { extId: e.extId, version: pr.version }) } } } providesVersionConflicts.sort() if (providesVersionConflicts.length) { bootStructuredLog( ctx, 'error', 'kernelExt.providesConflict', `[kernel.ext.d] conflicting provides version(s): ${providesVersionConflicts.join('; ')}` ) if (strictPol) { await appendKernelExtAuditNdjson(ctx, { event: 'provides_version_conflict', conflicts: providesVersionConflicts }) return false } } const idToCollected = new Map(collected.map((e) => [e.extId, e])) for (const e of collected) { for (const bid of e.beforeIds) { const target = idToCollected.get(bid) if (target && target.extId !== e.extId) { const next = new Set(target.dependsOn) next.add(e.extId) target.dependsOn = [...next].slice(0, 24) } } } for (const e of collected) { e.dependsOn = [...new Set(e.dependsOn)] } const idSet = new Set(collected.map((e) => e.extId)) for (const e of collected) { for (const c of e.conflictsWith) { if (idSet.has(c)) { bootStructuredLog( ctx, 'error', 'kernelExt.conflict', `[kernel.ext.d] conflict: extension "${e.extId}" conflictsWith "${c}"` ) if (strictPol) { await appendKernelExtAuditNdjson(ctx, { event: 'conflict', extId: e.extId, conflictsWith: c }) return false } } } } if (maxDepthCap > 0) { const d = kernelExtDependencyDepth(collected) if (d > maxDepthCap) { bootStructuredLog( ctx, 'error', 'kernelExt.maxDepth', `[kernel.ext.d] maxKernelExtensionDepth exceeded (${d} > ${maxDepthCap})` ) if (strictPol) return false } } const topo = topologicalOrderKernelExtEntries(collected) /** @type {typeof collected} */ let ordered if (!topo.ok) { const edgeHint = topo.cycleEdges && topo.cycleEdges.length ? '; cycle edges (stuck->dep): ' + topo.cycleEdges.join('; ') : '' bootStructuredLog( ctx, 'error', 'kernelExt.cycle', '[kernel.ext.d] dependency cycle in extension drop-ins; ext ids: ' + topo.cycleExtIds.join(', ') + edgeHint ) if (strictPol) { await appendKernelExtAuditNdjson(ctx, { event: 'dependency_cycle', cycleExtIds: [...topo.cycleExtIds].sort(), cycleEdges: topo.cycleEdges || [] }) return false } ordered = [...collected].sort((a, b) => a.file.localeCompare(b.file)) } else { ordered = topo.ordered } const dry = bootDryRun(ctx) const traceOn = strictPol && (ctx.env?.BARE_OS_BOOT_EXT_RESOLUTION_TRACE === '1' || ctx.env?.BARE_OS_BOOT_EXT_RESOLUTION_TRACE === 'true') const resolutionAlways = ctx.env?.BARE_OS_KERNEL_EXT_RESOLUTION_JSON_ALWAYS === '1' || ctx.env?.BARE_OS_KERNEL_EXT_RESOLUTION_JSON_ALWAYS === 'true' const extResolutionFailure = strictPol && !topo.ok if ( ctx.vfs && typeof ctx.vfs.writeFile === 'function' && ctx.b4a && (extResolutionFailure || traceOn || resolutionAlways) ) { try { const cycleSorted = topo.ok ? [] : [...topo.cycleExtIds].sort((a, b) => a.localeCompare(b)) const provideSnapshot = {} for (const [k, v] of provideNameToOwner) { provideSnapshot[k] = v } const body = JSON.stringify({ schema: 3, atMs: Date.now(), sessionId: String((ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''), bootPolicyStrict: !!strictPol, failure: extResolutionFailure ? { kind: 'dependency_cycle', cycleExtIds: cycleSorted, cycleEdges: topo.cycleEdges || [], provenance: 'kernel.ext.d topological sort (init-main)' } : null, ordered: ordered.map((e) => ({ extId: e.extId, file: e.file, dependsOn: e.dependsOn, provides: e.provides || [] })), cycleExtIds: topo.ok ? [] : cycleSorted, providesInvalidSemver, providesVersionConflicts: providesVersionConflicts.length > 0 ? providesVersionConflicts : undefined, provideNameToOwner: provideSnapshot }) + '\n' await ctx.vfs.writeFile( '/run/bare-os/kernel-ext-resolution.json', ctx.b4a.from(body) ) } catch { /* ignore */ } } const extGraphOn = ctx.env?.BARE_OS_KERNEL_EXT_GRAPH === '1' || ctx.env?.BARE_OS_KERNEL_EXT_GRAPH === 'true' const extGraphDefer = ctx.env?.BARE_OS_INIT_DEFER_KERNEL_EXT_GRAPH === '1' || ctx.env?.BARE_OS_INIT_DEFER_KERNEL_EXT_GRAPH === 'true' if ( extGraphOn && !extGraphDefer && ctx.vfs && typeof ctx.vfs.writeFile === 'function' && ctx.b4a ) { let bareModuleTraverse = 'unresolved' try { await import('bare-module-traverse') bareModuleTraverse = 'import_ok' } catch { bareModuleTraverse = 'import_failed' } try { const body = JSON.stringify({ schema: 1, atMs: Date.now(), bareModuleTraverse, note: 'Static drop-in DAG; deeper static edges require host tooling on extension sources.', nodes: ordered.map((e) => ({ extId: e.extId, file: e.file, scripts: e.scripts, dependsOn: e.dependsOn })) }) + '\n' await ctx.vfs.writeFile( '/run/bare-os/kernel-ext-graph.json', ctx.b4a.from(body) ) } catch { /* ignore */ } } for (const ent of ordered) { for (const sp of ent.scripts) { const imgPath = String(sp).trim() if (!imgPath.startsWith('/lib/bare-os/extensions/')) { bootStructuredLog( ctx, 'error', 'kernelExt.rejectedPath', `[kernel.ext.d] rejected script path: ${imgPath}` ) continue } if (incremental && loadedSet.has(imgPath)) { continue } if (dry) { bootStructuredLog( ctx, 'error', 'bootDryRunKernelExt', `[boot-dry-run] skip kernel.ext.d script: ${imgPath}` ) continue } if ( !(await verifyKernelExtSignerPinsForScript( ctx, ent, imgPath, extSignerPins, strictPol )) ) { if (strictPol) { if (typeof ctx.bareOsRequestBooterExit === 'function') ctx.bareOsRequestBooterExit(1) return false } continue } try { const extT0 = Date.now() await run(imgPath) const loadMs = Date.now() - extT0 loadedSet.add(imgPath) if (Array.isArray(ranScripts)) ranScripts.push(imgPath) if (typeof ctx.bareOsRegisterKernelExtensionRecord === 'function') { ctx.bareOsRegisterKernelExtensionRecord({ dropin: ent.file, script: imgPath, id: ent.extId, dependsOn: ent.dependsOn, signaturePointer: ent.signaturePointer, loadMs }) } } catch (e) { bootStructuredLog( ctx, 'error', 'kernelExt.scriptThrown', `[kernel.ext.d] ${ent.file}: ` + ((e && e.message) || String(e)) ) } } } return true }