// The device credential on disk, and the ceremonies that produce one.
//
// ── What a device IS ──────────────────────────────────────────────────────────────────────────
//
// A real Ed25519 keypair with a chain proof to the identity root: `{ keyPair, proof }`. It CAN
// sign, which is what makes everything else work — an already-linked device attests the next one
// from its own keypair, with no mnemonic. That is exactly what pear-git-v3's DeviceLinker ships
// over blind-pairing (`{ deviceSecretKey, deviceProof, rootPubkey }`, never the root secret).
//
// ⛔ A hypercore WRITER key is not a device. It is manifest-derived, nobody holds its secret half,
// and it can never sign — measured: attesting one and then trying to attest FROM it is refused. So
// a writer core is BOUND to a device as attested data (`bindWriter`), and the roster records which
// device stands behind each core.
//
// ⇒ The vault is opened for exactly two things: minting the FIRST device of an identity, and
// recovering onto a machine that has no linked device and no peer to pair with. Never at boot, and
// never to add a device when another one is reachable.
//
// ⚠ An earlier version of this file concluded the opposite — that every device needs the vault —
// from a measurement taken against a writer core key rather than a device keypair. The primitive
// was never the limitation; the modelling was.
import fs from '#node:fs'
import process from '#node:process'
import { join } from '#node:path'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'
// ⛔ THEIRS, NOT OURS. argon2id + secretbox, and it is that only because the scheme it replaced was
// repeating-key XOR against a mnemonic drawn from a PUBLIC 2048-word list — recoverable WITHOUT the
// passphrase (hyper-social #47/#48). This monorepo already carries several copies of one signing
// format; a fourth vault crypto is how that class of bug returns. Import the one that was fixed.
import { CryptoManager } from 'hyperident/lib/crypto.js'
const FILE = 'device.json'
const FEED = 'feed.key'
const NAMESPACE = 'namespace.key'
const NAMESPACE_JSON = 'namespace.json'
const NAMESPACES_JSON = 'namespaces.json'
const ROOMS = 'rooms.json'
export function proofPath (storage) { return join(storage, FILE) }
export function feedKeyPath (storage) { return join(storage, FEED) }
/**
* The feed this node writes to, if it was told one.
*
* ⛔ A node that founds its own feed never writes this — the key is derived when the log is created.
* It exists for a device that JOINED an identity: pairing hands over the feed key along with the
* credential, because being someone's device and writing to their feed are two different facts and
* only the first is in the chain.
*/
export function loadFeedKey (storage) {
try {
const s = fs.readFileSync(feedKeyPath(storage), 'utf8').trim()
return /^[0-9a-f]{64}$/.test(s) ? s : null
} catch {
return null
}
}
/**
* The namespace this node resolves handles in.
*
* ⛔ A FILE, not a constant. Shipping a hardcoded namespace would make us its authority; NAMING.md
* §8 #2 is explicit that a default which cannot be changed is a root. Edit this file to join a
* different one; delete it to start your own.
*/
export function loadNamespaceKey (storage) {
try {
const s = fs.readFileSync(join(storage, NAMESPACE), 'utf8').trim()
return /^[0-9a-f]{64}$/.test(s) ? s : null
} catch {
return null
}
}
export function saveNamespaceKey (storage, hex) {
fs.writeFileSync(join(storage, NAMESPACE), String(hex).trim() + '\n')
}
/**
* The namespace this node resolves handles in, AND the Corestore slot its log lives under.
*
* ⛔ The slot has to be recorded, not derived. Two Autobees sharing one Corestore namespace derive
* the same local writer and DEADLOCK rather than erroring (`node.js`, measured 2026-09-06), so each
* namespace gets its own — but the one this node CREATED could not be named after its key, because
* the key does not exist until the log is opened. So it lives in the legacy slot `names` and joined
* ones get `names:<key>`. Guessing that from the key alone is impossible; the file says which.
*
* ⚠ Seeds from the legacy `namespace.key`, which every deployed host holds, and never deletes it.
*/
export function loadNamespace (storage) {
try {
const raw = JSON.parse(fs.readFileSync(join(storage, NAMESPACE_JSON), 'utf8'))
if (raw && /^[0-9a-f]{64}$/.test(raw.key) && typeof raw.slot === 'string' && raw.slot) return raw
} catch {}
const legacy = loadNamespaceKey(storage)
// The legacy file names a log that was opened under `names`, and that is where its claims are.
if (legacy) return { key: legacy, slot: 'names' }
// ⛔ A DEFAULT, AND IT IS CONFIGURATION. Without one a fresh node FOUNDS ITS OWN namespace and
// resolves handles in a world containing only itself — so a new device could join the fleet, hold
// the right feeds, and still find nobody by name. That is what a handset did, and it read as a
// broken network rather than an empty registry.
//
// ⚠ `namespace.js` permits exactly this and states the limit: "Shipping a default namespace makes
// us a convenient starting point, never an authoritative one… So the key is an option." NAMING.md
// §8 #2 is the rule it must not break — "a default that cannot be changed is a root" — so
// FAVELA_NAMESPACE overrides it, `namespace --join` moves off it, and nothing here is load-bearing
// for anyone who has chosen otherwise.
//
// ⚠ TEMPORARY, by request. It hardcodes ONE fleet's registry into every install, which is a
// starting point and not a design: the right end state is a default a person picks during
// onboarding, or none at all for someone who wants their own world.
// ⛔ `own` MUST REMAIN POSSIBLE. A default that cannot be declined does not merely inconvenience
// someone who wants their own world — it removes the ability to be an ADMIN of anything, because
// admin rights come from founding. `test/namespace-join.test.js` asserts exactly that ("a linked
// node FOUNDS its namespace — so it has an admin from the first boot"), and it caught this.
const configured = envNamespace()
if (configured === OWN) return null
const key = configured || DEFAULT_NAMESPACE
return key ? { key, slot: namespaceSlot(key) } : null
}
// FAVELA_NAMESPACE=own — found a fresh one, the behaviour every node had before the default.
const OWN = 'own'
// tabertron's namespace — the one the fleet already resolves in, holding tabertron0, stortron0,
// ashatron1 and fleeky0. Measured from the running daemon rather than copied from a config file:
// `~/.favela/namespace.json` is a STALE storage root the daemon does not use, and reading it is how
// an earlier audit concluded the fleet was fragmented when it was not.
const DEFAULT_NAMESPACE = '2c9290ede07757087ba7983708508174768f92879ddd94858fac215ecbff9ca5'
function envNamespace () {
try {
const v = process.env.FAVELA_NAMESPACE
if (typeof v !== 'string') return null
const t = v.trim().toLowerCase()
if (t === OWN) return OWN
return /^[0-9a-f]{64}$/.test(t) ? t : null
} catch { return null }
}
export function saveNamespace (storage, { key, slot }) {
fs.writeFileSync(join(storage, NAMESPACE_JSON), JSON.stringify({ key, slot }, null, 2) + '\n')
}
/**
* Every namespace this node has on disk — `[{ key, slot }]`, current one included.
*
* ⛔ AN INDEX, because the slots cannot be enumerated. A joined namespace lives under
* `names:<key>` and the founded one under the legacy `names`, and nothing on disk lists them — so
* `namespaceSettings` could truthfully promise "you can come back to it, handles and all" while
* giving nobody a way to find out what "it" was. A 64-hex key you have to remember is not a door.
*
* ⚠ LOCAL state, exactly like `loadRooms`: which namespaces this machine holds is a fact about this
* machine, not something derivable from anyone's log.
*/
export function loadNamespaces (storage) {
let list = []
try {
const raw = JSON.parse(fs.readFileSync(join(storage, NAMESPACES_JSON), 'utf8'))
if (Array.isArray(raw)) list = raw.filter((n) => n && /^[0-9a-f]{64}$/.test(n.key) && typeof n.slot === 'string' && n.slot)
} catch {}
// ⛔ The CURRENT one is always in the list, even if the index predates it or was never written.
// An index that could omit the namespace you are standing in would offer you a switch to nowhere.
const cur = loadNamespace(storage)
if (cur && !list.some((n) => n.key === cur.key)) list = [{ key: cur.key, slot: cur.slot }, ...list]
return list
}
/**
* Which Corestore slot a namespace's log belongs in — pure, so the rule can be tested.
*
* ⛔ THE FOUNDED NAMESPACE IS THE WHOLE REASON THIS EXISTS. Its log lives in the legacy slot
* `names`, which `namespaceSlot(key)` cannot produce because the key did not exist when the slot was
* named. So leaving it and returning computed `names:<key>`, opened a SECOND log, and left every
* handle claimed there resolving from a slot this node no longer used. Nothing was lost; it was
* somewhere else, which is worse, because it presents as loss.
*
* Order matters: the namespace we are standing in knows its own slot, then the index, then derive.
*/
export function slotFor (key, { current = null, known = [] } = {}) {
if (current && current.key === key && current.slot) return current.slot
const recorded = (known || []).find((n) => n && n.key === key && n.slot)
return recorded ? recorded.slot : namespaceSlot(key)
}
export function saveNamespaces (storage, list) {
const seen = new Set()
const clean = []
for (const n of list || []) {
if (!n || !/^[0-9a-f]{64}$/.test(n.key) || seen.has(n.key)) continue
seen.add(n.key)
clean.push({ key: n.key, slot: n.slot })
}
fs.writeFileSync(join(storage, NAMESPACES_JSON), JSON.stringify(clean, null, 2) + '\n')
}
/**
* The rooms this node is in — `[{ key, slot }]`.
*
* ⚠ LOCAL state, not derived from anyone's log. A room key is a CAPABILITY: holding it is what lets
* this node replicate the room at all, so the list of rooms it is in is a fact about this machine.
*/
export function loadRooms (storage) {
try {
const raw = JSON.parse(fs.readFileSync(join(storage, ROOMS), 'utf8'))
return Array.isArray(raw) ? raw.filter((r) => r && /^[0-9a-f]{64}$/.test(r.key) && r.slot) : []
} catch {
return []
}
}
export function saveRooms (storage, rooms) {
fs.writeFileSync(join(storage, ROOMS), JSON.stringify(rooms, null, 2) + '\n')
}
/** The Corestore slot for a namespace we are JOINING rather than one we made. */
export const namespaceSlot = (keyHex) => 'names:' + String(keyHex).toLowerCase()
export function saveFeedKey (storage, hex) {
fs.writeFileSync(feedKeyPath(storage), String(hex).trim() + '\n')
}
/** The stored proof, or null. Never throws — a missing credential is a state, not a fault. */
/** The stored credential `{ keyPair, proof }`, or null. Never throws. */
export function loadCredential (storage) {
try {
const raw = JSON.parse(fs.readFileSync(proofPath(storage), 'utf8'))
if (!raw || !raw.proof || !raw.secretKey || !raw.publicKey) return null
return {
keyPair: { publicKey: b4a.from(raw.publicKey, 'hex'), secretKey: b4a.from(raw.secretKey, 'hex') },
proof: raw.proof
}
} catch {
return null
}
}
// ⚠ This file holds a SECRET KEY. Mode 0600, and it is the one thing on disk whose loss means
// re-linking the device — not re-creating the identity, which lives only in the vault.
export function saveCredential (storage, cred) {
const body = JSON.stringify({
publicKey: b4a.toString(cred.keyPair.publicKey, 'hex'),
secretKey: b4a.toString(cred.keyPair.secretKey, 'hex'),
proof: cred.proof
}, null, 2)
fs.writeFileSync(proofPath(storage), body + '\n', { mode: 0o600 })
}
/**
* Bind a hypercore writer key to this device — what an `add-device` op carries.
*
* The writer key travels as ATTESTED DATA inside the proof, so the result is good for that one
* core and nothing else. Replaying it onto another core does not verify.
*/
export function bindWriter (cred, writerKeyHex) {
const bound = IdentityKey.attestData(
b4a.from(writerKeyHex, 'hex'), cred.keyPair, b4a.from(cred.proof, 'base64'))
return b4a.toString(bound, 'base64')
}
/**
* Attest a NEW device from this one. No mnemonic — the blind-pairing path.
*
* Returns a full credential for the target, whose SECRET KEY is generated here and must reach the
* target over a channel that authenticated it (blind-pairing). Treat the return value as secret.
*/
export function mintForDevice (cred) {
const keyPair = crypto.keyPair()
const proof = IdentityKey.attestDevice(
keyPair.publicKey, cred.keyPair, b4a.from(cred.proof, 'base64'))
return { keyPair, proof: b4a.toString(proof, 'base64') }
}
/**
* Mint a proof for `deviceKeyHex` from a hyperident vault file.
*
* ⚠ Reads the vault JSON directly rather than going through hyperident's IdentityManager, which
* cannot load under Bare: `lib/file-ops.js` uses `process.` without importing `bare-process`
* (`lib/debug-manager.js` too). One line each, in a repo we do not own here.
*
* ⛔ An ENCRYPTED vault now opens, and the crypto is HYPERIDENT'S — `CryptoManager` only, never
* `IdentityManager`, for the Bare reason directly above. The previous version refused outright on the
* grounds that "doing that badly is worse than not doing it", which was right while the alternative
* was writing a fourth argon2id implementation in this monorepo. It is not right when the fixed one
* can simply be imported, and it was a wall rather than a nuisance on a handset: no second machine to
* link from, and no CLI to decrypt with.
*
* ⚠ Still a link-time ceremony a human is present for, not a boot path. The passphrase is passed in
* by the caller that collected it; nothing here reads a prompt, and the mnemonic never leaves this
* function.
*/
export async function mintFromVault (vaultPath, { passphrase = null } = {}) {
let raw
try {
raw = JSON.parse(fs.readFileSync(vaultPath, 'utf8'))
} catch (err) {
throw new Error('no identity vault at ' + vaultPath + ' (' + err.message + ')')
}
if (!raw.mnemonic) throw new Error('the vault at ' + vaultPath + ' carries no mnemonic')
let mnemonic = raw.mnemonic
if (raw.encrypted) {
// ⛔ NO `kdf` MEANS PRE-#47, AND THAT IS NOT A PASSPHRASE PROBLEM. Such a vault is XOR-"encrypted",
// which protects nothing: the plaintext is lowercase words from a public list, ~150-200 bytes
// against a 32-byte repeating key. Reporting "wrong passphrase" would send someone to try harder
// at the wrong thing; the secret should be treated as EXPOSED and the identity re-minted.
if (!raw.kdf) {
throw new Error('the vault at ' + vaultPath + ' was written with the pre-#47 XOR scheme, which ' +
'does not protect it. Recover the mnemonic with an older hyperident and re-create the vault. ' +
'Treat the secret as exposed if the file ever left this machine.')
}
if (!passphrase) {
throw new Error('the vault at ' + vaultPath + ' is encrypted — a passphrase is required to ' +
'unlock it. (Linking from an ALREADY-LINKED device needs no passphrase and no mnemonic.)')
}
// ⚠ HONOUR THE COST THE VAULT WAS WRITTEN WITH. Using today's defaults would derive a different
// key and surface as a wrong passphrase — a vault written on a phone at INTERACTIVE would become
// unopenable the day the default is raised. The parameters are recorded per-vault for this.
const params = {
...CryptoManager.kdfParams(),
opslimit: raw.opslimit || CryptoManager.kdfParams().opslimit,
memlimit: raw.memlimit || CryptoManager.kdfParams().memlimit
}
try {
const box = Buffer.from(raw.mnemonic, 'hex')
mnemonic = CryptoManager.decryptSecret(box, passphrase, Buffer.from(raw.salt, 'hex'), params).toString('utf8')
} catch {
// Deliberately not the underlying crypto error: secretbox failing and a mistyped passphrase are
// the same event to the person at the keyboard, and the detail leaks nothing useful.
throw new Error('the vault at ' + vaultPath + ' did not open — wrong passphrase')
}
}
const idk = await IdentityKey.from({ mnemonic })
// A FRESH device keypair, attested by the root. The vault's secret never leaves this function.
const keyPair = crypto.keyPair()
const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, keyPair.publicKey)
return { keyPair, proof: b4a.toString(proof, 'base64') }
}
export default { slotFor, loadNamespaces, saveNamespaces, loadRooms, saveRooms, proofPath, feedKeyPath, loadFeedKey, saveFeedKey, loadNamespaceKey, saveNamespaceKey, loadNamespace, saveNamespace, namespaceSlot, loadCredential, saveCredential, bindWriter, mintForDevice, mintFromVault }