1172 lines
32 KiB
JavaScript
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'
|