/** * Session identity: guest vs unlocked keypair; env + vfs; vault save. * User-facing **`ctx.console.log`** lines on register / unlock / logout / vault save are intentional session feedback (not debug). * Vault blobs use **`sealBytes`** / **`openBytes`** from **`identity-account.js`** (XSalsa20-Poly1305 via **bare-crypto**-shaped **`secretbox`**); changing algorithms requires a new vault index **`v`** and backward-compatible readers. * @see [Handbook — Identity and vault](../../../handbook/05-identity-vault-and-hdms.md) * @see [Developer guide — Context object](../../../developer-guide/02-the-context-object.md) */ import b4a from 'b4a' import { bareOsAppendVaultRotationCheckpoint } from './bare-os-vault-rotation-audit.js' import { loadBarerc } from './shell.js' import { ACCOUNT_PATH, decodeAccount, encodeAccount, generateEd25519Keypair, hashPublicKeyForUid, hashUtf8Path, secureZero, sealBytes, vaultKeyFromSecret } from './identity-account.js' import { bareOsResetShellIdentityState } from './shell.js' import { logicalAuthorizedKeysPath, parseSshdConfig, SSHD_DEFAULT_AUTHORIZED_KEYS_FILE } from './sshd-config-parse.js' import { ensureBareOsVarLogTree } from './bare-os-var-log.js' import { startBareUserSessionStack, stopBareUserSessionStack } from './bare-user-session-stack.js' import { ensureDiskBareOsChatTransport } from './bare-os-chat-service.js' import { ensureDiskBareOsMeshdropTransport } from './bare-os-meshdrop-service.js' import { ensureBareOsWwwHomeDefaults } from './bare-os-www-initd.js' const LEGACY_ROOT_MIGRATION_STATE = '/.bare-os/migration/legacy-root-v1.json' /** @param {string} abs */ function posixDirnameForLogicalAbs(abs) { const s = String(abs || '').replace(/\/+$/, '') if (!s || s === '/') return '/' const i = s.lastIndexOf('/') if (i <= 0) return '/' return s.slice(0, i) || '/' } /** * Create `$HOME/.ssh` and starter `authorized_keys` when missing (path from `/etc/ssh/sshd_config` when present). * @param {Record} ctx */ async function ensureAuthorizedKeysFile(ctx) { const vfs = ctx.vfs if ( !vfs || typeof vfs.mkdir !== 'function' || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function' || typeof vfs.resolveLogical !== 'function' ) return let rel = SSHD_DEFAULT_AUTHORIZED_KEYS_FILE try { const cfgAbs = vfs.resolveLogical('/etc/ssh/sshd_config') const buf = await vfs.readFile(cfgAbs) if (buf != null && b4a.from(buf).length) rel = parseSshdConfig(b4a.toString(buf)).authorizedKeysFile } catch { /* no readable sshd_config */ } const home = String(vfs.env?.HOME || '/home/guest') const logical = logicalAuthorizedKeysPath(home, rel) let abs try { abs = vfs.resolveLogical(logical) } catch { return } const parent = posixDirnameForLogicalAbs(abs) try { await vfs.mkdir(parent, { recursive: true }) } catch { /* exists */ } try { const existing = await vfs.readFile(abs) if (existing != null) return } catch { /* missing */ } try { await vfs.writeFile( abs, b4a.from('# Bare OS: add OpenSSH public keys here (one per line).\n') ) } catch { /* ignore */ } } /** * Mirror vault NDJSON checkpoints into the tamper-evident host audit chain when the booter exposes it. * @param {Record} ctx * @param {Record} row */ function mirrorVaultCheckpointToAuditChain(ctx, row) { if (typeof ctx.bareOsAuditLogAppendBatch !== 'function') return try { ctx.bareOsAuditLogAppendBatch([ { type: 'identity.vault_audit_mirror', schema: 1, vaultRotationAuditPath: '/.bare/vault-rotation-audit.ndjson', ...row, atMs: Date.now() } ]) } catch { /* optional chain */ } } /** * Optional NDJSON audit row when operators enable multisig continuity journaling. * @param {Record} ctx * @param {'applyLoginKeys' | 'unlockIdentity'} loginKind */ function maybeAppendVaultMultisigContinuityAudit(ctx, loginKind) { const env = ctx.vfs?.env if (!env || typeof ctx.bareOsAuditLogAppendBatch !== 'function') return const on = env.BARE_OS_VAULT_MULTISIG_CONTINUITY_AUDIT_NDJSON === '1' || env.BARE_OS_VAULT_MULTISIG_CONTINUITY_AUDIT_NDJSON === 'true' if (!on) return const raw = String(env.BARE_OS_VAULT_MULTISIG_CONTINUITY_JSON || '').trim() let continuityParseOk = false let continuityKeyCount = 0 if (raw) { try { const o = JSON.parse(raw) if (o && typeof o === 'object') { continuityParseOk = true continuityKeyCount = Math.min(Object.keys(o).length, 64) } } catch { /* leave flags false */ } } const pk = ctx.identity?.publicKey const pkHex = pk && pk.length && typeof b4a.toString === 'function' ? b4a.toString(pk, 'hex').slice(0, 16) : null try { ctx.bareOsAuditLogAppendBatch([ { type: 'vault.multisig_continuity_login_sketch', schema: 1, loginKind, publicKeyHexPrefix: pkHex, continuityEnvConfigured: !!raw, continuityParseOk, continuityKeyCount, atMs: Date.now() } ]) } catch { /* optional */ } } /** * @param {Record} ctx * @param {Record} row */ function mirrorMigrationAudit(ctx, row) { if (typeof ctx.bareOsAuditLogAppendBatch !== 'function') return try { ctx.bareOsAuditLogAppendBatch([ { type: 'identity.personal_root_migration', schema: 1, ...row, atMs: Date.now() } ]) } catch { /* optional */ } } /** * @param {Record} ctx * @param {Record} row */ function mirrorGuestScrubAudit(ctx, row) { if (typeof ctx.bareOsAuditLogAppendBatch !== 'function') return try { ctx.bareOsAuditLogAppendBatch([ { type: 'identity.guest_scrub', schema: 1, ...row, atMs: Date.now() } ]) } catch { /* optional */ } } /** Keep VFS identity and warm caches aligned with ctx.identity (defense in depth). */ function syncVfsIdentitySession(ctx) { const st = ctx.identity?.state === 'unlocked' ? 'unlocked' : 'guest' try { if (ctx.vfs && typeof ctx.vfs === 'object') { ctx.vfs.bareOsIdentitySession = st } } catch { /* ignore */ } try { if (typeof ctx.vfs?.bareOsClearWarmReadCaches === 'function') { ctx.vfs.bareOsClearWarmReadCaches() } } catch { /* ignore */ } try { if (typeof ctx.bareOsInvalidateWarmReadCaches === 'function') { ctx.bareOsInvalidateWarmReadCaches('identity-switch') } } catch { /* ignore */ } } /** * Invalidate identity-sensitive caches and reload shell history after session changes. * Used on guest and unlocked transitions so `/bin` warm reads + fish history never leak. * * @param {Record} ctx */ export async function invalidateIdentityCachesAndHistory(ctx) { syncVfsIdentitySession(ctx) try { await ctx.bareOsReloadFishHistoryForIdentity?.() } catch { /* optional */ } } export const GUEST_USER = 'guest' export const GUEST_HOME = '/home/guest' const GUEST_UID = '65534' const GUEST_GID = '65534' /** @param {Uint8Array} pk */ function displayNameFromPublicKey(pk) { return b4a.toString(pk, 'hex').slice(0, 12) } /** @param {Uint8Array} pk */ function uidFromPublicKey(pk) { return hashPublicKeyForUid(pk) } /** * @param {Record} ctx */ export async function applyGuestEnv(ctx) { bareOsResetShellIdentityState(ctx) const env = ctx.vfs.env env.USER = GUEST_USER env.LOGNAME = GUEST_USER env.HOME = GUEST_HOME env.PWD = GUEST_HOME env.UID = GUEST_UID env.GID = GUEST_GID env.GROUP = GUEST_USER delete env.BARE_OS_PUBLIC_KEY env.BARE_OS_IDENTITY = 'guest' ctx.identity = { state: 'guest', publicKey: null, secretKey: null } try { await scrubGuestWorkspaces(ctx) } catch (e) { ctx.console?.error?.('[bare-os] guest scrub: ' + (e?.message || e)) } await invalidateIdentityCachesAndHistory(ctx) try { await ctx.vfs.chdir(GUEST_HOME) } catch { /* ignore */ } try { await ctx.onIdentityGuest?.() } catch (e) { ctx.console?.error?.('[bare-os] onIdentityGuest: ' + (e?.message || e)) } try { await loadBarerc(ctx) } catch (e) { ctx.console?.error?.('[bare-os] loadBarerc: ' + (e?.message || e)) } try { await ensureAuthorizedKeysFile(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureAuthorizedKeysFile: ' + (e?.message || e)) } } /** * @param {Record} ctx * @param {Uint8Array} publicKey * @param {Uint8Array} secretKey */ export async function applyUnlockedEnv(ctx, publicKey, secretKey) { bareOsResetShellIdentityState(ctx) const env = ctx.vfs.env const name = displayNameFromPublicKey(publicKey) const home = `/home/${name}` env.USER = name env.LOGNAME = name env.HOME = home env.PWD = home await migrateLegacyPersonalHomeIfNeeded(ctx) const uid = uidFromPublicKey(publicKey) env.UID = uid env.GID = uid env.GROUP = name env.BARE_OS_PUBLIC_KEY = b4a.toString(publicKey, 'hex') env.BARE_OS_IDENTITY = 'unlocked' ctx.identity = { state: 'unlocked', publicKey: Uint8Array.from(publicKey), secretKey: Uint8Array.from(secretKey) } await invalidateIdentityCachesAndHistory(ctx) try { await ensureBareOsVarLogTree(ctx) } catch { /* best-effort; bare-initd ran at guest HOME — new HOME needs /var/log/bare-os on personal drive */ } try { await ctx.vfs.chdir(home) } catch { /* ignore */ } try { await ctx.onIdentityUnlocked?.() } catch (e) { ctx.console?.error?.('[bare-os] onIdentityUnlocked: ' + (e?.message || e)) } try { await loadBarerc(ctx, { createSkeletonIfMissing: true }) } catch (e) { ctx.console?.error?.('[bare-os] loadBarerc: ' + (e?.message || e)) } try { await ensureAuthorizedKeysFile(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureAuthorizedKeysFile: ' + (e?.message || e)) } } /** * @param {Record} ctx */ export function wipeSecret(ctx) { const sk = ctx.identity?.secretKey if (sk && sk.length) secureZero(sk) if (ctx.identity) ctx.identity.secretKey = null } /** * @param {Record} ctx * @param {{ publicKey: Uint8Array, secretKey: Uint8Array }} keys */ export async function applyLoginKeys(ctx, keys) { wipeSecret(ctx) await applyUnlockedEnv(ctx, keys.publicKey, keys.secretKey) await ensureBareDir(ctx) try { await ensureBareOsWwwHomeDefaults(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureBareOsWwwHomeDefaults: ' + (e?.message || e)) } try { await startBareUserSessionStack(ctx) } catch (e) { ctx.console?.error?.( '[bare-os] user session stack after login: ' + (e?.message || e) ) } maybeAppendVaultMultisigContinuityAudit(ctx, 'applyLoginKeys') } /** * @param {Record} ctx */ export async function ensureBareDir(ctx) { const drive = ctx.personalDrive if (!drive || typeof drive.put !== 'function') return try { await drive.put('/.bare/.keep', b4a.from('')) } catch { /* ignore */ } } /** * @param {Record} ctx */ export async function ensureGuestHome(ctx) { await ensureBareDir(ctx) await migrateLegacyPersonalHomeIfNeeded(ctx) const drive = ctx.personalDrive if (!drive || typeof drive.put !== 'function') return let keepPath = '/.bare-os/home/guest/.keep_guest' try { if (ctx.vfs && typeof ctx.vfs.route === 'function') { const rr = ctx.vfs.route('/home/guest') if (rr && rr.path && typeof rr.path === 'string' && !rr.virtualHomeDir) { const ph = rr.path.replace(/\/+$/, '') if (ph && ph !== '/') keepPath = `${ph}/.keep_guest` } } } catch { /* default path */ } try { const existing = await drive.get(keepPath) if (existing) { try { await ensureAuthorizedKeysFile(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureAuthorizedKeysFile: ' + (e?.message || e)) } return } } catch { /* missing */ } try { await drive.put(keepPath, b4a.from('')) } catch { /* ignore */ } try { await ensureAuthorizedKeysFile(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureAuthorizedKeysFile: ' + (e?.message || e)) } } /** * @param {Record} ctx * @param {string} passphrase */ export async function registerIdentity(ctx, passphrase) { const drive = ctx.personalDrive if (!drive) throw new Error('No personal drive') const existing = await drive.get(ACCOUNT_PATH) if (existing && b4a.from(existing).length > 0) { throw new Error('Account already exists — run: login (then enter passphrase at prompt)') } const { publicKey, secretKey } = generateEd25519Keypair() const blob = encodeAccount(passphrase, publicKey, secretKey) await drive.put(ACCOUNT_PATH, blob) await applyUnlockedEnv(ctx, publicKey, secretKey) secureZero(secretKey) await ensureBareDir(ctx) try { await ensureBareOsWwwHomeDefaults(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureBareOsWwwHomeDefaults: ' + (e?.message || e)) } try { await startBareUserSessionStack(ctx) } catch (e) { ctx.console?.error?.( '[bare-os] user session stack after register: ' + (e?.message || e) ) } try { await bareOsAppendVaultRotationCheckpoint(ctx, { kind: 'identity_register', user: String(ctx.vfs?.env?.USER || '') }) mirrorVaultCheckpointToAuditChain(ctx, { kind: 'identity_register', user: String(ctx.vfs?.env?.USER || '') }) } catch { /* ignore audit failures */ } ctx.console.log(`Registered identity ${ctx.vfs.env.USER} (Ed25519)`) } /** * @param {Record} ctx * @param {string} passphrase */ export async function unlockIdentity(ctx, passphrase) { const buf = await ctx.personalDrive.get(ACCOUNT_PATH) if (!buf) { const e = new Error('No account — run: login --new (then enter passphrase at prompt)') /** @type {Error & { code?: string }} */ e.code = 'BARE_OS_IDENTITY_NO_ACCOUNT' throw e } let publicKey let secretKey try { ;({ publicKey, secretKey } = decodeAccount(passphrase, b4a.from(buf))) } catch (err) { const e = new Error( 'Passphrase does not unlock this account (wrong passphrase or unreadable account file).' ) /** @type {Error & { code?: string, cause?: unknown }} */ e.code = 'BARE_OS_IDENTITY_PASSPHRASE_REJECTED' e.cause = err throw e } wipeSecret(ctx) await applyUnlockedEnv(ctx, publicKey, secretKey) await ensureBareDir(ctx) try { await ensureBareOsWwwHomeDefaults(ctx) } catch (e) { ctx.console?.error?.('[bare-os] ensureBareOsWwwHomeDefaults: ' + (e?.message || e)) } try { await startBareUserSessionStack(ctx) } catch (e) { ctx.console?.error?.( '[bare-os] user session stack after unlock: ' + (e?.message || e) ) } try { await bareOsAppendVaultRotationCheckpoint(ctx, { kind: 'identity_unlock', user: String(ctx.vfs?.env?.USER || '') }) mirrorVaultCheckpointToAuditChain(ctx, { kind: 'identity_unlock', user: String(ctx.vfs?.env?.USER || '') }) } catch { /* ignore audit failures */ } maybeAppendVaultMultisigContinuityAudit(ctx, 'unlockIdentity') ctx.console.log(`Unlocked as ${ctx.vfs.env.USER}`) } /** * @param {Record} ctx * @param {{ save?: boolean }} [opts] */ export async function logoutIdentity(ctx, opts = {}) { const prevUser = String(ctx.vfs?.env?.USER || '') if ( opts.save && ctx.identity?.state === 'unlocked' && ctx.identity?.secretKey ) { await saveVaultToDrive(ctx) } try { await bareOsAppendVaultRotationCheckpoint(ctx, { kind: 'identity_logout', user: prevUser, savedVault: !!opts.save }) mirrorVaultCheckpointToAuditChain(ctx, { kind: 'identity_logout', user: prevUser, savedVault: !!opts.save }) } catch { /* ignore audit failures */ } try { await stopBareUserSessionStack(ctx) } catch (e) { ctx.console?.error?.( '[bare-os] user session stack stop before logout: ' + (e?.message || e) ) } wipeSecret(ctx) await applyGuestEnv(ctx) try { if (ctx.disk && ctx.vfs?.env) { ensureDiskBareOsChatTransport( /** @type {import('./swarm-disk.js').SwarmDisk} */ (ctx.disk), /** @type {Record} */ (ctx.vfs.env) ) ensureDiskBareOsMeshdropTransport( /** @type {import('./swarm-disk.js').SwarmDisk} */ (ctx.disk), /** @type {Record} */ (ctx.vfs.env) ) await new Promise((r) => setImmediate(r)) if (typeof ctx.disk.pairBareOsChatExistingPeers === 'function') { ctx.disk.pairBareOsChatExistingPeers() } if (typeof ctx.disk.pairBareOsMeshdropExistingPeers === 'function') { ctx.disk.pairBareOsMeshdropExistingPeers() } } } catch (e) { ctx.console?.error?.( '[bare-os] guest swarm chat transport after logout: ' + (e?.message || e) ) } ctx.console.log('Logged out (guest)') } const VAULT_EXCLUDE_PREFIXES = [ 'bare/', 'bare', '.bare/', '.bare', '.bare-os/var/', '.bare-os/var', '.bare-os/migration/', '.bare-os/migration', '.vault/', '.vault', 'bin/', 'bin', 'boot/', 'boot' ] /** * @param {string} short path without leading slash */ function shouldVaultSkip(short, ctx) { const n = short.replace(/^\//, '') for (const ex of VAULT_EXCLUDE_PREFIXES) { if (n === ex || n.startsWith(ex + '/') || n.startsWith(ex + '\\')) return true } if (n.includes('bare_repl_history') || n.includes('nsh_history')) return true const want = currentVaultAcctPrefix(ctx) if (want && n.startsWith('bare-os/acct/') && !n.startsWith(want)) return true return false } /** * When `BARE_OS_PERSONAL_ACCT_PREFIX` is on, only vault files under the active account subtree. * @param {Record | null | undefined} ctx */ function currentVaultAcctPrefix(ctx) { const e = ctx?.vfs?.env if (!e) return '' if (e.BARE_OS_PERSONAL_ACCT_PREFIX !== '1' && e.BARE_OS_PERSONAL_ACCT_PREFIX !== 'true') return '' const home = String(e.HOME || '') const seg = home.startsWith('/home/') ? home.slice('/home/'.length).split('/')[0] : '' if (!seg) return 'bare-os/acct/_nosession/' if (seg === 'guest') return 'bare-os/acct/_guest/' const uid = String(e.UID || '').trim() if (uid && uid !== '65534') return `bare-os/acct/u${uid}/` return `bare-os/acct/h${seg}/` } /** * Export the active personal-drive namespace routing snapshot. * * This is intentionally pure data so operators/tools can persist and inspect * the current account-prefix routing decision without serializing secrets. * * @param {Record | null | undefined} env * @returns {{ * schema: 1, * accountPrefixRouting: boolean, * home: string, * uid: string, * homeSegment: string, * vaultAcctPrefix: string * }} */ export function exportPersonalDriveNamespace(env) { const home = String(env?.HOME || '') const uid = String(env?.UID || '').trim() const accountPrefixRouting = env?.BARE_OS_PERSONAL_ACCT_PREFIX === '1' || env?.BARE_OS_PERSONAL_ACCT_PREFIX === 'true' const homeSegment = home.startsWith('/home/') ? home.slice('/home/'.length).split('/')[0] || '' : '' const vaultAcctPrefix = accountPrefixRouting ? homeSegment === 'guest' ? 'bare-os/acct/_guest/' : uid && uid !== '65534' ? `bare-os/acct/u${uid}/` : homeSegment ? `bare-os/acct/h${homeSegment}/` : 'bare-os/acct/_nosession/' : '' return { schema: 1, accountPrefixRouting, home, uid, homeSegment, vaultAcctPrefix } } /** * Import a previously exported namespace snapshot and return a normalized route * decision for callers that need deterministic replay. * * @param {unknown} snapshot * @returns {{ * accepted: boolean, * schema: number, * accountPrefixRouting: boolean, * homeSegment: string, * vaultAcctPrefix: string * }} */ export function importPersonalDriveNamespace(snapshot) { if (!snapshot || typeof snapshot !== 'object') { return { accepted: false, schema: 0, accountPrefixRouting: false, homeSegment: '', vaultAcctPrefix: '' } } const s = /** @type {Record} */ (snapshot) const schema = Number(s.schema || 0) const accountPrefixRouting = !!s.accountPrefixRouting const homeSegment = String(s.homeSegment || '') let vaultAcctPrefix = String(s.vaultAcctPrefix || '') if (accountPrefixRouting && !vaultAcctPrefix) { vaultAcctPrefix = 'bare-os/acct/_nosession/' } return { accepted: schema === 1, schema, accountPrefixRouting, homeSegment, vaultAcctPrefix } } /** * Top-level personal-drive names we do not lift from `/` into `/.bare-os/home//` * during legacy migration (machine metadata, new layout root, old guest marker). * @param {string} name single path segment (no slashes) */ function legacyRootMigrateSkip(name) { if (shouldVaultSkip(name)) return true if (name === '.bare-os' || name === '.keep_guest') return true return false } /** * Physical personal-drive path for `/home/` (matches VFS routing, including acct prefix). * @param {Record} ctx * @param {string} seg */ function resolvePersonalHomeDrivePrefix(ctx, seg) { try { if (ctx.vfs && typeof ctx.vfs.route === 'function') { const rr = ctx.vfs.route(`/home/${seg}`) if ( rr && rr.path && typeof rr.path === 'string' && !rr.virtualHomeDir && rr.drive === ctx.personalDrive ) { return rr.path.replace(/\/+$/, '') || rr.path } } } catch { /* fall through */ } return `/.bare-os/home/${seg}` } /** * @param {import('hyperdrive').default} drive * @param {string} prefix absolute e.g. /.bare-os/home/guest */ async function personalHomePrefixIsEmpty(drive, prefix) { try { const names = await driveReaddirNames(drive, prefix) return names.length === 0 } catch { return true } } /** * @param {import('hyperdrive').default} drive * @param {string} fromAbs * @param {string} toAbs */ async function moveDriveSubtree(drive, fromAbs, toAbs) { const ent = await drive.entry(fromAbs, { follow: false }) if (!ent?.value) return const v = ent.value if (v.linkname != null) { await drive.putEntry(toAbs, { linkname: v.linkname, executable: !!v.executable, metadata: v.metadata ?? null }) await drive.del(fromAbs) return } if (v.blob) { const data = await drive.get(fromAbs, { follow: true }) if (data) { await drive.put(toAbs, data, { executable: !!v.executable, metadata: v.metadata ?? null }) } await drive.del(fromAbs) return } const names = await driveReaddirNames(drive, fromAbs) for (const n of names) { const f = fromAbs === '/' ? `/${n}` : `${fromAbs}/${n}` const t = toAbs === '/' ? `/${n}` : `${toAbs}/${n}` await moveDriveSubtree(drive, f, t) } try { await drive.del(fromAbs) } catch { /* directory may have no tombstone */ } } /** * @param {import('hyperdrive').default} drive * @param {string} absPrefix */ async function deleteDriveSubtree(drive, absPrefix) { if (!drive || typeof absPrefix !== 'string' || !absPrefix.startsWith('/')) return if (absPrefix !== '/.bare-os' && !absPrefix.startsWith('/.bare-os/')) return let ent try { ent = await drive.entry(absPrefix, { follow: false }) } catch { return } if (!ent?.value) return const v = ent.value if (v.blob || v.linkname != null) { try { await drive.del(absPrefix) } catch { /* ignore */ } return } const names = await driveReaddirNames(drive, absPrefix) for (const n of names) { const child = absPrefix === '/' ? `/${n}` : `${absPrefix.replace(/\/+$/, '')}/${n}` await deleteDriveSubtree(drive, child) } try { await drive.del(absPrefix) } catch { /* ignore */ } } /** * @param {import('hyperdrive').default} drive */ async function personalDriveHasAccount(drive) { try { const b = await drive.get(ACCOUNT_PATH) return !!(b && b4a.from(b).length > 0) } catch { return false } } /** * @param {import('hyperdrive').default} drive */ async function readLegacyMigrationState(drive) { try { const b = await drive.get(LEGACY_ROOT_MIGRATION_STATE) if (!b) return { schemaVersion: 1, segments: {} } const j = JSON.parse(b4a.toString(b)) if (j && typeof j === 'object' && j.segments && typeof j.segments === 'object') return j return { schemaVersion: 1, segments: {} } } catch { return { schemaVersion: 1, segments: {} } } } /** * @param {import('hyperdrive').default} drive * @param {Record} state */ async function writeLegacyMigrationState(drive, state) { try { await drive.put( LEGACY_ROOT_MIGRATION_STATE, b4a.from(JSON.stringify(state, null, 0) + '\n') ) } catch { /* ignore */ } } /** * Optional kiosk-style wipe of guest `/.bare-os/tmp/guest` and selected caches under guest `$HOME`. * @param {Record} ctx */ async function scrubGuestWorkspaces(ctx) { const e = ctx.env && typeof ctx.env === 'object' ? ctx.env : ctx.vfs?.env if ( !e || (e.BARE_OS_GUEST_SCRUB !== '1' && e.BARE_OS_GUEST_SCRUB !== 'true') ) { return } const drive = ctx.personalDrive const vfs = ctx.vfs if (!drive || !vfs || typeof vfs.route !== 'function') return /** @type {string[]} */ const scrubbed = [] try { const tr = vfs.route('/tmp') if ( tr && tr.drive === drive && typeof tr.path === 'string' && tr.path.startsWith('/.bare-os/') ) { await deleteDriveSubtree(drive, tr.path) scrubbed.push(tr.path) } } catch { /* ignore */ } try { const hr = vfs.route('/home/guest') if (hr && hr.drive === drive && typeof hr.path === 'string') { const base = hr.path.replace(/\/+$/, '') for (const sub of ['.cache', 'tmp']) { const p = `${base}/${sub}` await deleteDriveSubtree(drive, p) scrubbed.push(p) } } } catch { /* ignore */ } mirrorGuestScrubAudit(ctx, { paths: scrubbed }) } /** * One-time best-effort: flat personal-root files from older booters → `/.bare-os/home//`. * Never moves ambiguous `/` content into **guest** when an account blob exists (unless * `BARE_OS_PERSONAL_ROOT_MIGRATE=guest`). State: `/.bare-os/migration/legacy-root-v1.json`. * @param {Record} ctx */ export async function migrateLegacyPersonalHomeIfNeeded(ctx) { const drive = ctx.personalDrive const vfs = ctx.vfs if (!drive || typeof drive.put !== 'function' || !vfs?.env) return const migrateMode = String(vfs.env.BARE_OS_PERSONAL_ROOT_MIGRATE || '') .trim() .toLowerCase() if (migrateMode === 'skip') return const home = vfs.env.HOME if (typeof home !== 'string' || !home.startsWith('/home/')) return const seg = home.slice('/home/'.length).split('/')[0] if (!seg) return const prefix = resolvePersonalHomeDrivePrefix(ctx, seg) const state = await readLegacyMigrationState(drive) if (!state.segments || typeof state.segments !== 'object') state.segments = {} const segState = /** @type {{ completed?: boolean, skipped?: string }} */ ( state.segments[seg] || {} ) if (segState.completed || segState.skipped === 'account_present_guest') return const hasAccount = await personalDriveHasAccount(drive) if (seg === 'guest' && hasAccount && migrateMode !== 'guest') { state.segments[seg] = { skipped: 'account_present_guest', atMs: Date.now() } await writeLegacyMigrationState(drive, state) mirrorMigrationAudit(ctx, { segment: seg, action: 'skip_guest_root_migration', reason: 'account_blob_present' }) return } if (migrateMode === 'unlocked-only' && seg === 'guest') { state.segments[seg] = { skipped: 'unlocked_only_policy', atMs: Date.now() } await writeLegacyMigrationState(drive, state) return } if (!(await personalHomePrefixIsEmpty(drive, prefix))) { state.segments[seg] = { completed: true, atMs: Date.now(), note: 'home_nonempty' } await writeLegacyMigrationState(drive, state) return } const rootNames = await driveReaddirNames(drive, '/') let moved = 0 for (const name of rootNames) { if (legacyRootMigrateSkip(name)) continue const from = `/${name}` const to = `${prefix}/${name}` try { await moveDriveSubtree(drive, from, to) moved++ } catch { /* best-effort */ } } try { const oldGuest = await drive.get('/.keep_guest') if (oldGuest) await drive.del('/.keep_guest') } catch { /* ignore */ } state.segments[seg] = { completed: true, atMs: Date.now(), movedTopLevel: moved } await writeLegacyMigrationState(drive, state) mirrorMigrationAudit(ctx, { segment: seg, action: 'legacy_root_migrated', movedTopLevel: moved }) } /** * @param {import('hyperdrive').default} drive * @param {string} dir * @returns {Promise} */ async function driveReaddirNames(drive, dir) { const names = [] try { const stream = drive.readdir(dir) for await (const name of stream) names.push(name) } catch { return [] } return names } /** * Persists selected drive files under `/.bare/vault/` using symmetric sealed blobs (at-rest encryption). * @param {Record} ctx */ export async function saveVaultToDrive(ctx) { const id = ctx.identity if (!id?.secretKey || id.state !== 'unlocked') { throw new Error('Not logged in') } const env = /** @type {Record} */ (ctx.env || {}) const snapHint = env.BARE_OS_SAVEVAULT_PRESNAPSHOT_HINT === '1' || env.BARE_OS_SAVEVAULT_PRESNAPSHOT_HINT === 'true' if (snapHint && typeof globalThis.process?.emit === 'function') { try { globalThis.process.emit('bare-os:vault-pre-save-snapshot-hint', { atMs: Date.now(), note: 'Host may create a corestore-snapshot (or equivalent) of the personal namespace before vault sealing.' }) } catch { /* ignore */ } } const vaultKey = vaultKeyFromSecret(id.secretKey) const drive = ctx.personalDrive /** @type {Record} */ const indexFiles = {} /** @param {string} p absolute path on drive e.g. /foo */ async function walk(dir) { const names = await driveReaddirNames(drive, dir) for (const name of names) { const p = dir === '/' ? `/${name}` : `${dir}/${name}` const short = p.startsWith('/') ? p.slice(1) : p if (shouldVaultSkip(short, ctx)) continue const entry = await drive.entry(p, { follow: true }) const hasBlob = !!(entry && entry.value && entry.value.blob) if (hasBlob) { const data = await drive.get(p, { follow: true }) if (!data) continue const plain = b4a.from(data) const idHash = hashUtf8Path(b4a.from(p, 'utf8')) const blobPath = `/.bare/vault/blobs/${b4a.toString(idHash, 'hex')}` const sealed = sealBytes(vaultKey, plain) await drive.put(blobPath, sealed) indexFiles[p] = { blob: blobPath } } else { await walk(p) } } } await walk('/') const indexJson = b4a.from( JSON.stringify({ v: 2, t: Date.now(), files: indexFiles }) ) const packed = sealBytes(vaultKey, indexJson) await drive.put('/.bare/vault/index.bin', packed) secureZero(vaultKey) const fileCount = Object.keys(indexFiles).length ctx.console.log(`Vault saved (${fileCount} files)`) const pathcapTrustedRaw = String( env.BARE_OS_PATH_CAPABILITY_TRUSTED_PUBKEYS_HEX || '' ).trim() const pathcapTrustedKeyCount = pathcapTrustedRaw ? pathcapTrustedRaw.split(/[\s,]+/).filter(Boolean).length : 0 try { await bareOsAppendVaultRotationCheckpoint(ctx, { kind: 'vault_save', fileCount, pathcapTrustedKeyCount, ...(snapHint ? { preSnapshotHintEmitted: true } : {}) }) mirrorVaultCheckpointToAuditChain(ctx, { kind: 'vault_save', fileCount }) } catch (e) { ctx.console?.error?.( '[bare-os] vault-save audit: ' + (e?.message || e) ) } } export { bareOsAppendVaultRotationCheckpoint } from './bare-os-vault-rotation-audit.js'