Files
bare-operating-system/packages/bare-os-booter/lib/security/bare-os-path-capability.js
T
2026-08-18 18:11:28 -04:00

193 lines
6.7 KiB
JavaScript

/**
* 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<string, unknown>} */ (v)
const keys = Object.keys(o).sort()
return (
'{' +
keys.map((k) => JSON.stringify(k) + ':' + stableJson(o[k])).join(',') +
'}'
)
}
/**
* @param {Record<string, unknown>} 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<string, unknown>} */ (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<string, unknown>} */ (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<string, string>} 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
const strictTrusted =
env.BARE_OS_PATH_CAPABILITY_REQUIRE_TRUSTED_SIGNER === '1' ||
env.BARE_OS_PATH_CAPABILITY_REQUIRE_TRUSTED_SIGNER === 'true' ||
String(env.BARE_OS_ZERO_TRUST_PROFILE || '')
.trim()
.toLowerCase() === 'strict'
if (strictTrusted) {
const tr = verifyPathCapabilityEnvelopeTrusted(inner, env)
if (!tr.ok || tr.trustedIssuer !== true) return true
}
if (!la.startsWith(vr.payload.prefix)) return true
return false
}
/**
* @param {Record<string, string | undefined> | 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<string, string | undefined> | 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<string, unknown>} */ (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 }
}