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

1172 lines
32 KiB
JavaScript

/**
* 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<string, unknown>} 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<string, unknown>} ctx
* @param {Record<string, unknown>} 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<string, unknown>} 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<string, unknown>} ctx
* @param {Record<string, unknown>} 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<string, unknown>} ctx
* @param {Record<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<string, string | undefined>} */ (ctx.vfs.env)
)
ensureDiskBareOsMeshdropTransport(
/** @type {import('./swarm-disk.js').SwarmDisk} */ (ctx.disk),
/** @type {Record<string, string | undefined>} */ (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<string, unknown> | 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<string, string | undefined> | 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<string, unknown>} */ (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/<seg>/`
* 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/<seg>` (matches VFS routing, including acct prefix).
* @param {Record<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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/<seg>/`.
* 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<string, unknown>} 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<string[]>}
*/
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<string, unknown>} 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<string, string>} */ (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<string, { blob: string }>} */
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'