| … | … | @@ -19,6 +19,7 @@ import { startNode, stopNode } from './worklet' |
| 19 | 19 | import { parseShellMessage } from '../lib/shell-handoff' |
| 20 | 20 | import { initialScreen } from '../lib/first-run' |
| 21 | 21 | import Pair from './Pair' |
| 22 | import Create from './Create' | |
| 22 | 23 | import { Center } from './Screens' |
| 23 | 24 | |
| 24 | 25 | export default function App () { |
| … | … | @@ -41,6 +42,9 @@ export default function App () { |
| 41 | 42 | // around. Reaching pairing again does not depend on it either way — favela-web's settings offer the |
| 42 | 43 | // ceremony to an anonymous viewer (render.js settingsPage renders pairSection with no viewer). |
| 43 | 44 | 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) | |
| 44 | 48 | |
| 45 | 49 | // Boot is a named operation because it happens TWICE: once at launch, and again after pairing. The |
| 46 | 50 | // 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 () { |
| 114 | 118 | // ceremony, while the screen's own footer promised it could carry on without one. Reported on a handset |
| 115 | 119 | // 2026-08-25: "it also will not go past the initial pairing modal". Skipping shows the app that is |
| 116 | 120 | // 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') { | |
| 118 | 135 | return ( |
| 119 | 136 | <Pair |
| 120 | 137 | client={client} |
| … | … | @@ -122,6 +139,9 @@ export default function App () { |
| 122 | 139 | // Both flags are cleared: `pairing` because the ceremony is no longer open, `readOnly` because |
| 123 | 140 | // the user just declined it. Leaving `pairing` set would re-open this screen immediately. |
| 124 | 141 | 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} | |
| 125 | 145 | /> |
| 126 | 146 | ) |
| 127 | 147 | } |
| … | … | @@ -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. | |
| 19 | import React, { useState } from 'react' | |
| 20 | import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native' | |
| 21 | ||
| 22 | export 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 | ||
| 176 | const 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 | }) |
| … | … | @@ -10,9 +10,14 @@ |
| 10 | 10 | import React, { useState } from 'react' |
| 11 | 11 | import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native' |
| 12 | 12 | |
| 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. | |
| 15 | export 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. | |
| 20 | export default function Pair ({ client, onPaired, onSkip = null, onCreate = null }) { | |
| 16 | 21 | const [invite, setInvite] = useState('') |
| 17 | 22 | const [busy, setBusy] = useState(false) |
| 18 | 23 | const [step, setStep] = useState(null) |
| … | … | @@ -93,6 +98,21 @@ export default function Pair ({ client, onPaired, onSkip = null }) { |
| 93 | 98 | {step ? <Text style={s.dim}>{step}</Text> : null} |
| 94 | 99 | {err ? <Text style={s.err}>{err}</Text> : null} |
| 95 | 100 | |
| 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 | ||
| 96 | 116 | {/* ⛔ THIS FOOTER USED TO BE A LIE. It said "you can keep using the app without pairing" and there |
| 97 | 117 | was no control that did it — the only exit from this screen was a successful ceremony, so a |
| 98 | 118 | 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({ |
| 131 | 151 | btnT: { color: '#fff', fontWeight: '600' }, |
| 132 | 152 | dim: { color: '#888', fontSize: 13 }, |
| 133 | 153 | 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' }, | |
| 134 | 158 | skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 }, |
| 135 | 159 | skipT: { color: '#2563eb', fontSize: 16 }, |
| 136 | 160 | skipOff: { opacity: 0.4 }, |
| … | … | @@ -24,9 +24,14 @@ |
| 24 | 24 | // skip the user made earlier. That case is real — a device that HOLDS the identity but is missing from |
| 25 | 25 | // the room's __members__ has to re-pair, and App.jsx's own note records that removing this route once |
| 26 | 26 | // already broke exactly that. |
| 27 | export function initialScreen ({ signing = false, pairing = false, readOnly = false } = {}) { | |
| 27 | export function initialScreen ({ signing = false, pairing = false, creating = false, readOnly = false } = {}) { | |
| 28 | 28 | // 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. | |
| 29 | 33 | if (pairing) return 'pair' |
| 34 | if (creating) return 'create' | |
| 30 | 35 | // No identity, and the user has not declined: the ceremony, unprompted. A fresh install has no settings |
| 31 | 36 | // page to reach a button on, so this stays the first thing the phone shows. |
| 32 | 37 | if (!signing && !readOnly) return 'pair' |
| … | … | @@ -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. | |
| 7 | export { mintIdentity, submitMintedGenesis } from 'hyper-proto-peer/lib/mint.js' |
| … | … | @@ -180,10 +180,14 @@ async function boot () { |
| 180 | 180 | log('hpp boot FAILED — read-only', { error: (e && e.message) || String(e) }) |
| 181 | 181 | } |
| 182 | 182 | } 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)') | |
| 187 | 191 | } |
| 188 | 192 | |
| 189 | 193 | // ---- there is ONE index on this phone, and favela-web owns it ---- |
| … | … | @@ -525,6 +529,88 @@ const handlers = { |
| 525 | 529 | return { paired: true, devicePublicKey: r.devicePublicKey, entry: r.entry, restartRequired: true } |
| 526 | 530 | }, |
| 527 | 531 | |
| 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 | ||
| 528 | 614 | // View a DID this node does not host: join its topic over P2P, verify its head, index it into the |
| 529 | 615 | // SAME appview the hosted DIDs use. After this it is an ordinary account everywhere downstream — |
| 530 | 616 | // author feed, thread, search — with no special case. Tracked so it survives a restart. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.5", | |
| 3 | "version": "0.1.6", | |
| 4 | 4 | "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.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "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", | |
| 13 | 13 | "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()))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs", |
| 15 | 15 | "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk" |
| … | … | @@ -40,4 +40,22 @@ ok(initialScreen({ signing: true, pairing: true, readOnly: true }) === 'pair', ' |
| 40 | 40 | // A skip does not survive gaining an identity — nothing to suppress once signing is true. |
| 41 | 41 | ok(initialScreen({ signing: true, readOnly: true }) === 'web', 'a stale skip does not hide the app from a paired device') |
| 42 | 42 | |
| 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). | |
| 46 | ok(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. | |
| 50 | ok(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. | |
| 56 | ok(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. | |
| 59 | ok(initialScreen({ signing: true, creating: true }) === 'create', 'a paired device that explicitly asks can still create') | |
| 60 | ||
| 43 | 61 | console.log('\n' + pass + ' assertions passed') |
| … | … | @@ -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 | |
| 19 | import 'favela-web/lib/bare-globals.js' | |
| 20 | import fs from 'bare-fs' | |
| 21 | import os from 'bare-os' | |
| 22 | import path from 'bare-path' | |
| 23 | import { mintIdentity } from '../lib/mint.js' | |
| 24 | import { sealVault, openVault } from 'atproto-identity/lib/vault.js' | |
| 25 | ||
| 26 | let pass = 0 | |
| 27 | // Not process.exit — there is no `process` under bare (see first-run.test.mjs). | |
| 28 | const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ } | |
| 29 | ||
| 30 | const 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 | ||
| 36 | const 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. | |
| 41 | ok(typeof mintIdentity === 'function', 'lib/mint.js re-exports mintIdentity') | |
| 42 | ||
| 43 | // ── mint, then seal exactly what the screen shows ──────────────────────────────────────────────── | |
| 44 | const r = await mintIdentity({ configFile: tmp('seal') }) | |
| 45 | ok(r.did.startsWith('did:plc:'), 'minted ' + r.did) | |
| 46 | ok(r.mnemonic === undefined, 'the handler names it `rootMnemonic`, not `mnemonic` — the field the screen reads') | |
| 47 | ok(typeof r.rootMnemonic === 'string' && r.rootMnemonic.split(' ').length >= 12, 'a real mnemonic came back') | |
| 48 | ||
| 49 | const envelope = sealVault({ secret: r.rootMnemonic, passphrase: PASSPHRASE, did: r.did }) | |
| 50 | ok(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 ──────────────────────────────────────────────────────────────── | |
| 54 | const refuses = (fn, what) => { | |
| 55 | try { fn(); ok(false, what) } catch { ok(true, what) } | |
| 56 | } | |
| 57 | refuses(() => 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. | |
| 60 | const other = await mintIdentity({ configFile: tmp('other') }) | |
| 61 | ok(other.did !== r.did, 'a second mint is a different identity') | |
| 62 | refuses(() => openVault({ envelope, passphrase: PASSPHRASE, did: other.did }), 'this vault does not open as another identity') | |
| 63 | ||
| 64 | const tampered = { ...envelope, ct: envelope.ct.slice(0, -2) + (envelope.ct.endsWith('A') ? 'B' : 'A') } | |
| 65 | refuses(() => 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. | |
| 70 | const raw = JSON.stringify(envelope) | |
| 71 | ok(!raw.includes(PASSPHRASE), 'the passphrase is not in the envelope') | |
| 72 | ok(!raw.includes(r.rootMnemonic), 'nor the mnemonic') | |
| 73 | for (const w of r.rootMnemonic.split(' ')) ok(!raw.includes('"' + w + '"'), 'nor any word verbatim (' + w + ')') | |
| 74 | ||
| 75 | console.log('\n' + pass + ' assertions passed') |