/** * Ed25519-signed path capability envelopes (schema 1) for optional VFS read enforcement. * Store **`user.bareos.cap_v1`** in **`PATH.bare_xattr.json`** (see `/bin/xattr`) as base64 UTF-8 JSON. */ import b4a from 'b4a' import { verifyBootManifestEd25519 } from '#bare-os-boot-manifest-sig' /** * @param {unknown} v * @returns {string} */ function stableJson(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v) if (Array.isArray(v)) { return '[' + v.map((x) => stableJson(x)).join(',') + ']' } const o = /** @type {Record} */ (v) const keys = Object.keys(o).sort() return ( '{' + keys.map((k) => JSON.stringify(k) + ':' + stableJson(o[k])).join(',') + '}' ) } /** * @param {Record} payload */ export function bareOsPathCapabilityPayloadCanonicalUtf8(payload) { return b4a.from(stableJson(payload), 'utf8') } /** * @param {unknown} envelope * @returns {{ ok: true, payload: { prefix: string, ops: string[], expMs: number | null } } | { ok: false, reason: string }} */ export function verifyPathCapabilityEnvelope(envelope) { if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { return { ok: false, reason: 'envelope_not_object' } } const e = /** @type {Record} */ (envelope) if (Number(e.schema) !== 1) return { ok: false, reason: 'bad_schema' } const payload = e.payload if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return { ok: false, reason: 'bad_payload' } } const pl = /** @type {Record} */ (payload) const prefix = String(pl.prefix || '').trim() if (!prefix.startsWith('/')) return { ok: false, reason: 'bad_prefix' } const ops = Array.isArray(pl.ops) ? pl.ops.map((x) => String(x)) : [] if (!ops.includes('read') && !ops.includes('all')) { return { ok: false, reason: 'no_read_op' } } const expRaw = pl.expMs const expMs = expRaw != null && Number.isFinite(Number(expRaw)) ? Number(expRaw) : null if (expMs != null && Date.now() > expMs) return { ok: false, reason: 'expired' } const msg = bareOsPathCapabilityPayloadCanonicalUtf8(pl) const sig = String(e.signatureHex || '').trim().replace(/^0x/, '') const pub = String(e.pubkeyHex || '').trim().replace(/^0x/, '') if (!/^[0-9a-f]{128}$/i.test(sig)) return { ok: false, reason: 'bad_signature_len' } if (!/^[0-9a-f]{64}$/i.test(pub)) return { ok: false, reason: 'bad_pubkey_len' } const ok = verifyBootManifestEd25519(msg, sig, pub) if (!ok) return { ok: false, reason: 'bad_signature' } return { ok: true, payload: { prefix, ops, expMs } } } /** * @param {string} b64 */ function xattrB64ToUtf8(b64) { const t = String(b64 || '').replace(/\s+/g, '') try { return b4a.toString(b4a.from(t, 'base64'), 'utf8') } catch { return '' } } /** * When **`BARE_OS_PATH_CAPABILITY_ENFORCE_READ`** is set, personal-drive reads under * **`BARE_OS_PATH_CAPABILITY_PREFIX`** require a valid **`user.bareos.cap_v1`** xattr envelope * on **`PATH.bare_xattr.json`** authorizing the logical path prefix. * * @param {Record} env * @param {import('hyperdrive').default} drive * @param {string} drivePath path segment on drive (as `drive.get` expects) * @param {string} logicalAbs resolved logical absolute path (e.g. `/home/u/f`) */ export async function bareOsVfsPathCapabilityDeniesDriveRead( env, drive, drivePath, logicalAbs ) { const on = env.BARE_OS_PATH_CAPABILITY_ENFORCE_READ === '1' || env.BARE_OS_PATH_CAPABILITY_ENFORCE_READ === 'true' if (!on || !drive || typeof drive.get !== 'function') return false const root = String(env.BARE_OS_PATH_CAPABILITY_PREFIX || '/home/').trim() if (!root.startsWith('/')) return false const la = String(logicalAbs || '').replace(/\/+$/, '') || '/' if (!la.startsWith(root)) return false if (la.endsWith('.bare_xattr.json') || la.endsWith('.bare_acl')) return false const p = String(drivePath || '').replace(/\/+$/, '') || '/' const sidePath = p + '.bare_xattr.json' let raw try { raw = await drive.get(sidePath, { follow: false }) } catch { return true } if (!raw || !raw.byteLength) return true let map try { map = JSON.parse(b4a.toString(raw)) } catch { return true } if (!map || typeof map !== 'object') return true const b64 = map['user.bareos.cap_v1'] if (typeof b64 !== 'string' || !b64.trim()) return true let inner try { inner = JSON.parse(xattrB64ToUtf8(b64)) } catch { return true } const vr = verifyPathCapabilityEnvelope(inner) if (!vr.ok) return true if (!la.startsWith(vr.payload.prefix)) return true return false } /** * @param {Record | null | undefined} env * @returns {string[] | null} */ export function parsePathCapabilityTrustedPubkeysHex(env) { const raw = String(env?.BARE_OS_PATH_CAPABILITY_TRUSTED_PUBKEYS_HEX || '').trim() if (!raw) return null /** @type {string[]} */ const out = [] for (const p of raw.split(/[,;\s]+/)) { const x = p.trim().toLowerCase().replace(/^0x/, '') if (/^[0-9a-f]{64}$/i.test(x)) out.push(x.toLowerCase()) } return out.length ? out : null } /** * Same as {@link verifyPathCapabilityEnvelope} plus optional issuer allowlist from * **`BARE_OS_PATH_CAPABILITY_TRUSTED_PUBKEYS_HEX`** (comma-separated 64-hex Ed25519 pubkeys). * @param {unknown} envelope * @param {Record | null | undefined} env * @returns {{ ok: true, payload: { prefix: string, ops: string[], expMs: number | null }, trustedIssuer: true | 'signature_only' } | { ok: false, reason: string }} */ export function verifyPathCapabilityEnvelopeTrusted(envelope, env) { const base = verifyPathCapabilityEnvelope(envelope) if (!base.ok) return base const allow = parsePathCapabilityTrustedPubkeysHex(env) if (!allow) { return { ok: true, payload: base.payload, trustedIssuer: 'signature_only' } } const e = envelope && typeof envelope === 'object' && !Array.isArray(envelope) ? /** @type {Record} */ (envelope) : {} const pub = String(e.pubkeyHex || '') .trim() .toLowerCase() .replace(/^0x/, '') if (!allow.includes(pub)) return { ok: false, reason: 'issuer_not_trusted' } return { ok: true, payload: base.payload, trustedIssuer: true } }