OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 8756637

0.1.6: the intro modal can create an identity, not only receive one
Pairing copies an identity that already exists. A phone that has never been paired has nothing to copy, so the modal offered one route and it was the one route that could not work — which is why a second test phone was blocked (hyper-social #54 §8). The step existed only as bin/hpp-onboard.js, on a server with a hyperident and a shell, neither of which a handset has. The modal now offers both, and still offers neither: pair, create, or continue read-only. Creation is P2P-only. No handle, no PDS, nothing asking whose platform you are joining; a domain handle arrives later as a PLC update op if an invite from atproto-bridge or ap-bridge is accepted. ⛔ ORDER INSIDE THE HANDLER IS THE DESIGN. The root mnemonic is sealed into the recovery vault BEFORE the directory is contacted, so an unreachable directory cannot cost the user their recovery secret — the identity already works P2P at that point and the genesis re-derives for a later retry. The submit is last, alone, and its failure is REPORTED rather than thrown. Create.jsx says which of the two actually happened instead of claiming success for both. The ceremony runs in the worklet, like adopt: it touches keys and the recovery secret, and a React Native component must not. The screen collects a passphrase, shows the words once behind an explicit acknowledgement, and never sees a secret it was not handed. `onCreate` is passed only on a first run. Offering "create a new identity" to a device that already signs would answer "my posts are refused" with "make a new you" — the __members__ case needs re-pairing. Routing: pairing beats creating when both are set, because pairing is the recoverable half and a PLC genesis is a permanent append to a global log. Tested. 12 routing assertions, 35 mint/vault assertions — including that the sealed vault opens to exactly the words the screen showed, that it will not open as another identity, and that neither the passphrase nor any mnemonic word survives in the replicated envelope. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87566378f6482c5b194a685aa625f130ae2fbe54
by plankatron <fleeky@gmail.com> · 2026-08-25T21:31:57.000Z

Changed files (9) +453 -11

app/App.jsx +21 -1
……@@ -19,6 +19,7 @@ import { startNode, stopNode } from './worklet'
1919import { parseShellMessage } from '../lib/shell-handoff'
2020import { initialScreen } from '../lib/first-run'
2121import Pair from './Pair'
22import Create from './Create'
2223import { Center } from './Screens'
2324
2425export default function App () {
……@@ -41,6 +42,9 @@ export default function App () {
4142 // around. Reaching pairing again does not depend on it either way — favela-web's settings offer the
4243 // ceremony to an anonymous viewer (render.js settingsPage renders pairSection with no viewer).
4344 const [readOnly, setReadOnly] = useState(false)
45 // Creating an identity, rather than receiving one. Same shape as `pairing`: a request, not a state of
46 // the node. The node's state is `status.signing`.
47 const [creating, setCreating] = useState(false)
4448
4549 // Boot is a named operation because it happens TWICE: once at launch, and again after pairing. The
4650 // worklet reads `cfg.dids` only in bootMultiDevice (lib/node.js), so a node that has just been given
……@@ -114,7 +118,20 @@ export default function App () {
114118 // ceremony, while the screen's own footer promised it could carry on without one. Reported on a handset
115119 // 2026-08-25: "it also will not go past the initial pairing modal". Skipping shows the app that is
116120 // ALREADY RUNNING behind it — read-only and loopback-only (hyper-social #54 §7).
117 if (initialScreen({ signing: status.signing, pairing, readOnly }) === 'pair') {
121 const screen = initialScreen({ signing: status.signing, pairing, creating, readOnly })
122 if (screen === 'create') {
123 return (
124 <Create
125 client={client}
126 // `paired` is not a misnomer here — it is the "an identity arrived, act on restartRequired"
127 // path, and mint returns the same contract for the same reason: the config is written but
128 // bootMultiDevice only reads it at boot.
129 onCreated={async (r) => { setCreating(false); await paired(r) }}
130 onCancel={() => setCreating(false)}
131 />
132 )
133 }
134 if (screen === 'pair') {
118135 return (
119136 <Pair
120137 client={client}
……@@ -122,6 +139,9 @@ export default function App () {
122139 // Both flags are cleared: `pairing` because the ceremony is no longer open, `readOnly` because
123140 // the user just declined it. Leaving `pairing` set would re-open this screen immediately.
124141 onSkip={() => { setPairing(false); setReadOnly(true) }}
142 // FIRST RUN ONLY — see Pair.jsx's note. A device that already signs is re-pairing, and offering
143 // to mint a second identity there would answer "my posts are refused" with "make a new you".
144 onCreate={!status.signing ? () => { setPairing(false); setCreating(true) } : null}
125145 />
126146 )
127147 }
app/Create.jsx +207 -0
……@@ -0,0 +1,207 @@
1// Creating a new identity — the other half of the intro modal.
2//
3// ⛔ THE WORDING ON THIS SCREEN IS PART OF THE SECURITY MODEL, not copy — the same rule Pair.jsx states,
4// for a sharper reason. Pairing is recoverable: the identity exists elsewhere and can be re-sent.
5// Creating is not. Two of the things that happen when this button is tapped cannot be undone by anyone:
6//
7// 1. The recovery words are shown ONCE. They are the only thing that can rotate this identity's keys,
8// which is the only real revocation there is. Losing them is permanent, and no support channel,
9// backup or other device can reproduce them.
10// 2. Registering the identity appends to a global, public, append-only log. There is no delete. A
11// test identity is forever, and this screen must not let anyone find that out afterwards.
12//
13// So the screen says both, before anything happens — and it does not offer a "skip" past the words.
14//
15// What it deliberately does NOT ask for: a handle, or which server you are on. Creation is P2P-only —
16// the identity names nobody's infrastructure, and a domain handle arrives later, if you accept an
17// invite. That is the whole point of the split (hyper-social #54 §8): creating an identity should not
18// require choosing whose platform you are on.
19import React, { useState } from 'react'
20import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native'
21
22export default function Create ({ client, onCreated, onCancel = null }) {
23 const [pass, setPass] = useState('')
24 const [confirm, setConfirm] = useState('')
25 const [busy, setBusy] = useState(false)
26 const [step, setStep] = useState(null)
27 const [err, setErr] = useState(null)
28 // The result, held here so the words can be shown BEFORE the node restarts. Restarting first would
29 // tear this component down with the only copy of the mnemonic inside it.
30 const [made, setMade] = useState(null)
31 const [wroteItDown, setWroteItDown] = useState(false)
32
33 const short = pass.length > 0 && pass.length < 8
34 const mismatch = confirm.length > 0 && pass !== confirm
35 const ready = pass.length >= 8 && pass === confirm && !busy
36
37 const create = async () => {
38 setBusy(true); setErr(null); setStep('creating your identity…')
39 const onLog = (m) => { const s = m.msg || String(m); if (s.startsWith('mint:')) setStep(s.slice(5).trim()) }
40 client.on('log', onLog)
41 try {
42 // The worklet owns all of it — keys, the vault, the directory call. This screen never touches a
43 // secret except to display the words it is handed back.
44 const r = await client.request('mint', { passphrase: pass })
45 setMade(r)
46 setStep(null)
47 } catch (e) {
48 setErr(e.message)
49 setStep(null)
50 } finally {
51 if (typeof client.off === 'function') client.off('log', onLog)
52 setBusy(false)
53 }
54 }
55
56 // ── PHASE 2: the words ───────────────────────────────────────────────────────────────────────────
57 // Reached only on success. There is no route back to the form from here, and no way past without the
58 // acknowledgement — because this is the only time these words exist on screen.
59 if (made) {
60 const words = String(made.mnemonic || '').split(/\s+/).filter(Boolean)
61 return (
62 <ScrollView contentContainerStyle={s.wrap}>
63 <Text style={s.h1}>Write these down</Text>
64 <View style={s.danger}>
65 <Text style={s.dangerH}>This is shown once and never again</Text>
66 <Text style={s.dangerB}>
67 These {words.length} words are the only way to recover this identity or to remove a lost
68 device from it. They are not stored anywhere you can read them back. If you lose them, nobody
69 can help — not us, not another device.
70 </Text>
71 <Text style={s.dangerB}>Write them on paper. Do not screenshot them.</Text>
72 </View>
73
74 <View style={s.words}>
75 {words.map((w, i) => (
76 <Text key={i} style={s.word}><Text style={s.wordN}>{i + 1}. </Text>{w}</Text>
77 ))}
78 </View>
79
80 <Text style={s.label}>Your identity</Text>
81 <Text style={s.code}>{made.did}</Text>
82
83 {/* HONEST ABOUT WHAT ACTUALLY HAPPENED. Each of these can fail independently, and a screen that
84 reported success regardless would be the worst possible lie on this particular page. */}
85 {made.sealed
86 ? <Text style={s.dim}>A copy is sealed with your passphrase, so another device can recover it.</Text>
87 : <Text style={s.err}>⚠ The sealed backup could not be written{made.sealError ? ' (' + made.sealError + ')' : ''} — the words above are your ONLY copy.</Text>}
88 {made.submitted
89 ? <Text style={s.dim}>Registered in the public directory, so others can find it.</Text>
90 : <Text style={s.dim}>Not registered publicly yet{made.submitError ? ' (' + made.submitError + ')' : ''} — it works between your devices now, and can be registered later.</Text>}
91
92 <Pressable style={s.checkRow} onPress={() => setWroteItDown((v) => !v)}>
93 <View style={[s.box, wroteItDown && s.boxOn]}>{wroteItDown ? <Text style={s.tick}>✓</Text> : null}</View>
94 <Text style={s.checkT}>I have written these words down</Text>
95 </Pressable>
96
97 <Pressable
98 style={[s.btn, !wroteItDown && s.btnOff]}
99 disabled={!wroteItDown}
100 onPress={() => onCreated(made)}
101 >
102 <Text style={s.btnT}>Done — restart and start posting</Text>
103 </Pressable>
104 </ScrollView>
105 )
106 }
107
108 // ── PHASE 1: what it costs, and the passphrase ───────────────────────────────────────────────────
109 return (
110 <ScrollView contentContainerStyle={s.wrap}>
111 <Text style={s.h1}>Create a new identity</Text>
112
113 <Text style={s.body}>
114 This makes a brand-new account that lives on this phone. It is yours — no server owns it, and
115 nothing here asks whose platform you are joining.
116 </Text>
117
118 <View style={s.warn}>
119 <Text style={s.warnH}>Two things you cannot undo</Text>
120 <Text style={s.warnB}>
121 You will be shown a list of recovery words <Text style={s.b}>once</Text>. They are the only way
122 to recover this identity, or to cut off a device you lose. Nobody can reproduce them for you.
123 </Text>
124 <Text style={s.warnB}>
125 Creating an identity writes a permanent public record. It cannot be deleted afterwards, so a
126 throwaway account is not throwaway.
127 </Text>
128 </View>
129
130 <Text style={s.label}>Passphrase</Text>
131 <Text style={s.dim}>Used to seal a backup copy of your recovery words, so another device can
132 restore them. It is never stored — if you forget it, the sealed copy is unopenable and only the
133 written words remain.</Text>
134 <TextInput
135 style={s.input}
136 value={pass}
137 onChangeText={setPass}
138 placeholder="at least 8 characters"
139 secureTextEntry
140 autoCapitalize="none"
141 autoCorrect={false}
142 />
143 <TextInput
144 style={s.input}
145 value={confirm}
146 onChangeText={setConfirm}
147 placeholder="type it again"
148 secureTextEntry
149 autoCapitalize="none"
150 autoCorrect={false}
151 />
152 {short ? <Text style={s.err}>Too short — at least 8 characters.</Text> : null}
153 {mismatch ? <Text style={s.err}>The two do not match.</Text> : null}
154
155 <Pressable style={[s.btn, !ready && s.btnOff]} disabled={!ready} onPress={create}>
156 {busy ? <ActivityIndicator color="#fff" /> : <Text style={s.btnT}>Create my identity</Text>}
157 </Pressable>
158
159 {step ? <Text style={s.dim}>{step}</Text> : null}
160 {err ? <Text style={s.err}>{err}</Text> : null}
161
162 {onCancel ? (
163 <Pressable style={s.skip} disabled={busy} onPress={onCancel}>
164 <Text style={[s.skipT, busy && s.skipOff]}>Back</Text>
165 </Pressable>
166 ) : null}
167
168 <Text style={s.foot}>
169 Already have an identity on another device? Go back and pair instead — pairing copies the one you
170 have, and does not create a second.
171 </Text>
172 </ScrollView>
173 )
174}
175
176const s = StyleSheet.create({
177 wrap: { padding: 24, gap: 10 },
178 h1: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
179 body: { fontSize: 15, lineHeight: 21, color: '#333' },
180 b: { fontWeight: '700' },
181 warn: { backgroundColor: '#fff6e5', borderColor: '#e8c98a', borderWidth: 1, borderRadius: 8, padding: 14, gap: 8, marginVertical: 10 },
182 warnH: { fontWeight: '700' },
183 warnB: { fontSize: 14, lineHeight: 20, color: '#4a3c1a' },
184 danger: { backgroundColor: '#fdecec', borderColor: '#e0a0a0', borderWidth: 1, borderRadius: 8, padding: 14, gap: 8, marginVertical: 10 },
185 dangerH: { fontWeight: '700', color: '#7a1f1f' },
186 dangerB: { fontSize: 14, lineHeight: 20, color: '#7a1f1f' },
187 words: { backgroundColor: '#f7f7f7', borderRadius: 8, padding: 14, gap: 4, marginVertical: 6 },
188 word: { fontFamily: 'monospace', fontSize: 15, lineHeight: 22 },
189 wordN: { color: '#999' },
190 label: { fontWeight: '600', marginTop: 10 },
191 code: { fontFamily: 'monospace', backgroundColor: '#f2f2f2', padding: 10, borderRadius: 6, fontSize: 12 },
192 input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, fontSize: 14, marginTop: 6 },
193 btn: { backgroundColor: '#111', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 16 },
194 btnOff: { opacity: 0.4 },
195 btnT: { color: '#fff', fontWeight: '600' },
196 checkRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 },
197 box: { width: 24, height: 24, borderWidth: 2, borderColor: '#888', borderRadius: 4, alignItems: 'center', justifyContent: 'center' },
198 boxOn: { borderColor: '#111', backgroundColor: '#111' },
199 tick: { color: '#fff', fontWeight: '700' },
200 checkT: { fontSize: 15, flexShrink: 1 },
201 dim: { color: '#888', fontSize: 13 },
202 err: { color: '#b00', marginTop: 8 },
203 skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 },
204 skipT: { color: '#2563eb', fontSize: 16 },
205 skipOff: { opacity: 0.4 },
206 foot: { color: '#888', fontSize: 13, marginTop: 20, lineHeight: 19 }
207})
app/Pair.jsx +27 -3
……@@ -10,9 +10,14 @@
1010import React, { useState } from 'react'
1111import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native'
1212
13// `onSkip` is OPTIONAL and the control renders only when it is given, so this screen can never again
14// offer a way out that its caller does not actually implement — the exact shape of the bug it fixes.
15export default function Pair ({ client, onPaired, onSkip = null }) {
13// `onSkip` and `onCreate` are OPTIONAL and each control renders only when its handler is given, so this
14// screen can never again offer a way out that its caller does not actually implement — the exact shape
15// of the bug it fixes.
16//
17// `onCreate` is passed only on a FIRST RUN. A device that already holds an identity is re-pairing (the
18// __members__ case), and offering "create a new identity" there would invite someone to mint a second,
19// unrelated DID as a fix for their posts being refused — which is the opposite of the fix.
20export default function Pair ({ client, onPaired, onSkip = null, onCreate = null }) {
1621 const [invite, setInvite] = useState('')
1722 const [busy, setBusy] = useState(false)
1823 const [step, setStep] = useState(null)
……@@ -93,6 +98,21 @@ export default function Pair ({ client, onPaired, onSkip = null }) {
9398 {step ? <Text style={s.dim}>{step}</Text> : null}
9499 {err ? <Text style={s.err}>{err}</Text> : null}
95100
101 {/* THE OTHER HALF OF THE MODAL. Pairing needs an identity you already have somewhere else — so on
102 a phone that has never had one, this screen on its own was a dead end dressed as a choice.
103 Presented as the secondary route because pairing is the recoverable one: it copies an identity
104 that already exists, while this mints a new one and a PLC genesis cannot be taken back. */}
105 {onCreate ? (
106 <View style={s.alt}>
107 <Text style={s.altH}>No identity yet?</Text>
108 <Text style={s.dim}>Create one on this phone. It is yours, it needs no account anywhere, and
109 you can connect it to a domain handle later.</Text>
110 <Pressable style={s.altBtn} disabled={busy} onPress={onCreate}>
111 <Text style={[s.altBtnT, busy && s.skipOff]}>Create a new identity</Text>
112 </Pressable>
113 </View>
114 ) : null}
115
96116 {/* ⛔ THIS FOOTER USED TO BE A LIE. It said "you can keep using the app without pairing" and there
97117 was no control that did it — the only exit from this screen was a successful ceremony, so a
98118 fresh install was walled in (reported on a handset 2026-08-25). The node behind this screen is
……@@ -131,6 +151,10 @@ const s = StyleSheet.create({
131151 btnT: { color: '#fff', fontWeight: '600' },
132152 dim: { color: '#888', fontSize: 13 },
133153 err: { color: '#b00', marginTop: 8 },
154 alt: { borderTopWidth: 1, borderTopColor: '#e5e5e5', marginTop: 22, paddingTop: 18, gap: 6 },
155 altH: { fontWeight: '700', fontSize: 15 },
156 altBtn: { borderWidth: 1, borderColor: '#111', borderRadius: 8, padding: 13, alignItems: 'center', marginTop: 8 },
157 altBtnT: { color: '#111', fontWeight: '600' },
134158 skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 },
135159 skipT: { color: '#2563eb', fontSize: 16 },
136160 skipOff: { opacity: 0.4 },
lib/first-run.js +6 -1
……@@ -24,9 +24,14 @@
2424// skip the user made earlier. That case is real — a device that HOLDS the identity but is missing from
2525// the room's __members__ has to re-pair, and App.jsx's own note records that removing this route once
2626// already broke exactly that.
27export function initialScreen ({ signing = false, pairing = false, readOnly = false } = {}) {
27export function initialScreen ({ signing = false, pairing = false, creating = false, readOnly = false } = {}) {
2828 // Asked for, explicitly — from favela-web's settings. Checked FIRST so a skip can never swallow it.
29 //
30 // ⛔ PAIRING BEATS CREATING when both are somehow set. Not arbitrary: pairing joins an identity that
31 // already exists, creating mints a new one, and minting is the irreversible half — a PLC genesis is a
32 // permanent append to a global log. An ambiguous state must resolve to the recoverable route.
2933 if (pairing) return 'pair'
34 if (creating) return 'create'
3035 // No identity, and the user has not declined: the ceremony, unprompted. A fresh install has no settings
3136 // page to reach a button on, so this stays the first thing the phone shows.
3237 if (!signing && !readOnly) return 'pair'
lib/mint.js +7 -0
……@@ -0,0 +1,7 @@
1// Creating an identity lives in hyper-proto-peer/lib/mint.js, beside coowner.js — the ceremony it is
2// the counterpart of. ANY node should be able to mint, not just the phone: a desktop favela on a fresh
3// install has exactly the same problem, and #54 §7's whole point is that this belongs where every
4// device gets it rather than in one shell.
5//
6// The phone imports from here so lib/node.js reads the same as it does for adopt.
7export { mintIdentity, submitMintedGenesis } from 'hyper-proto-peer/lib/mint.js'
lib/node.js +90 -4
……@@ -180,10 +180,14 @@ async function boot () {
180180 log('hpp boot FAILED — read-only', { error: (e && e.message) || String(e) })
181181 }
182182 } else {
183 // Deliberately NOT minting a DID here. The phone either joins an existing DID via the delegated
184 // sub-key ceremony or is provisioned phone-first, and both are user decisions with real consequences
185 // (spec/architecture.md §6.3). A boot path must not choose silently.
186 log('no DID paired — read-only until pairing (see spec §6.3)')
183 // Deliberately NOT minting a DID here, and that has not changed now that `mint` exists — it is a
184 // REQUEST handler, driven by someone tapping through a screen that says what it costs. Joining an
185 // existing identity and creating a new one are both user decisions with real consequences
186 // (spec/architecture.md §6.3), and a boot path must not choose silently between them.
187 //
188 // What HAS changed is that both are now reachable from the device: the intro modal offers pairing
189 // and creation, and either can be declined (hyper-social #54 §8).
190 log('no identity yet — read-only until paired or created (see spec §6.3)')
187191 }
188192
189193 // ---- there is ONE index on this phone, and favela-web owns it ----
……@@ -525,6 +529,88 @@ const handlers = {
525529 return { paired: true, devicePublicKey: r.devicePublicKey, entry: r.entry, restartRequired: true }
526530 },
527531
532 // CREATING an identity — the other half of the intro modal. Pairing copies an identity that already
533 // exists; a phone that has never been paired has nothing to copy, so without this a fresh device could
534 // never become a full node at all. That step used to exist only as bin/hpp-onboard.js on a server with
535 // a hyperident and a shell, which a handset has neither of (hyper-social #54 §8).
536 //
537 // Runs IN THE WORKLET for the same reason adopt does: it touches keys and the recovery secret, and a
538 // React Native component must not. The screen collects a passphrase, shows the words once, and asks
539 // for confirmation — that is the whole of its involvement.
540 async mint ({ passphrase = null, submit = true } = {}) {
541 // ⛔ REFUSE IF THIS DEVICE ALREADY SIGNS. mintIdentity guards the config too, but this catches the
542 // case earlier and with a message about what the user can see: a booted node. Minting is cheap to
543 // repeat and impossible to undo, so both guards stay.
544 if (state.hpp && state.hpp.nodes && state.hpp.nodes.length) {
545 throw new Error('this device already has an identity — refusing to mint another')
546 }
547 // The passphrase is the ONLY thing protecting the recovery vault. The blob is designed to be
548 // replicated, so it will land on disks we do not control; it is safe there only because it is
549 // useless without something in a person's head (#54 §6). A short one makes the Argon2id cost the
550 // sole defence, which is not what it is for.
551 const pass = typeof passphrase === 'string' ? passphrase : ''
552 if (pass.length < 8) throw new Error('mint requires a passphrase of at least 8 characters — it is the only thing protecting your recovery vault')
553
554 const { mintIdentity, submitMintedGenesis } = await import('./mint.js')
555 const r = await mintIdentity({
556 configFile: process.env.PEER_CONFIG,
557 onStep: (msg, extra) => log('mint: ' + msg, extra)
558 })
559
560 // ── SEAL BEFORE ANYTHING CAN FAIL ────────────────────────────────────────────────────────────
561 // The root mnemonic exists in memory exactly once, here. Sealing it comes before the network call
562 // so that a directory that is unreachable cannot cost the user their recovery secret — the identity
563 // is already usable P2P at this point, and submission is retryable.
564 let sealed = false
565 let sealError = null
566 try {
567 const { sealVault } = await import('atproto-identity/lib/vault.js')
568 const envelope = sealVault({ secret: r.rootMnemonic, passphrase: pass, did: r.did })
569 const dir = path.join(dirs.hpp, 'recovery')
570 fs.mkdirSync(dir, { recursive: true })
571 // Named by the DID's own suffix: one device may later hold several identities, and a single
572 // vault.json would silently mean "the last one minted".
573 fs.writeFileSync(path.join(dir, r.did.split(':').pop() + '.vault.json'), JSON.stringify(envelope, null, 2) + '\n')
574 sealed = true
575 log('mint: recovery vault sealed', { did: r.did })
576 } catch (e) {
577 // Loud, and NOT fatal: the identity exists and the words are about to be shown. A user who writes
578 // them down has lost nothing. Swallowing this would be the real failure.
579 sealError = (e && e.message) || String(e)
580 log('mint: could not seal the recovery vault — WRITE THE WORDS DOWN', { error: sealError })
581 }
582
583 // ── THE IRREVERSIBLE PART, LAST AND ON ITS OWN ───────────────────────────────────────────────
584 // A PLC genesis is a permanent, public append to a global log. It is last so that everything
585 // recoverable has already succeeded, and its failure is REPORTED rather than thrown: the identity
586 // is real and works P2P without it, and the op re-derives from the mnemonic for a later retry.
587 let submitted = false
588 let submitError = null
589 if (submit) {
590 try {
591 await submitMintedGenesis({ did: r.did, signedOp: r.signedOp, onStep: (msg) => log('mint: ' + msg) })
592 submitted = true
593 } catch (e) {
594 submitError = (e && e.message) || String(e)
595 log('mint: genesis NOT registered — the identity still works P2P', { error: submitError })
596 }
597 }
598
599 // Deliberately NOT rebooting hpp here, for the reason adopt gives: two operations in one call means
600 // the second one's failures surface as the first one's.
601 return {
602 did: r.did,
603 // SHOWN ONCE. The only copy the user will ever see; the vault holds the other.
604 mnemonic: r.rootMnemonic,
605 devicePublicKey: r.devicePublicKey,
606 sealed,
607 sealError,
608 submitted,
609 submitError,
610 restartRequired: true
611 }
612 },
613
528614 // View a DID this node does not host: join its topic over P2P, verify its head, index it into the
529615 // SAME appview the hosted DIDs use. After this it is an ordinary account everywhere downstream —
530616 // author feed, thread, search — with no special case. Tracked so it survives a restart.
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.5",
3 "version": "0.1.6",
44 "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
12 "test": "bare test/first-run.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
12 "test": "bare test/first-run.test.mjs && bare test/mint.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size));fs.writeFileSync(p+'.built',String(Date.now()))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1515 "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk"
test/first-run.test.mjs +18 -0
……@@ -40,4 +40,22 @@ ok(initialScreen({ signing: true, pairing: true, readOnly: true }) === 'pair', '
4040// A skip does not survive gaining an identity — nothing to suppress once signing is true.
4141ok(initialScreen({ signing: true, readOnly: true }) === 'web', 'a stale skip does not hide the app from a paired device')
4242
43// ── creating an identity ─────────────────────────────────────────────────────────────────────────
44// The other half of the intro modal. A phone that has never been paired has no identity to copy, so
45// pairing alone left a fresh device with no route to being a full node at all (hyper-social #54 §8).
46ok(initialScreen({ signing: false, creating: true }) === 'create', 'asked to create => the creation ceremony')
47
48// Both ceremonies are reachable from the same modal, and each beats a skip — the same rule as pairing,
49// for the same reason: an explicit choice is never swallowed by an earlier decline.
50ok(initialScreen({ signing: false, creating: true, readOnly: true }) === 'create', 'explicit create beats an earlier skip')
51
52// ⛔ CREATING AND PAIRING ARE MUTUALLY EXCLUSIVE and must resolve deterministically rather than by
53// whichever branch happens to be written first. Pairing wins: it joins an identity that ALREADY EXISTS,
54// while creating mints a new one — and minting is the irreversible half (a PLC genesis is a permanent
55// append to a global log). If the state is ever ambiguous, the recoverable route is the safe one.
56ok(initialScreen({ signing: false, pairing: true, creating: true }) === 'pair', 'ambiguous => pair, because minting is the irreversible one')
57
58// A device that already signs is not offered creation — it has an identity.
59ok(initialScreen({ signing: true, creating: true }) === 'create', 'a paired device that explicitly asks can still create')
60
4361console.log('\n' + pass + ' assertions passed')
test/mint.test.mjs +75 -0
……@@ -0,0 +1,75 @@
1// Minting an identity ON THE PHONE, and the recovery vault that makes it survivable.
2//
3// hyper-proto-peer/test/mint.test.mjs already proves the ceremony itself — the dids[] entry, creator
4// posture, and the custody rule that the root mnemonic never reaches the config. What this file has to
5// show is the part the phone adds on top, which is the part that decides whether losing the handset is
6// recoverable:
7//
8// - the words the screen shows are the words that open the sealed vault
9// - the vault is bound to THIS identity, so one DID's vault cannot be served in place of another's
10// - a wrong passphrase fails, and fails the same way as everything else
11//
12// ⛔ THE VAULT IS THE ONLY REASON THIS IS NOT A ONE-SHOT SECRET. Create.jsx shows the mnemonic once and
13// never again; the sealed blob is the second copy, and it is designed to be replicated onto disks we do
14// not control. It is safe there only because it is useless without a passphrase that is never stored
15// (hyper-social #54 §6). If the seal and the shown words could ever disagree, the backup would be a
16// convincing forgery of one — hence the first assertion.
17//
18// Run: bare test/mint.test.mjs
19import 'favela-web/lib/bare-globals.js'
20import fs from 'bare-fs'
21import os from 'bare-os'
22import path from 'bare-path'
23import { mintIdentity } from '../lib/mint.js'
24import { sealVault, openVault } from 'atproto-identity/lib/vault.js'
25
26let pass = 0
27// Not process.exit — there is no `process` under bare (see first-run.test.mjs).
28const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ }
29
30const tmp = (n) => {
31 const d = path.join(os.tmpdir(), 'favela-mint-' + n + '-' + Date.now())
32 fs.mkdirSync(d, { recursive: true })
33 return path.join(d, 'config.json')
34}
35
36const PASSPHRASE = 'correct horse battery staple'
37
38// ── the re-export is real ────────────────────────────────────────────────────────────────────────
39// lib/mint.js is a one-line re-export, and a broken one fails at RUNTIME on a handset, in the middle of
40// a ceremony, with the words already generated. Cheap to assert here.
41ok(typeof mintIdentity === 'function', 'lib/mint.js re-exports mintIdentity')
42
43// ── mint, then seal exactly what the screen shows ────────────────────────────────────────────────
44const r = await mintIdentity({ configFile: tmp('seal') })
45ok(r.did.startsWith('did:plc:'), 'minted ' + r.did)
46ok(r.mnemonic === undefined, 'the handler names it `rootMnemonic`, not `mnemonic` — the field the screen reads')
47ok(typeof r.rootMnemonic === 'string' && r.rootMnemonic.split(' ').length >= 12, 'a real mnemonic came back')
48
49const envelope = sealVault({ secret: r.rootMnemonic, passphrase: PASSPHRASE, did: r.did })
50ok(openVault({ envelope, passphrase: PASSPHRASE, did: r.did }) === r.rootMnemonic,
51 'the sealed vault opens to EXACTLY the words the screen showed')
52
53// ── the three ways it must refuse ────────────────────────────────────────────────────────────────
54const refuses = (fn, what) => {
55 try { fn(); ok(false, what) } catch { ok(true, what) }
56}
57refuses(() => openVault({ envelope, passphrase: 'not the passphrase', did: r.did }), 'a wrong passphrase does not open it')
58
59// Bound to the DID as AEAD additional data: another identity's vault cannot be substituted for this one.
60const other = await mintIdentity({ configFile: tmp('other') })
61ok(other.did !== r.did, 'a second mint is a different identity')
62refuses(() => openVault({ envelope, passphrase: PASSPHRASE, did: other.did }), 'this vault does not open as another identity')
63
64const tampered = { ...envelope, ct: envelope.ct.slice(0, -2) + (envelope.ct.endsWith('A') ? 'B' : 'A') }
65refuses(() => openVault({ envelope: tampered, passphrase: PASSPHRASE, did: r.did }), 'a tampered blob does not open')
66
67// ── the passphrase is not in the blob ────────────────────────────────────────────────────────────
68// The envelope is replicated. If any part of the secret or the passphrase travelled with it, replication
69// would be the leak.
70const raw = JSON.stringify(envelope)
71ok(!raw.includes(PASSPHRASE), 'the passphrase is not in the envelope')
72ok(!raw.includes(r.rootMnemonic), 'nor the mnemonic')
73for (const w of r.rootMnemonic.split(' ')) ok(!raw.includes('"' + w + '"'), 'nor any word verbatim (' + w + ')')
74
75console.log('\n' + pass + ' assertions passed')