OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 72787f3

0.3.120: mint — create an identity on the device that will use it
The symmetric half of coowner.js. coownerJoin copies an EXISTING DID onto a second device; nothing created the first one from inside the product. That step lived only in bin/hpp-onboard.js — a server with a hyperident and a shell — so a handset could receive an identity but never have one, and a second test phone was blocked on a step that cannot be performed on a phone (hyper-social #54 §8). mintIdentity is hpp-onboard's fresh-mint path (createSwarmIdentity + provisionDevice) writing the multi-DID dids[] entry bootMultiDevice reads instead of the single-DID server config. P2P-only by default: no handle, no PDS. The domain handle arrives later as a PLC update op when an invite from atproto-bridge or ap-bridge is accepted. ⛔ CUSTODY. The root mnemonic is the recovery authority — it can rotate the signing key and revoke every device, and device-node.js is explicit that a device never receives it. Minting on the device is the one moment it exists there, so it is RETURNED to be shown once and sealed into the vault, and never written. The test asserts the config does not contain it, nor any two-word fragment of it. forceCreator is explicit, not emergent: the creator election is "lowest-keyed member == self", so leaving the role to key ordering silently flips it when a lower-sorting device joins — the loser-club fork coowner.js already documents. Submission is a SEPARATE call. Appending to the PLC directory is permanent, public and irreversible, so it cannot be a side effect of a local write, and a mint with no network still yields a working P2P identity — the op re-derives from the mnemonic and can be submitted later. readConfigFile/writeConfigFile are exported rather than copied: ENOENT-is-not-an-error and backup-before-write are both load-bearing and a second copy would drift. 8/8, 31/31. The four coowner suites still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72787f3f23413d9c6908e2a1e52e94220b5bebc8
by plankatron <fleeky@gmail.com> · 2026-08-25T21:00:08.000Z

Changed files (5) +267 -3

lib/coowner.js +5 -2
……@@ -44,7 +44,10 @@ const hex = (u8) => b4a.toString(b4a.from(u8), 'hex')
4444// would discard a device's mnemonic and roster — the one thing that cannot be regenerated (a fresh key
4545// orphans an attestation and leaves the machine permanently unadmitted). Missing means "new"; unreadable
4646// means "stop".
47function 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.
50export function readConfigFile (p) {
4851 let raw
4952 try { raw = fs.readFileSync(p, 'utf8') } catch (e) {
5053 if (e && (e.code === 'ENOENT' || /ENOENT/.test(e.message || ''))) return {}
……@@ -55,7 +58,7 @@ function readConfigFile (p) {
5558
5659// Never write a config without leaving the previous one on disk. A mistyped roster locks a machine out
5760// of a room, and the old file is the fastest way back.
58function writeConfigFile (p, cfg) {
61export function writeConfigFile (p, cfg) {
5962 const backup = p + '.bak.' + Math.floor(Date.now() / 1000)
6063 try { fs.copyFileSync(p, backup) } catch {}
6164 fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n')
lib/mint.js +133 -0
……@@ -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.
33import b4a from 'b4a'
34import IdentityKey from 'keet-identity-key'
35import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
36import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
37import { provisionDevice } from './device-node.js'
38import { readConfigFile, writeConfigFile } from './coowner.js'
39import { configPath } from './config.js'
40
41const 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 */
55export 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 */
123export 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}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.119",
3 "version": "0.3.120",
44 "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.",
55 "type": "module",
66 "main": "lib/peer.js",
test/all.js +1 -0
……@@ -9,6 +9,7 @@ import './conflict-watch.test.js' // fast + offline; drives a REAL conflict —
99import './side-store-channel-priority.test.js' // fast + offline
1010import './shared-store-autoopen.test.js' // fast + offline; corestore auto-opens on-disk cores on remote interest (#22)
1111import './inline-migration-boot.test.mjs' // fast + offline
12import './mint.test.mjs' // fast + offline; creating an identity on the device that will use it
1213import './swarm-device.test.js'
1314import './event-driven-sign.test.mjs'
1415import './failover-election.test.mjs'
test/mint.test.mjs +127 -0
……@@ -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
21import '../lib/bare-globals.js'
22import test from 'brittle'
23import fs from 'bare-fs'
24import os from 'bare-os'
25import path from 'bare-path'
26import { checkGenesisOp } from 'atproto-identity/lib/plc-directory.js'
27import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
28import { mintIdentity } from '../lib/mint.js'
29
30const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
31
32function 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
38test('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
53test('⛔ 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
69test('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
80test('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
86test('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
102test('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
112test('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
120test('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})