| … | … | @@ -44,7 +44,10 @@ const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') |
| 44 | 44 | // would discard a device's mnemonic and roster — the one thing that cannot be regenerated (a fresh key |
| 45 | 45 | // orphans an attestation and leaves the machine permanently unadmitted). Missing means "new"; unreadable |
| 46 | 46 | // means "stop". |
| 47 | function readConfigFile (p) { | |
| 47 | // Exported for lib/mint.js, which writes the same `dids[]` file for a device that CREATES an identity | |
| 48 | // rather than joining one. Shared rather than reimplemented on purpose: the ENOENT-is-not-an-error rule | |
| 49 | // above and the backup-before-write rule below are both load-bearing, and a second copy would drift. | |
| 50 | export function readConfigFile (p) { | |
| 48 | 51 | let raw |
| 49 | 52 | try { raw = fs.readFileSync(p, 'utf8') } catch (e) { |
| 50 | 53 | if (e && (e.code === 'ENOENT' || /ENOENT/.test(e.message || ''))) return {} |
| … | … | @@ -55,7 +58,7 @@ function readConfigFile (p) { |
| 55 | 58 | |
| 56 | 59 | // Never write a config without leaving the previous one on disk. A mistyped roster locks a machine out |
| 57 | 60 | // of a room, and the old file is the fastest way back. |
| 58 | function writeConfigFile (p, cfg) { | |
| 61 | export function writeConfigFile (p, cfg) { | |
| 59 | 62 | const backup = p + '.bak.' + Math.floor(Date.now() / 1000) |
| 60 | 63 | try { fs.copyFileSync(p, backup) } catch {} |
| 61 | 64 | fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n') |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | // MINT — create a NEW atproto identity on the device that will use it. | |
| 2 | // | |
| 3 | // The symmetric half of lib/coowner.js. coownerJoin copies an EXISTING DID onto a second device; this | |
| 4 | // creates the first one. Without it a node could only ever receive an identity, which left the origin | |
| 5 | // of every identity outside the product: `bin/hpp-onboard.js` on a server with a hyperident and a shell. | |
| 6 | // A handset has neither, so a second test phone was blocked on a step that could not be performed on it | |
| 7 | // (hyper-social #54 §8). | |
| 8 | // | |
| 9 | // This is hpp-onboard's fresh-mint path — the same createSwarmIdentity + provisionDevice — writing the | |
| 10 | // MULTI-DID `dids[]` entry that bootMultiDevice reads, rather than the single-DID server config. | |
| 11 | // | |
| 12 | // ── P2P-ONLY BY DEFAULT ────────────────────────────────────────────────────────────────────────── | |
| 13 | // No handle, no PDS. Creating an identity and joining someone's infrastructure are different decisions, | |
| 14 | // and requiring both at once is what made minting undecidable — you could not have a DID without first | |
| 15 | // answering "whose server are you on?". The domain handle and the service endpoint arrive LATER, as a | |
| 16 | // PLC update op, when an invite from atproto-bridge or ap-bridge is accepted. That ordering is forced, | |
| 17 | // not chosen: an update op must chain onto a genesis already in the log. | |
| 18 | // | |
| 19 | // ── ⛔ CUSTODY: THE ROOT MNEMONIC MUST NOT STAY HERE ────────────────────────────────────────────── | |
| 20 | // The root is the recovery authority — it can rotate the DID's signing key and therefore revoke every | |
| 21 | // device. device-node.js is explicit that a device gets { did, signingKey, deviceProof, rootIdentityKey } | |
| 22 | // and NEVER the root, "so it can't derive the recovery key or self-recover". Minting on the device is | |
| 23 | // the one moment the root exists there. So it is RETURNED and never written: the caller shows it once | |
| 24 | // and seals it into the vault (atproto-identity/lib/vault.js), and nothing persists it in the config. | |
| 25 | // test/mint.test.mjs asserts the config does not contain it. | |
| 26 | // | |
| 27 | // ── WHAT THIS DOES NOT DO ──────────────────────────────────────────────────────────────────────── | |
| 28 | // It does not touch the network. Submitting the genesis to the PLC directory is a PERMANENT, PUBLIC, | |
| 29 | // IRREVERSIBLE append to a global log, and it is a separate call for that reason — the same split | |
| 30 | // hpp-onboard makes with --submit. A mint that cannot reach the directory still produces a working P2P | |
| 31 | // identity, and the op can be re-derived from the mnemonic and submitted later (see the determinism | |
| 32 | // test): submission is recoverable, so it must not be able to fail the part that isn't. | |
| 33 | import b4a from 'b4a' | |
| 34 | import IdentityKey from 'keet-identity-key' | |
| 35 | import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js' | |
| 36 | import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js' | |
| 37 | import { provisionDevice } from './device-node.js' | |
| 38 | import { readConfigFile, writeConfigFile } from './coowner.js' | |
| 39 | import { configPath } from './config.js' | |
| 40 | ||
| 41 | const hex = (u8) => b4a.toString(b4a.from(u8), 'hex') | |
| 42 | ||
| 43 | /** | |
| 44 | * Create a new identity and write the dids[] entry this device boots from. | |
| 45 | * | |
| 46 | * @param {object} opts | |
| 47 | * @param {string} [opts.rootMnemonic] the recovery root. Omitted, a fresh one is generated — the normal | |
| 48 | * case. Passing one is for tests and for re-minting a known identity. | |
| 49 | * @param {string} [opts.configFile] defaults to the usual PEER_CONFIG / ~/.config path. | |
| 50 | * @param {string|null} [opts.handle] leave null for P2P-only. Naming one here mints a DIFFERENT DID. | |
| 51 | * @param {string|null} [opts.pdsUrl] as above. | |
| 52 | * @param {function} [opts.onStep] progress, mirroring joinAdoptInvite's reporting. | |
| 53 | * @returns {{did, rootMnemonic, devicePublicKey, signedOp, entry}} | |
| 54 | */ | |
| 55 | export async function mintIdentity ({ rootMnemonic = null, configFile = null, handle = null, pdsUrl = null, onStep = () => {} } = {}) { | |
| 56 | const p = configFile || configPath() | |
| 57 | const cfg = readConfigFile(p) | |
| 58 | if (!Array.isArray(cfg.dids)) cfg.dids = [] | |
| 59 | ||
| 60 | // ⛔ REFUSE RATHER THAN ADD. A device that already holds an identity is not asking to mint a second | |
| 61 | // one — it has arrived here by a routing mistake or a double-tap, and the cost of being wrong is | |
| 62 | // asymmetric: minting is cheap to repeat and impossible to undo, and a stray second entry would have | |
| 63 | // this node signing as an identity nobody else in the room knows about. | |
| 64 | if (cfg.dids.length) { | |
| 65 | throw new Error('mint: this device already has an identity (' + cfg.dids[0].did + ') — refusing to mint another') | |
| 66 | } | |
| 67 | ||
| 68 | const root = rootMnemonic || IdentityKey.generateMnemonic() | |
| 69 | onStep('generating this device\'s key') | |
| 70 | // DEVICE-SCOPED, and a different secret from the root. The device key is what the roster admits; the | |
| 71 | // root is what attests it. Conflating them would put the recovery authority on every device. | |
| 72 | const deviceMnemonic = IdentityKey.generateMnemonic() | |
| 73 | const deviceKp = await deviceKeetKeyPair(deviceMnemonic) | |
| 74 | const devicePublicKey = hex(deviceKp.publicKey) | |
| 75 | ||
| 76 | onStep('creating the identity') | |
| 77 | const bundle = await provisionDevice({ rootMnemonic: root, devicePublicKey, handle, pdsUrl }) | |
| 78 | ||
| 79 | const entry = { | |
| 80 | did: bundle.did, | |
| 81 | deviceMnemonic, | |
| 82 | deviceProof: bundle.deviceProof, | |
| 83 | signingKey: bundle.signingKey, | |
| 84 | rootIdentityKey: bundle.rootIdentityKey, | |
| 85 | // The roster this device will be admitted against. It contains itself and nothing else: it is the | |
| 86 | // only device that exists. Co-owners are added later by the invite ceremony. | |
| 87 | members: [devicePublicKey], | |
| 88 | // THE FIRST DEVICE HOLDS THE GENESIS. This is the opposite posture from coownerJoin's `joiner`, and | |
| 89 | // it is explicit for the reason recorded there: the creator election is "lowest-keyed member == | |
| 90 | // self", so leaving the role to emerge from key ordering silently flips it when a device joins whose | |
| 91 | // key sorts lower. A room's creator is a property, not an accident. | |
| 92 | forceCreator: true | |
| 93 | } | |
| 94 | // handle / pdsUrl are deliberately ABSENT rather than null — an entry that says nothing about | |
| 95 | // infrastructure is different from one that says "none", and the invite will add the real values. | |
| 96 | if (handle) entry.handle = handle | |
| 97 | if (pdsUrl) entry.pdsUrl = pdsUrl | |
| 98 | ||
| 99 | cfg.dids.push(entry) | |
| 100 | writeConfigFile(p, cfg) | |
| 101 | onStep('identity written — restart to sign') | |
| 102 | ||
| 103 | return { | |
| 104 | did: bundle.did, | |
| 105 | // ⛔ SHOW ONCE, SEAL, FORGET. Never persisted here. See the custody note above. | |
| 106 | rootMnemonic: root, | |
| 107 | devicePublicKey, | |
| 108 | signedOp: bundle.signedOp, | |
| 109 | entry: { did: bundle.did, members: entry.members } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * Submit a minted genesis to the PLC directory. | |
| 115 | * | |
| 116 | * ⛔ PERMANENT AND PUBLIC. This appends to a global append-only log. There is no delete, and a test | |
| 117 | * identity is forever. Kept separate from mintIdentity so that the irreversible act is always its own | |
| 118 | * explicit call, and so a mint without network still succeeds. | |
| 119 | * | |
| 120 | * Safe to retry: the directory accepts a re-submission of the identical genesis, and the op can be | |
| 121 | * re-derived from the root mnemonic alone if this device is gone. | |
| 122 | */ | |
| 123 | export async function submitMintedGenesis ({ did, signedOp, plcUrl = null, onStep = () => {} } = {}) { | |
| 124 | if (!did || !signedOp) throw new Error('submitMintedGenesis requires { did, signedOp }') | |
| 125 | // Bare has no global fetch and PlcDirectory needs one for the POST. Installed lazily so that minting | |
| 126 | // — which is offline — never drags the network stack in. Same approach as hpp-onboard. | |
| 127 | if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global') | |
| 128 | onStep('registering in the global directory') | |
| 129 | const plc = new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {}) | |
| 130 | const data = await plc.submitGenesis({ did, signedOp }) | |
| 131 | onStep('registered') | |
| 132 | return { did, submitted: true, alsoKnownAs: (data && data.alsoKnownAs) || [], services: (data && data.services) || {} } | |
| 133 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.119", | |
| 3 | "version": "0.3.120", | |
| 4 | 4 | "description": "Self-custody, P2P-native atproto peer — holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/peer.js", |
| … | … | @@ -9,6 +9,7 @@ import './conflict-watch.test.js' // fast + offline; drives a REAL conflict — |
| 9 | 9 | import './side-store-channel-priority.test.js' // fast + offline |
| 10 | 10 | import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22) |
| 11 | 11 | import './inline-migration-boot.test.mjs' // fast + offline |
| 12 | import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it | |
| 12 | 13 | import './swarm-device.test.js' |
| 13 | 14 | import './event-driven-sign.test.mjs' |
| 14 | 15 | import './failover-election.test.mjs' |
| … | … | @@ -0,0 +1,127 @@ |
| 1 | // MINTING AN IDENTITY ON THE DEVICE THAT WILL USE IT. | |
| 2 | // | |
| 3 | // Until now a node could only RECEIVE an identity — coownerJoin copies an existing DID onto a second | |
| 4 | // device. That leaves the first device unexplained: something has to create the DID, and on a handset | |
| 5 | // there is no CLI to run hpp-onboard from. So a second test phone was blocked on a step that existed | |
| 6 | // only as a shell script on a server (hyper-social #54 §8). | |
| 7 | // | |
| 8 | // mintIdentity is hpp-onboard's fresh-mint path, writing the MULTI-DID `dids[]` entry a phone boots | |
| 9 | // from instead of the single-DID server config. It mints P2P-ONLY: no handle, no PDS. Creating an | |
| 10 | // identity and joining someone's infrastructure are separate decisions, and the domain handle arrives | |
| 11 | // later as a PLC update op when an invite from atproto-bridge or ap-bridge is accepted. | |
| 12 | // | |
| 13 | // ⛔ THE LOAD-BEARING ASSERTION IS THE CUSTODY ONE. The root mnemonic is the recovery authority: it can | |
| 14 | // rotate the DID's signing key and therefore revoke every device. device-node.js is explicit that a | |
| 15 | // device receives { did, signingKey, deviceProof, rootIdentityKey } and NEVER the root, "so it can't | |
| 16 | // derive the recovery key or self-recover". Minting on the device is the one moment the root exists | |
| 17 | // there, so the test that matters is that it does not stay: it is returned to be shown once and sealed | |
| 18 | // into the vault, and it is never written to the config. | |
| 19 | // | |
| 20 | // Run: bare test/mint.test.mjs | |
| 21 | import '../lib/bare-globals.js' | |
| 22 | import test from 'brittle' | |
| 23 | import fs from 'bare-fs' | |
| 24 | import os from 'bare-os' | |
| 25 | import path from 'bare-path' | |
| 26 | import { checkGenesisOp } from 'atproto-identity/lib/plc-directory.js' | |
| 27 | import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js' | |
| 28 | import { mintIdentity } from '../lib/mint.js' | |
| 29 | ||
| 30 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 31 | ||
| 32 | function tmpCfg (name) { | |
| 33 | const dir = path.join(os.tmpdir(), 'hpp-mint-' + name + '-' + process.pid) | |
| 34 | fs.mkdirSync(dir, { recursive: true }) | |
| 35 | return path.join(dir, 'config.json') | |
| 36 | } | |
| 37 | ||
| 38 | test('mints a did:plc and writes a bootable dids[] entry', async (t) => { | |
| 39 | const cfgFile = tmpCfg('basic') | |
| 40 | const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile }) | |
| 41 | ||
| 42 | t.ok(r.did && r.did.startsWith('did:plc:'), 'a real did:plc — ' + r.did) | |
| 43 | const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8')) | |
| 44 | t.is(cfg.dids.length, 1, 'one entry written') | |
| 45 | const e = cfg.dids[0] | |
| 46 | t.is(e.did, r.did, 'the entry names the minted DID') | |
| 47 | t.ok(e.signingKey, 'carries the #atproto signing key — this device can author') | |
| 48 | t.ok(e.deviceProof, 'carries the device attestation') | |
| 49 | t.ok(e.rootIdentityKey, 'carries the root PUBLIC key (the admission anchor)') | |
| 50 | t.ok(e.deviceMnemonic, 'carries its own device mnemonic') | |
| 51 | }) | |
| 52 | ||
| 53 | test('⛔ the root mnemonic is returned, never written to disk', async (t) => { | |
| 54 | const cfgFile = tmpCfg('custody') | |
| 55 | const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile }) | |
| 56 | ||
| 57 | t.is(r.rootMnemonic, ROOT, 'returned to the caller — to show once and seal into the vault') | |
| 58 | const raw = fs.readFileSync(cfgFile, 'utf8') | |
| 59 | t.absent(raw.includes(ROOT), 'the root mnemonic is NOT in the config') | |
| 60 | for (const word of ROOT.split(' ').slice(0, 3)) { | |
| 61 | t.absent(new RegExp('"[^"]*' + word + ' ' + word).test(raw), 'no fragment of it either (' + word + ')') | |
| 62 | } | |
| 63 | const cfg = JSON.parse(raw) | |
| 64 | t.absent(cfg.rootMnemonic, 'no rootMnemonic field') | |
| 65 | t.absent(cfg.dids[0].rootMnemonic, 'not on the entry either') | |
| 66 | t.not(cfg.dids[0].deviceMnemonic, ROOT, 'the device mnemonic is a DIFFERENT secret') | |
| 67 | }) | |
| 68 | ||
| 69 | test('P2P-only: names nobody\'s infrastructure', async (t) => { | |
| 70 | const cfgFile = tmpCfg('p2p') | |
| 71 | const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile }) | |
| 72 | const e = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).dids[0] | |
| 73 | ||
| 74 | t.absent(e.handle, 'no handle') | |
| 75 | t.absent(e.pdsUrl, 'no PDS') | |
| 76 | t.alike(r.signedOp.alsoKnownAs, [], 'and the op claims no alias') | |
| 77 | t.alike(r.signedOp.services, {}, 'and names no service') | |
| 78 | }) | |
| 79 | ||
| 80 | test('the genesis op it produces is submittable', async (t) => { | |
| 81 | const r = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('genesis') }) | |
| 82 | const chk = checkGenesisOp(r.signedOp, r.did) | |
| 83 | t.ok(chk.ok, 'passes the directory\'s genesis validation: ' + (chk.error || 'ok')) | |
| 84 | }) | |
| 85 | ||
| 86 | test('this device is the CREATOR, and is in its own roster', async (t) => { | |
| 87 | const cfgFile = tmpCfg('creator') | |
| 88 | const r = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile }) | |
| 89 | const e = JSON.parse(fs.readFileSync(cfgFile, 'utf8')).dids[0] | |
| 90 | ||
| 91 | // The first device holds the genesis. Getting this wrong is the loser-club fork: a node that does not | |
| 92 | // claim the deterministic genesis writer authors a rival one (see coowner.js's `joiner` note). | |
| 93 | t.ok(e.forceCreator, 'forceCreator — this node holds the genesis') | |
| 94 | t.absent(e.joiner, 'and is NOT a joiner; exactly one node is the creator') | |
| 95 | ||
| 96 | const kp = await deviceKeetKeyPair(e.deviceMnemonic) | |
| 97 | const devHex = Buffer.from(kp.publicKey).toString('hex') | |
| 98 | t.is(r.devicePublicKey, devHex, 'the reported device key derives from the written mnemonic') | |
| 99 | t.ok((e.members || []).includes(devHex), 'and this device is in the roster it will be admitted against') | |
| 100 | }) | |
| 101 | ||
| 102 | test('minting twice creates two DIFFERENT identities', async (t) => { | |
| 103 | // No rootMnemonic passed — each mint generates its own. If it did not, every phone would mint the | |
| 104 | // same DID and the second would silently collide with the first. | |
| 105 | const a = await mintIdentity({ configFile: tmpCfg('rand-a') }) | |
| 106 | const b = await mintIdentity({ configFile: tmpCfg('rand-b') }) | |
| 107 | t.not(a.did, b.did, 'different DIDs') | |
| 108 | t.not(a.rootMnemonic, b.rootMnemonic, 'different roots') | |
| 109 | t.ok(a.rootMnemonic.split(' ').length >= 12, 'a real BIP39 mnemonic, not a placeholder') | |
| 110 | }) | |
| 111 | ||
| 112 | test('the DID is reproducible from the root alone', async (t) => { | |
| 113 | // This is what makes "submit the genesis later" possible: the op can be re-derived from the mnemonic | |
| 114 | // the user wrote down, with no surviving local state. hpp-onboard's --submit mode relies on it. | |
| 115 | const a = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('det-a') }) | |
| 116 | const b = await mintIdentity({ rootMnemonic: ROOT, configFile: tmpCfg('det-b') }) | |
| 117 | t.is(a.did, b.did, 'same root => same DID') | |
| 118 | }) | |
| 119 | ||
| 120 | test('refuses to clobber an identity this device already has', async (t) => { | |
| 121 | const cfgFile = tmpCfg('clobber') | |
| 122 | const first = await mintIdentity({ rootMnemonic: ROOT, configFile: cfgFile }) | |
| 123 | await t.exception(mintIdentity({ configFile: cfgFile }), /already/i, 'a second mint is refused') | |
| 124 | const cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8')) | |
| 125 | t.is(cfg.dids.length, 1, 'and the existing entry is untouched') | |
| 126 | t.is(cfg.dids[0].did, first.did, 'still the first identity') | |
| 127 | }) |