Skip to content

Commit 52903dd

Browse files
feat: add account node type, recovery root modal, and developer derivation tool
- Add IdentityNodeType union (persona | account) with helper functions - Add recovery root modal for creating/restoring mnemonic-backed roots - Add developer derivation tool in identities view - Improve export modal styling (CSS classes instead of inline styles) - Add persona test coverage for derivation, account type, and validation - Fix createPersona to correctly pass nodeType through toAppPersona - Validate inputs in recovery root modal before dispatching events - Zero private key bytes after hex conversion for security hygiene - Use bytesToHex helper instead of inline Array.from patterns - Remove broken README links to nonexistent nsec-tree docs - Remove unused escapeHtml import from recovery-root-modal
1 parent 5fcf683 commit 52903dd

10 files changed

Lines changed: 1003 additions & 95 deletions

File tree

app/components/export-modal.ts

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,135 @@
11
// app/components/export-modal.ts — Export modal: reveal nsec, copy, QR, relay info
2-
32
import { getPersona } from '../persona.js'
43
import { personaColour } from './persona-picker.js'
54
import { escapeHtml } from '../utils/escape.js'
65
import { getState } from '../state.js'
76
import { generateQR } from './qr.js'
7+
import { identityNodeLabel, identityNodeType } from '../types.js'
88
import type { AppPersona } from '../types.js'
9-
109
// ── Constants ──────────────────────────────────────────────────
11-
1210
const CLIPBOARD_WIPE_MS = 30_000
1311
const MODAL_ID = 'export-modal'
14-
12+
const STYLE_ID = 'export-modal-styles'
13+
const STYLES = `
14+
.export-modal::backdrop { background: rgba(0, 0, 0, 0.72); }
15+
.export-modal {
16+
width: min(42rem, calc(100vw - 2rem));
17+
max-width: 42rem;
18+
border: 1px solid var(--border);
19+
border-radius: 10px;
20+
background: var(--bg-surface);
21+
color: var(--text-primary);
22+
padding: 0;
23+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
24+
}
25+
.export-modal__content { padding: 1rem; display: grid; gap: 0.875rem; }
26+
.export-modal__close {
27+
justify-self: end; background: none; border: none; color: var(--text-muted);
28+
font-size: 1.5rem; cursor: pointer; line-height: 1; padding: 0;
29+
}
30+
.export-modal__title { margin: 0; font-size: 1.1rem; color: var(--text-bright); line-height: 1.4; }
31+
.export-modal__badge {
32+
display: inline-flex; width: 1.5rem; height: 1.5rem; border-radius: 999px;
33+
align-items: center; justify-content: center; color: #fff; font-size: 0.75rem; margin: 0 0.35rem;
34+
}
35+
.export-modal__context { display: grid; gap: 0.45rem; }
36+
.export-modal__context p, .export-modal__relays {
37+
margin: 0; font-size: 0.78rem; color: var(--text-secondary); line-height: 1.55;
38+
}
39+
.export-modal__nsec-wrap {
40+
position: relative; border: 1px solid var(--border); border-radius: 8px;
41+
background: var(--bg-deep); padding: 0.9rem; overflow: hidden;
42+
}
43+
.export-modal__label {
44+
display: block; font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase;
45+
color: var(--text-muted); margin-bottom: 0.5rem;
46+
}
47+
.export-modal__nsec {
48+
display: block; font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.55;
49+
word-break: break-all; white-space: pre-wrap; padding-right: 0.25rem;
50+
}
51+
.export-modal__reveal-overlay {
52+
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
53+
background: rgba(10, 13, 18, 0.72); color: var(--text-bright); font-size: 0.82rem; cursor: pointer;
54+
}
55+
.export-modal__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
56+
.export-modal__qr { border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; background: #fff; justify-self: center; }
57+
`
1558
// ── Public API ─────────────────────────────────────────────────
16-
1759
/**
1860
* Show an export modal for a persona's nsec, with context, reveal,
1961
* copy (nsec + npub), QR (npub only), and relay info.
2062
*/
2163
export function showExportModal(persona: AppPersona): void {
22-
// Remove any existing export modal
2364
document.getElementById(MODAL_ID)?.remove()
24-
65+
ensureStyles()
2566
const colour = personaColour(persona.name)
2667
const displayName = persona.displayName ?? persona.name
2768
const settings = getState().settings
28-
29-
// Derive the full persona (with private key material)
69+
const nodeLabel = identityNodeLabel(persona).toLowerCase()
70+
const isAccount = identityNodeType(persona) === 'account'
3071
const fullPersona = getPersona(persona.name, persona.index)
3172
const nsec = fullPersona.identity.nsec
3273
const npub = fullPersona.identity.npub
33-
34-
// Relay info
3574
const relays = persona.writeRelays?.length
3675
? persona.writeRelays
3776
: settings.defaultWriteRelays
38-
39-
// ── Build dialog ───────────────────────────────────────────
40-
4177
const dialog = document.createElement('dialog')
4278
dialog.id = MODAL_ID
4379
dialog.className = 'modal export-modal'
44-
45-
// All interpolated values are escaped via escapeHtml() — safe innerHTML usage
46-
// consistent with the rest of the codebase (header.ts, persona-picker.ts, modal.ts)
4780
dialog.innerHTML = `
4881
<div class="export-modal__content">
4982
<button class="export-modal__close" type="button" aria-label="Close">&times;</button>
50-
5183
<h2 class="export-modal__title">
5284
Export nsec for
5385
<span class="export-modal__badge" style="background-color:${colour}">${escapeHtml(displayName.slice(0, 1).toUpperCase())}</span>
5486
${escapeHtml(displayName)}
5587
</h2>
56-
5788
<div class="export-modal__context">
58-
<p>This key gives full control of this persona. Only paste it into Nostr clients you trust.</p>
59-
<p>If this key is compromised, you can rotate the persona from here &mdash; your other personas are unaffected.</p>
89+
<p>This key gives full control of this ${escapeHtml(nodeLabel)}. Only paste it into Nostr clients you trust.</p>
90+
<p>${isAccount ? 'Anonymous accounts are unlinkable by default unless you later choose to generate a proof.' : 'If this key is compromised, you can rotate this persona without affecting your other branches.'}</p>
6091
<p>Clipboard auto-clears after 30 seconds.</p>
6192
</div>
62-
6393
<div class="export-modal__nsec-wrap" id="export-nsec-wrap">
94+
<span class="export-modal__label">${escapeHtml(nodeLabel)} nsec</span>
6495
<code class="export-modal__nsec" id="export-nsec-code" style="filter:blur(5px);user-select:none;">${escapeHtml(nsec)}</code>
6596
<div class="export-modal__reveal-overlay" id="export-reveal-overlay">Click to reveal</div>
6697
</div>
67-
6898
<div class="export-modal__actions">
6999
<button class="btn btn--sm" id="export-copy-nsec" type="button">Copy nsec</button>
70100
<button class="btn btn--sm" id="export-copy-npub" type="button">Copy npub</button>
71101
<button class="btn btn--sm" id="export-toggle-qr" type="button">Show QR</button>
72102
</div>
73-
74103
<div class="export-modal__qr" id="export-qr-area" hidden></div>
75-
76104
<div class="export-modal__relays">
77-
This persona publishes to: ${relays.map((r) => `<code>${escapeHtml(r)}</code>`).join(', ') || '<em>default relays</em>'}
105+
This ${escapeHtml(nodeLabel)} publishes to: ${relays.map((r) => `<code>${escapeHtml(r)}</code>`).join(', ') || '<em>default relays</em>'}
78106
</div>
79107
</div>
80108
`
81-
82109
document.body.appendChild(dialog)
83-
84-
// ── Wire: close ────────────────────────────────────────────
85-
86-
const closeBtn = dialog.querySelector<HTMLButtonElement>('.export-modal__close')
87-
closeBtn?.addEventListener('click', () => closeExportModal(dialog))
88-
110+
dialog.querySelector<HTMLButtonElement>('.export-modal__close')?.addEventListener('click', () => closeExportModal(dialog))
89111
dialog.addEventListener('click', (e) => {
90112
if (e.target === dialog) closeExportModal(dialog)
91113
})
92-
93114
dialog.addEventListener('cancel', () => closeExportModal(dialog))
94-
95-
// ── Wire: reveal ───────────────────────────────────────────
96-
97115
const nsecWrap = dialog.querySelector<HTMLElement>('#export-nsec-wrap')
98116
const nsecCode = dialog.querySelector<HTMLElement>('#export-nsec-code')
99117
const overlay = dialog.querySelector<HTMLElement>('#export-reveal-overlay')
100-
101118
nsecWrap?.addEventListener('click', () => {
102119
if (!nsecCode || !overlay) return
103120
nsecCode.style.filter = 'none'
104121
nsecCode.style.userSelect = 'all'
105122
overlay.hidden = true
106123
})
107-
108-
// ── Wire: copy nsec ────────────────────────────────────────
109-
110124
wireClipboardButton(dialog, '#export-copy-nsec', nsec, 'Copy nsec')
111-
112-
// ── Wire: copy npub ────────────────────────────────────────
113-
114125
wireClipboardButton(dialog, '#export-copy-npub', npub, 'Copy npub')
115-
116-
// ── Wire: QR toggle ────────────────────────────────────────
117-
118126
const qrBtn = dialog.querySelector<HTMLButtonElement>('#export-toggle-qr')
119127
const qrArea = dialog.querySelector<HTMLElement>('#export-qr-area')
120128
let qrVisible = false
121-
122129
qrBtn?.addEventListener('click', () => {
123130
if (!qrArea || !qrBtn) return
124131
qrVisible = !qrVisible
125132
if (qrVisible) {
126-
// generateQR returns sanitised SVG from qrcode-generator — safe innerHTML
127133
qrArea.innerHTML = generateQR(npub)
128134
qrArea.hidden = false
129135
qrBtn.textContent = 'Hide QR'
@@ -133,15 +139,9 @@ export function showExportModal(persona: AppPersona): void {
133139
qrBtn.textContent = 'Show QR'
134140
}
135141
})
136-
137-
// ── Show ───────────────────────────────────────────────────
138-
139142
dialog.showModal()
140143
}
141-
142144
// ── Internal helpers ───────────────────────────────────────────
143-
144-
/** Wire a clipboard-copy button with 30 s auto-wipe. */
145145
function wireClipboardButton(
146146
dialog: HTMLElement,
147147
selector: string,
@@ -158,9 +158,14 @@ function wireClipboardButton(
158158
} catch { /* clipboard may be blocked */ }
159159
})
160160
}
161-
162-
/** Close and remove the dialog from the DOM. */
163161
function closeExportModal(dialog: HTMLDialogElement): void {
164162
dialog.close()
165163
dialog.remove()
166164
}
165+
function ensureStyles(): void {
166+
if (document.getElementById(STYLE_ID)) return
167+
const style = document.createElement('style')
168+
style.id = STYLE_ID
169+
style.textContent = STYLES
170+
document.head.appendChild(style)
171+
}

app/components/identity-tree.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { walkTree, findById } from '../persona-tree.js'
55
import { createChildPersona } from '../persona.js'
66
import { personaColour } from './persona-picker.js'
77
import { escapeHtml } from '../utils/escape.js'
8-
import type { AppPersona, AppGroup } from '../types.js'
8+
import { identityNodeLabel } from '../types.js'
9+
import type { AppPersona, AppGroup, IdentityNodeType } from '../types.js'
910

1011
// ── Styles ─────────────────────────────────────────────────────
1112

@@ -78,6 +79,16 @@ const TREE_STYLES = `
7879
font-size: 0.75rem;
7980
}
8081
82+
.id-tree__type {
83+
font-size: 0.625rem;
84+
letter-spacing: 0.08em;
85+
text-transform: uppercase;
86+
color: var(--text-muted);
87+
border: 1px solid var(--border);
88+
border-radius: 999px;
89+
padding: 0.05rem 0.35rem;
90+
}
91+
8192
.id-tree__groups {
8293
margin-left: auto;
8394
font-size: 0.6875rem;
@@ -179,6 +190,7 @@ function renderNode(
179190
const displayNameHtml = persona.displayName && persona.displayName !== persona.name
180191
? ` <span class="id-tree__display-name">(${escapeHtml(persona.displayName)})</span>`
181192
: ''
193+
const typeHtml = `<span class="id-tree__type">${escapeHtml(identityNodeLabel(persona))}</span>`
182194

183195
const paddingLeft = depth * 1.5
184196
const isSelected = persona.id === selectedId
@@ -189,7 +201,8 @@ function renderNode(
189201
<span class="id-tree__connector">${parentPrefixes}${connector}</span>
190202
<span class="id-tree__badge" style="background: ${colour};">${letter}</span>
191203
<span class="id-tree__name">${escapeHtml(persona.name)}</span>${displayNameHtml}
192-
<button class="id-tree__add-btn" data-tree-add-child="${escapeHtml(persona.id)}" title="Add child persona">+</button>
204+
${typeHtml}
205+
<button class="id-tree__add-btn" data-tree-add-child="${escapeHtml(persona.id)}" title="Add child persona or account">+</button>
193206
${groupLabel ? `<span class="id-tree__groups" data-tree-groups-persona="${escapeHtml(persona.id)}">${groupLabel}</span>` : ''}
194207
</div>
195208
`
@@ -320,7 +333,16 @@ function showInlineInput(tree: HTMLElement, addBtn: HTMLElement, parentId: strin
320333
input.maxLength = 32
321334
input.autocomplete = 'off'
322335

336+
const typeSelect = document.createElement('select')
337+
typeSelect.className = 'input'
338+
typeSelect.style.cssText = 'font-size:0.75rem;padding:0.125rem 0.375rem;max-width:8rem;'
339+
typeSelect.innerHTML = `
340+
<option value="account">Account</option>
341+
<option value="persona">Persona</option>
342+
`
343+
323344
inputRow.appendChild(input)
345+
inputRow.appendChild(typeSelect)
324346
nodeRow.insertAdjacentElement('afterend', inputRow)
325347
input.focus()
326348

@@ -336,7 +358,8 @@ function showInlineInput(tree: HTMLElement, addBtn: HTMLElement, parentId: strin
336358
}
337359

338360
try {
339-
const newChild = createChildPersona(parentId, name)
361+
const nodeType = (typeSelect.value === 'persona' ? 'persona' : 'account') as IdentityNodeType
362+
const newChild = createChildPersona(parentId, name, nodeType)
340363
const { personas } = getState()
341364

342365
// Insert child into parent's children

app/components/linkage-proof.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getState } from '../state.js'
88
import { findById } from '../persona-tree.js'
99
import { escapeHtml } from '../utils/escape.js'
1010
import { personaColour } from './persona-picker.js'
11+
import { identityNodeLabel } from '../types.js'
1112
import { fromNsec } from 'nsec-tree/core'
1213
import { createBlindProof, createFullProof, verifyProof } from 'nsec-tree/proof'
1314
import type { LinkageProof } from 'nsec-tree/core'
@@ -88,6 +89,7 @@ export function showProveOwnershipModal(personaId: string): void {
8889

8990
const { persona, ancestors } = found
9091
const personaName = persona.name
92+
const nodeLabel = identityNodeLabel(persona).toLowerCase()
9193
// All values below are escaped before interpolation
9294
const pathStr = escapeHtml([...ancestors.map(a => a.name), personaName].join(' / '))
9395
const badgeHtml = badge(personaName)
@@ -99,29 +101,33 @@ export function showProveOwnershipModal(personaId: string): void {
99101
dialog.innerHTML = `
100102
<div class="modal__form" style="max-width:32rem;">
101103
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
102-
<h2 style="margin:0;font-size:1.125rem;">Prove Ownership</h2>
104+
<h2 style="margin:0;font-size:1.125rem;">Prove continuity</h2>
103105
<button data-close style="background:none;border:none;cursor:pointer;font-size:1.25rem;color:var(--text,#e0e0e0);">&times;</button>
104106
</div>
105107
106108
<p style="margin:0 0 1rem;color:var(--text-secondary,#aaa);font-size:0.875rem;">
107-
Prove ${badgeHtml}<strong>${nameHtml}</strong> derives from your master key.
109+
Show that ${badgeHtml}<strong>${nameHtml}</strong> comes from the same root as your master identity.
110+
</p>
111+
112+
<p style="margin:0 0 1rem;color:var(--text-secondary,#aaa);font-size:0.8125rem;line-height:1.5;">
113+
Use this when you want to prove a ${escapeHtml(nodeLabel)} is yours without handing over your seed phrase or raw master key.
108114
</p>
109115
110116
<div style="margin-bottom:1rem;font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--text-muted,#999);">
111117
Path: ${pathStr}
112118
</div>
113119
114120
<fieldset style="border:1px solid var(--border,#444);border-radius:6px;padding:0.75rem;margin-bottom:1rem;">
115-
<legend style="font-size:0.8125rem;color:var(--text-secondary,#aaa);padding:0 0.25rem;">Proof type</legend>
121+
<legend style="font-size:0.8125rem;color:var(--text-secondary,#aaa);padding:0 0.25rem;">How much should the proof reveal?</legend>
116122
<label style="display:block;margin-bottom:0.5rem;cursor:pointer;">
117123
<input type="radio" name="lp-type" value="blind" checked />
118-
<strong>Blind</strong>
119-
<span style="display:block;margin-left:1.25rem;font-size:0.75rem;color:var(--text-secondary,#aaa);">Proves ownership without revealing your master identity.</span>
124+
<strong>Private proof (recommended)</strong>
125+
<span style="display:block;margin-left:1.25rem;font-size:0.75rem;color:var(--text-secondary,#aaa);">Proves both identities share a root, while keeping derivation details hidden.</span>
120126
</label>
121127
<label style="display:block;cursor:pointer;">
122128
<input type="radio" name="lp-type" value="full" />
123-
<strong>Full</strong>
124-
<span style="display:block;margin-left:1.25rem;font-size:0.75rem;color:var(--text-secondary,#aaa);">Reveals your master identity and derivation path. For legal/compliance only.</span>
129+
<strong>Debug / compliance proof</strong>
130+
<span style="display:block;margin-left:1.25rem;font-size:0.75rem;color:var(--text-secondary,#aaa);">Also reveals the exact derivation context. Useful for audits, recovery debugging, or compliance workflows.</span>
125131
</label>
126132
</fieldset>
127133
@@ -198,12 +204,12 @@ export function showVerifyProofModal(): void {
198204
dialog.innerHTML = `
199205
<div class="modal__form" style="max-width:32rem;">
200206
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
201-
<h2 style="margin:0;font-size:1.125rem;">Verify Linkage Proof</h2>
207+
<h2 style="margin:0;font-size:1.125rem;">Verify continuity proof</h2>
202208
<button data-close style="background:none;border:none;cursor:pointer;font-size:1.25rem;color:var(--text,#e0e0e0);">&times;</button>
203209
</div>
204210
205211
<label style="display:block;margin-bottom:0.75rem;">
206-
<span style="font-size:0.8125rem;color:var(--text-secondary,#aaa);">Paste a linkage proof JSON</span>
212+
<span style="font-size:0.8125rem;color:var(--text-secondary,#aaa);">Paste a proof JSON to confirm two identities share the same root.</span>
207213
<textarea id="vp-input" rows="8" style="display:block;width:100%;margin-top:0.25rem;padding:0.5rem;border-radius:6px;border:1px solid var(--border,#444);background:var(--surface,#1e1e2e);color:var(--text,#e0e0e0);font-family:monospace;font-size:0.75rem;resize:vertical;" placeholder='{"masterPubkey":"...","childPubkey":"...","attestation":"...","signature":"..."}'></textarea>
208214
</label>
209215

0 commit comments

Comments
 (0)