rootcard 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/keytool.html CHANGED
@@ -3,114 +3,386 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src https: http://127.0.0.1:* http://localhost:*; img-src data:; base-uri 'none'; form-action 'none'">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src https: http://127.0.0.1:* http://localhost:* http://[::1]:*; img-src data:; base-uri 'none'; form-action 'none'">
7
7
  <meta name="referrer" content="no-referrer">
8
+ <meta name="color-scheme" content="dark">
8
9
  <title>rootcard key tool</title>
9
10
  <style>
10
- :root { --bg: #fbfaf7; --fg: #1d1d1b; --muted: #6b6a66; --line: #dcd9d2; --accent: #2d5b8a; --warn: #8a3b2d; --panel: #fff; }
11
- @media (prefers-color-scheme: dark) { :root { --bg: #161615; --fg: #ecebe7; --muted: #a3a19b; --line: #3a3936; --accent: #8db6e0; --warn: #e09a8d; --panel: #1f1f1d; } }
12
- * { box-sizing: border-box; }
13
- body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
14
- main { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
15
- h1 { font-size: 22px; margin: 0 0 4px; }
16
- h2 { font-size: 16px; margin: 0 0 12px; }
17
- p.lead { color: var(--muted); margin: 0 0 20px; }
18
- .warn { border-left: 3px solid var(--warn); padding: 8px 12px; margin: 0 0 20px; background: var(--panel); }
19
- section { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin: 0 0 16px; }
20
- label { display: block; font-size: 13px; color: var(--muted); margin: 8px 0 2px; }
21
- input, textarea, select { width: 100%; font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
22
- textarea { min-height: 64px; font-family: ui-monospace, monospace; font-size: 13px; }
23
- .row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 12px; }
24
- button { font: inherit; padding: 6px 14px; margin: 12px 8px 0 0; border: 1px solid var(--accent); border-radius: 6px; background: transparent; color: var(--accent); cursor: pointer; }
25
- button.danger { border-color: var(--warn); color: var(--warn); }
26
- button:disabled { opacity: .4; cursor: default; }
27
- pre { white-space: pre-wrap; word-break: break-all; font: 13px/1.45 ui-monospace, monospace; margin: 0; }
28
- #status { min-height: 1.5em; }
29
- .ok { color: var(--accent); } .err { color: var(--warn); }
30
- details > summary { cursor: pointer; font-weight: 600; }
11
+ /* The theme of rootcard.org: the logo's teal tile and white chip on a deep night background. Everything is inline:
12
+ the page works from file:// and loads nothing. */
13
+ :root {
14
+ --bg: #0a0f14; --bg-2: #0d141b; --surface: #111a23; --surface-2: #16212c; --line: #1f2c39; --line-2: #2a3a4a;
15
+ --text: #e7eef4; --muted: #94a5b6; --faint: #66788a;
16
+ --teal-700: #136579; --teal-600: #1a7a91; --teal-500: #2493ab; --teal-400: #3fb0c6; --teal-300: #6fd0df; --teal-200: #a6e6ee;
17
+ --ok: #4ed39a; --warn: #f2b25c; --danger: #ff7a6b;
18
+ --radius: 14px; --radius-lg: 22px;
19
+ --shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 20px 50px -20px rgba(0, 0, 0, 0.6);
20
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
21
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
22
+ }
23
+ *, *::before, *::after { box-sizing: border-box; }
24
+ html { -webkit-text-size-adjust: 100%; }
25
+ body {
26
+ margin: 0; font: 16px/1.6 var(--sans); color: var(--text); min-height: 100vh;
27
+ background:
28
+ radial-gradient(1200px 600px at 80% -10%, rgba(36, 147, 171, 0.18), transparent 60%),
29
+ radial-gradient(900px 500px at -10% 10%, rgba(19, 101, 121, 0.16), transparent 60%),
30
+ var(--bg);
31
+ background-attachment: fixed;
32
+ }
33
+ ::selection { background: rgba(63, 176, 198, 0.35); }
34
+ a { color: var(--teal-300); }
35
+ code, .mono { font-family: var(--mono); font-size: 0.92em; }
36
+ :not(pre) > code { background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 0.08em 0.4em; }
37
+ h1, h2, h3 { line-height: 1.15; margin: 0 0 0.5em; letter-spacing: -0.02em; }
38
+ h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 800; }
39
+ h2 { font-size: 1.35rem; }
40
+ h3 { font-size: 1.1rem; }
41
+ p { margin: 0 0 1em; }
42
+ [hidden] { display: none !important; }
43
+ .wrap { width: min(980px, 100% - 2.5rem); margin-inline: auto; }
44
+ .muted { color: var(--muted); }
45
+
46
+ .topbar { border-bottom: 1px solid rgba(255, 255, 255, 0.06); background: rgba(10, 15, 20, 0.72); }
47
+ .topbar-inner { display: flex; align-items: center; justify-content: space-between; height: 62px; gap: 1rem; }
48
+ .brand { display: inline-flex; align-items: center; gap: 0.6rem; }
49
+ .brand svg { width: 34px; height: 34px; border-radius: 9px; box-shadow: 0 6px 18px -6px rgba(36, 147, 171, 0.7); }
50
+ .wordmark { font-weight: 800; font-size: 1.22rem; letter-spacing: -0.03em; }
51
+ .wordmark b { color: var(--teal-300); }
52
+
53
+ .page-head { padding: clamp(2rem, 5vw, 3.2rem) 0 1.4rem; }
54
+ .page-head p { color: var(--muted); font-size: 1.08rem; max-width: 46rem; }
55
+ .eyebrow { display: inline-block; font-size: 0.8rem; font-weight: 750; letter-spacing: 0.14em; text-transform: uppercase; color: var(--teal-300); margin-bottom: 0.8rem; }
56
+
57
+ .panel {
58
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0)), var(--surface);
59
+ border: 1px solid var(--line); border-radius: var(--radius-lg); padding: clamp(1.2rem, 3vw, 1.9rem); box-shadow: var(--shadow);
60
+ margin-bottom: 1.25rem;
61
+ }
62
+ .step-no { font-family: var(--mono); font-size: 0.85rem; font-weight: 700; color: var(--teal-300); margin-right: 0.5rem; }
63
+
64
+ .btn {
65
+ --b: var(--line-2); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
66
+ font: inherit; font-weight: 650; font-size: 1rem; line-height: 1; padding: 0.85rem 1.3rem; border-radius: 12px;
67
+ border: 1px solid var(--b); background: var(--surface-2); color: var(--text); cursor: pointer; white-space: nowrap;
68
+ transition: transform 0.12s ease, background 0.2s, border-color 0.2s;
69
+ }
70
+ .btn:hover { border-color: var(--teal-500); transform: translateY(-1px); }
71
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
72
+ .btn-primary {
73
+ --b: transparent; background: linear-gradient(180deg, var(--teal-500), var(--teal-700)); color: #fff;
74
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.25) inset, 0 10px 28px -10px rgba(36, 147, 171, 0.9);
75
+ }
76
+ .btn-primary:hover { background: linear-gradient(180deg, #2aa2bb, var(--teal-600)); border-color: transparent; }
77
+ .btn-danger { --b: rgba(255, 122, 107, 0.4); color: var(--danger); background: transparent; }
78
+ .btn-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-top: 0.4rem; }
79
+
80
+ label, .label { display: block; font-weight: 600; font-size: 0.93rem; margin-bottom: 0.4rem; }
81
+ .opt { color: var(--faint); font-weight: 500; }
82
+ .field { margin-bottom: 1.1rem; }
83
+ .hint { display: block; color: var(--faint); font-size: 0.85rem; font-weight: 400; margin-top: 0.35rem; }
84
+ .hint.good { color: var(--ok); }
85
+ .hint.bad { color: var(--danger); }
86
+ input:not([type="file"]):not([type="radio"]), textarea, select {
87
+ width: 100%; font: inherit; color: var(--text); background: var(--bg-2); border: 1px solid var(--line-2);
88
+ border-radius: 11px; padding: 0.75rem 0.95rem; outline: none; transition: border-color 0.15s, box-shadow 0.15s;
89
+ }
90
+ input[type="file"] {
91
+ width: 100%; font: inherit; font-size: 0.93rem; color: var(--muted); background: var(--bg-2);
92
+ border: 1px dashed var(--line-2); border-radius: 11px; padding: 0.6rem;
93
+ }
94
+ input[type="file"]::file-selector-button {
95
+ font: inherit; font-weight: 650; color: var(--text); background: var(--surface-2); border: 1px solid var(--line-2);
96
+ border-radius: 9px; padding: 0.45rem 0.9rem; margin-right: 0.8rem; cursor: pointer;
97
+ }
98
+ textarea { font-family: var(--mono); font-size: 0.86rem; min-height: 6rem; resize: vertical; }
99
+ input:focus, textarea:focus, select:focus { border-color: var(--teal-400); box-shadow: 0 0 0 4px rgba(63, 176, 198, 0.16); }
100
+ input[aria-invalid="true"] { border-color: var(--danger); }
101
+ .row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 1rem; }
102
+
103
+ .choice { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1.2rem; }
104
+ @media (max-width: 640px) { .choice { grid-template-columns: 1fr; } }
105
+ .choice label { display: flex; gap: 0.7rem; align-items: flex-start; padding: 0.9rem 1rem; border: 1px solid var(--line-2); border-radius: 13px; background: var(--bg-2); cursor: pointer; margin: 0; }
106
+ .choice label:has(input:checked) { border-color: var(--teal-400); background: rgba(63, 176, 198, 0.08); }
107
+ .choice input { accent-color: var(--teal-500); margin-top: 0.3rem; flex: none; }
108
+ .choice span { display: block; font-weight: 400; color: var(--muted); font-size: 0.88rem; }
109
+
110
+ details.more { margin: -0.3rem 0 1.1rem; }
111
+ details.more > summary { cursor: pointer; color: var(--teal-300); font-size: 0.93rem; font-weight: 600; }
112
+ details.more[open] > summary { margin-bottom: 0.8rem; }
113
+
114
+ .tabs { display: flex; gap: 0.3rem; border-bottom: 1px solid var(--line); margin: -0.4rem 0 1.4rem; overflow-x: auto; }
115
+ .tabs button { font: inherit; font-weight: 600; font-size: 0.97rem; background: none; border: 0; color: var(--muted); padding: 0.7rem 0.9rem; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; }
116
+ .tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--teal-400); }
117
+ .tabs button:hover { color: var(--text); }
118
+
119
+ .notice { display: flex; gap: 0.8rem; padding: 0.9rem 1.1rem; border-radius: 13px; border: 1px solid var(--line-2); background: var(--surface-2); font-size: 0.95rem; margin-bottom: 1.1rem; }
120
+ .notice::before { content: "i"; flex: none; width: 1.4rem; height: 1.4rem; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 0.8rem; background: var(--teal-700); color: #fff; margin-top: 0.1rem; }
121
+ .notice.warn { border-color: rgba(242, 178, 92, 0.35); background: rgba(242, 178, 92, 0.07); }
122
+ .notice.warn::before { content: "!"; background: #9a6420; }
123
+ .notice.error { border-color: rgba(255, 122, 107, 0.4); background: rgba(255, 122, 107, 0.08); }
124
+ .notice.error::before { content: "×"; background: #a33b2f; }
125
+ .notice.ok { border-color: rgba(78, 211, 154, 0.35); background: rgba(78, 211, 154, 0.07); }
126
+ .notice.ok::before { content: "✓"; background: #1f7d55; }
127
+ .notice p:last-child { margin: 0; }
128
+
129
+ .badge { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.25rem 0.6rem; border-radius: 999px; border: 1px solid var(--line-2); color: var(--muted); background: var(--surface-2); white-space: nowrap; }
130
+ .badge.ok { color: var(--ok); border-color: rgba(78, 211, 154, 0.35); background: rgba(78, 211, 154, 0.08); }
131
+ .badge.teal { color: var(--teal-200); border-color: rgba(63, 176, 198, 0.4); background: rgba(63, 176, 198, 0.1); }
132
+ .badge.warn { color: var(--warn); border-color: rgba(242, 178, 92, 0.35); background: rgba(242, 178, 92, 0.08); }
133
+ .badge.bad { color: var(--danger); border-color: rgba(255, 122, 107, 0.4); background: rgba(255, 122, 107, 0.08); }
134
+ .badge .dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: currentColor; }
135
+ .badges { display: flex; flex-wrap: wrap; gap: 0.45rem; }
136
+
137
+ /* The identity, drawn as the card on rootcard.org. */
138
+ .summary { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 1.5rem; align-items: center; margin-bottom: 1.4rem; }
139
+ @media (max-width: 760px) { .summary { grid-template-columns: 1fr; } }
140
+ .idcard {
141
+ --c1: #1f8ea6; --c2: #0d4c5e; aspect-ratio: 85.6 / 54; border-radius: 18px; padding: 7% 7.5%; color: #fff;
142
+ container-type: inline-size; overflow: hidden; display: grid; grid-template-rows: auto 1fr auto;
143
+ background:
144
+ radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, 0.22), transparent 55%),
145
+ repeating-radial-gradient(circle at 115% 120%, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 9px),
146
+ linear-gradient(135deg, var(--c1), var(--c2));
147
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14) inset, 0 1px 0 rgba(255, 255, 255, 0.35) inset, 0 30px 60px -30px rgba(0, 0, 0, 0.85);
148
+ }
149
+ .idcard.local { --c1: #2c3440; --c2: #11161d; }
150
+ .idcard.retired { filter: grayscale(0.9) brightness(0.7); }
151
+ .ic-top { display: flex; justify-content: space-between; align-items: center; font-size: 4.4cqi; font-weight: 800; }
152
+ .ic-kind { font-size: 0.7em; font-weight: 750; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.85; }
153
+ .idcard.local .ic-kind { color: var(--warn); }
154
+ .ic-mid { display: flex; align-items: center; gap: 6cqi; }
155
+ .ic-chip { width: 17cqi; flex: none; }
156
+ .ic-label { font-size: 2.8cqi; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.7; font-weight: 700; }
157
+ .ic-handle { font-size: 7.4cqi; font-weight: 800; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
158
+ .ic-handle .tag { color: var(--teal-200); font-weight: 700; }
159
+ .idcard.local .ic-handle .tag { color: var(--warn); }
160
+ .ic-id { font-family: var(--mono); font-size: 3cqi; letter-spacing: 0.04em; opacity: 0.9; word-break: break-all; }
161
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.45rem 1.1rem; font-size: 0.93rem; margin: 0 0 1rem; }
162
+ .kv dt { color: var(--faint); }
163
+ .kv dd { margin: 0; word-break: break-all; }
164
+
165
+ /* Actions: tiles to choose from, then the chosen action's form. */
166
+ .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.7rem; margin-bottom: 1.3rem; }
167
+ .tile { text-align: left; font: inherit; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem 1rem; cursor: pointer; display: flex; flex-direction: column; gap: 0.45rem; transition: border-color 0.15s, transform 0.15s; }
168
+ .tile:hover { border-color: var(--line-2); transform: translateY(-1px); }
169
+ .tile[aria-pressed="true"] { border-color: var(--teal-400); background: rgba(63, 176, 198, 0.08); }
170
+ .tile.unavailable { opacity: 0.55; }
171
+ .tile b { font-size: 0.98rem; }
172
+ .tile small { color: var(--muted); font-size: 0.85rem; line-height: 1.4; }
173
+ .tile.danger b { color: var(--danger); }
174
+ .tile .req { align-self: flex-start; margin-top: auto; font-size: 0.68rem; padding: 0.15rem 0.5rem; }
175
+ .opform { border-top: 1px solid var(--line); padding-top: 1.3rem; }
176
+ .opform h3 { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
177
+
178
+ .result-head { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.9rem; }
179
+ .result-head h2 { margin: 0; }
180
+ #status { font-weight: 700; }
181
+ #status.ok { color: var(--ok); }
182
+ #status.err { color: var(--danger); }
183
+ #status.busy { color: var(--teal-300); }
184
+ pre {
185
+ margin: 0; background: #070b0f; border: 1px solid var(--line); border-radius: 13px; padding: 1rem 1.15rem;
186
+ white-space: pre-wrap; word-break: break-all; font: 0.86rem/1.6 var(--mono); color: #cfe3ea; min-height: 3.2rem;
187
+ }
188
+ .foot { color: var(--faint); font-size: 0.85rem; padding: 0.5rem 0 3rem; }
189
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }
31
190
  </style>
32
191
  </head>
33
192
  <body>
34
- <main>
35
- <h1>rootcard key tool</h1>
36
- <p class="lead">The tool for everything that needs your recovery card: moving an identity, resetting or adding a password, rotating the recovery key and closing the identity. It runs entirely in this page and talks only to the hosts you enter.</p>
37
- <div class="warn">Only enter your recovery key in the rootcard key tool, never in an application. Save this page and open it from your own disk.</div>
193
+ <header class="topbar">
194
+ <div class="wrap topbar-inner">
195
+ <span class="brand">
196
+ <svg viewBox="0 0 64 64" aria-hidden="true">
197
+ <defs>
198
+ <linearGradient id="lt" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2493ab"/><stop offset="1" stop-color="#0d4c5e"/></linearGradient>
199
+ <linearGradient id="lc" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d7eef3"/></linearGradient>
200
+ <mask id="lm">
201
+ <rect x="13" y="17" width="38" height="30" rx="7.5" fill="#fff"/>
202
+ <g stroke="#000" stroke-width="2.6" fill="none"><path d="M25.5 15V38.3M12 27H25.5M12 37H25.5"/><path d="M38.5 49V25.7M52 37H38.5M52 27H38.5"/></g>
203
+ </mask>
204
+ </defs>
205
+ <rect width="64" height="64" rx="15" fill="url(#lt)"/>
206
+ <rect x="13" y="17" width="38" height="30" rx="7.5" fill="url(#lc)" mask="url(#lm)"/>
207
+ </svg>
208
+ <span class="wordmark">root<b>card</b></span>
209
+ </span>
210
+ <span class="badge teal">Key tool <span id="version"></span></span>
211
+ </div>
212
+ </header>
213
+
214
+ <main class="wrap">
215
+ <div class="page-head">
216
+ <span class="eyebrow">Key tool</span>
217
+ <h1>Everything that needs your recovery card.</h1>
218
+ <p>Create an identity, put it on a host or move it, reset or add a password, rotate the recovery key, or close the
219
+ identity. The page runs entirely in your browser. It contacts no one until you enter a host address, and then only
220
+ that host.</p>
221
+ <div class="notice warn"><p>Only enter your recovery key here, never in an application or a website. Save this page
222
+ and open it from your own disk.</p></div>
223
+ </div>
224
+
225
+ <section class="panel">
226
+ <div class="tabs" role="tablist">
227
+ <button type="button" role="tab" id="tab-open" aria-selected="true" aria-controls="pane-open">Open my identity</button>
228
+ <button type="button" role="tab" id="tab-create" aria-selected="false" aria-controls="pane-create">Create a new identity</button>
229
+ <button type="button" role="tab" id="tab-verify" aria-selected="false" aria-controls="pane-verify">Verify a proof</button>
230
+ </div>
38
231
 
39
- <section>
40
- <h2>Your identity</h2>
41
- <label for="cardFile">Recovery card (file)</label>
42
- <input id="cardFile" type="file" accept=".json,application/json">
43
- <details><summary>or type the card</summary>
44
- <div class="row">
45
- <div><label for="realm">Realm</label><input id="realm" value="global" autocomplete="off"></div>
46
- <div><label for="username">Username</label><input id="username" autocomplete="off"></div>
232
+ <div id="pane-open" role="tabpanel">
233
+ <div class="field">
234
+ <label for="cardFile">Recovery card</label>
235
+ <input id="cardFile" type="file" accept=".json,application/json">
236
+ <span class="hint">The <code>.card.json</code> file you saved when the identity was created.</span>
47
237
  </div>
48
- <label for="rkey">Recovery key (RCR-…)</label><input id="rkey" autocomplete="off" spellcheck="false">
49
- </details>
50
- <label for="accountFile">Account file (optional)</label>
51
- <input id="accountFile" type="file" accept=".json,application/json">
52
- <label for="hosts">Hosts to ask for the newest version (comma separated base URLs, optional)</label>
53
- <input id="hosts" placeholder="https://id.example.com" autocomplete="off">
54
- <button id="load">Load identity</button>
55
- <pre id="identity"></pre>
56
- </section>
238
+ <details class="more"><summary>No file? Type the card instead</summary>
239
+ <div class="row">
240
+ <div class="field"><label for="realm">Realm</label><input id="realm" value="global" autocomplete="off" spellcheck="false"></div>
241
+ <div class="field"><label for="username">Username</label><input id="username" autocomplete="off" spellcheck="false"></div>
242
+ </div>
243
+ <div class="field"><label for="rkey">Recovery key</label><input id="rkey" autocomplete="off" spellcheck="false" placeholder="RCR-…"></div>
244
+ </details>
245
+ <div class="field">
246
+ <label for="accountFile">Account file <span class="opt">(optional)</span></label>
247
+ <input id="accountFile" type="file" accept=".json,application/json">
248
+ <span class="hint">An <code>.account.json</code> export. It holds the newest log and a copy of your vault, so the
249
+ tool can work from it even when the host cannot be reached.</span>
250
+ </div>
251
+ <div class="field">
252
+ <label for="hosts">Host address <span class="opt">(optional)</span></label>
253
+ <input id="hosts" data-host="list" placeholder="https://id.example.com" autocomplete="off" spellcheck="false">
254
+ <span class="hint" id="hostsCheck">The address of the server where you use the identity. A card alone does not
255
+ know where the identity lives, so enter the host here or load the account file. Leave it empty to work only
256
+ with the files.</span>
257
+ </div>
258
+ <div class="btn-row"><button type="button" class="btn btn-primary" id="load">Open identity</button></div>
259
+ </div>
57
260
 
58
- <section id="actions" hidden>
59
- <h2>Actions</h2>
60
- <div class="row">
61
- <div><label for="newPassword">New password (for restore with a new host, fresh, reset, add)</label><input id="newPassword" type="password" autocomplete="new-password"></div>
62
- <div><label for="label">Label of the new password</label><input id="label" value="Password"></div>
261
+ <div id="pane-create" role="tabpanel" hidden>
262
+ <p class="muted">A new recovery key is generated in this page. The card is downloaded before anything is sent.
263
+ Keep it offline: it is the only way to recover the identity.</p>
264
+ <div class="label">Where should the identity live?</div>
265
+ <div class="choice">
266
+ <label><input type="radio" name="nWhere" id="nAtHost" value="host" checked>
267
+ <div>At a host<span>Registered right away, with a password to sign in with in applications.</span></div></label>
268
+ <label><input type="radio" name="nWhere" id="nLocal" value="local">
269
+ <div>Only on this computer<span>A card and an account file, nothing sent. Put it on a host later with
270
+ "Start at a host".</span></div></label>
271
+ </div>
272
+ <div class="row">
273
+ <div class="field"><label for="nRealm">Realm</label><input id="nRealm" value="global" autocomplete="off" spellcheck="false">
274
+ <span class="hint">The namespace the identity belongs to. Keep <code>global</code> unless an application says otherwise.</span></div>
275
+ <div class="field"><label for="nUser">Username</label><input id="nUser" autocomplete="off" spellcheck="false"></div>
276
+ </div>
277
+ <div data-when="host">
278
+ <div class="field"><label for="nHost">Host address</label>
279
+ <input id="nHost" data-host="one" placeholder="https://id.example.com" autocomplete="off" spellcheck="false">
280
+ <span class="hint" id="nHostCheck">The server that will store the identity, for example the one an application told you to use.</span></div>
281
+ <div class="field"><label for="nPassword">Password</label><input id="nPassword" type="password" autocomplete="new-password">
282
+ <span class="hint">At least 12 characters, not the username. You sign in with it; the recovery card stays offline.</span></div>
283
+ </div>
284
+ <div class="btn-row"><button type="button" class="btn btn-primary" id="create">Create identity</button></div>
63
285
  </div>
64
- <label for="targetHost">Host for a new instance (restore, fresh)</label>
65
- <input id="targetHost" placeholder="https://id.example.com" autocomplete="off">
66
- <button data-op="restore">Restore at host</button>
67
- <button data-op="fresh">Fresh instance at host</button>
68
- <button data-op="reset">Reset forgotten password</button>
69
- <button data-op="add">Add password</button>
70
- <label for="slot">Password to revoke</label><select id="slot"></select>
71
- <button data-op="revoke" class="danger">Revoke password</button>
72
- <button data-op="rotate">Rotate recovery key</button>
73
- <button data-op="card">Print card</button>
74
- <label for="retireConfirm">To close the identity forever, type its username</label>
75
- <input id="retireConfirm" autocomplete="off">
76
- <button data-op="retire" class="danger">Retire identity</button>
77
- <details><summary>Root proof</summary>
78
- <label for="proofStatement">Statement (JSON) or challenge (64 hex)</label><input id="proofStatement" value='"I own this identity"'>
79
- <label for="proofRecipient">Recipient</label><input id="proofRecipient">
80
- <button data-op="prove">Make root proof</button>
81
- </details>
82
- </section>
83
286
 
84
- <section>
85
- <details><summary>Create a new identity</summary>
287
+ <div id="pane-verify" role="tabpanel" hidden>
288
+ <p class="muted">Check a proof that someone made with their identity. Works offline.</p>
289
+ <div class="field"><label for="vLog">Account file or log (JSON)</label><textarea id="vLog" spellcheck="false"></textarea></div>
290
+ <div class="field"><label for="vProof">Proof (JSON)</label><textarea id="vProof" spellcheck="false"></textarea></div>
86
291
  <div class="row">
87
- <div><label for="nRealm">Realm</label><input id="nRealm" value="global"></div>
88
- <div><label for="nUser">Username</label><input id="nUser"></div>
292
+ <div class="field"><label for="vStatement">Statement (JSON) or challenge (64 hex)</label><input id="vStatement" spellcheck="false"></div>
293
+ <div class="field"><label for="vRecipient">Recipient</label><input id="vRecipient" spellcheck="false"></div>
89
294
  </div>
90
- <label for="nHost">Host (optional: without one, the identity has no instance until you restore or start fresh)</label>
91
- <input id="nHost" placeholder="https://id.example.com">
92
- <label for="nPassword">Password</label><input id="nPassword" type="password" autocomplete="new-password">
93
- <button id="create">Create</button>
94
- </details>
295
+ <div class="btn-row"><button type="button" class="btn btn-primary" id="verify">Verify</button></div>
296
+ </div>
95
297
  </section>
96
298
 
97
- <section>
98
- <details><summary>Verify a proof</summary>
99
- <label for="vLog">Account file or log (JSON)</label><textarea id="vLog"></textarea>
100
- <label for="vProof">Proof (JSON)</label><textarea id="vProof"></textarea>
101
- <label for="vStatement">Statement (JSON) or challenge (64 hex)</label><input id="vStatement">
102
- <label for="vRecipient">Recipient</label><input id="vRecipient">
103
- <button id="verify">Verify</button>
104
- </details>
299
+ <section class="panel" id="actions" hidden>
300
+ <h2>Your identity</h2>
301
+ <div class="summary">
302
+ <div class="idcard" id="idcard">
303
+ <div class="ic-top"><span>root<b>card</b></span><span class="ic-kind" id="icKind"></span></div>
304
+ <div class="ic-mid">
305
+ <svg class="ic-chip" viewBox="13 17 38 30" aria-hidden="true">
306
+ <defs>
307
+ <linearGradient id="cg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f6fbfc"/><stop offset="0.5" stop-color="#cfe6ec"/><stop offset="1" stop-color="#a9cdd6"/></linearGradient>
308
+ <mask id="cm">
309
+ <rect x="13" y="17" width="38" height="30" rx="7.5" fill="#fff"/>
310
+ <g stroke="#000" stroke-width="2.2" fill="none"><path d="M25.5 15V38.1M12 27H25.5M12 37H25.5"/><path d="M38.5 49V25.9M52 37H38.5M52 27H38.5"/></g>
311
+ </mask>
312
+ </defs>
313
+ <rect x="13" y="17" width="38" height="30" rx="7.5" fill="url(#cg)" mask="url(#cm)"/>
314
+ </svg>
315
+ <div style="min-width: 0"><div class="ic-label">Handle</div><div class="ic-handle" id="icHandle"></div></div>
316
+ </div>
317
+ <div class="ic-id" id="icAccount"></div>
318
+ </div>
319
+ <div>
320
+ <div class="badges" id="badges"></div>
321
+ <p></p>
322
+ <dl class="kv" id="facts"></dl>
323
+ </div>
324
+ </div>
325
+ <div class="notice" id="modeNote"></div>
326
+
327
+ <h3>What do you want to do?</h3>
328
+ <div class="tiles" id="tiles">
329
+ <button type="button" class="tile" data-pick="fresh"><b>Start at a host</b><small>Put the identity on a host with a new, empty account and a password.</small></button>
330
+ <button type="button" class="tile" data-pick="restore"><b>Move to another host</b><small>Carry data, profile and passwords to a host, or restore after a host is gone.</small></button>
331
+ <button type="button" class="tile" data-pick="reset"><b>Reset forgotten password</b><small>One new password; every old password and session stops working.</small></button>
332
+ <button type="button" class="tile" data-pick="add"><b>Add a password</b><small>Another password, for example for a second device.</small></button>
333
+ <button type="button" class="tile" data-pick="revoke"><b>Revoke a password</b><small>Stop one password from working, for example after losing a device.</small></button>
334
+ <button type="button" class="tile" data-pick="rotate"><b>Rotate recovery key</b><small>Replace the recovery key. You get a new card; destroy the old one.</small></button>
335
+ <button type="button" class="tile" data-pick="card"><b>Show the card</b><small>The recovery card as text, to print or copy onto paper.</small></button>
336
+ <button type="button" class="tile" data-pick="prove"><b>Make a root proof</b><small>Sign a statement with the recovery key.</small></button>
337
+ <button type="button" class="tile danger" data-pick="retire"><b>Retire identity</b><small>Close the identity for good. It cannot be undone.</small></button>
338
+ </div>
339
+
340
+ <div class="opform" id="opform" hidden>
341
+ <h3><span id="opTitle"></span><span class="badge" id="opWhere"></span></h3>
342
+ <p class="muted" id="opText"></p>
343
+ <div class="notice warn" id="opBlocked" hidden></div>
344
+ <div class="field" data-for="fresh restore">
345
+ <label for="targetHost" id="targetHostLabel">Host address</label>
346
+ <input id="targetHost" data-host="one" placeholder="https://id.example.com" autocomplete="off" spellcheck="false">
347
+ <span class="hint" id="targetHostCheck">The server that will store the identity from now on.</span>
348
+ </div>
349
+ <div class="row" data-for="fresh reset add">
350
+ <div class="field"><label for="newPassword">New password</label><input id="newPassword" type="password" autocomplete="new-password">
351
+ <span class="hint">At least 12 characters, not the username.</span></div>
352
+ <div class="field"><label for="label">Name of the password</label><input id="label" value="Password">
353
+ <span class="hint">So you can tell your passwords apart, for example "Phone".</span></div>
354
+ </div>
355
+ <div class="field" data-for="revoke"><label for="slot">Password to revoke</label><select id="slot"></select></div>
356
+ <div data-for="prove">
357
+ <div class="field"><label for="proofStatement">Statement (JSON) or challenge (64 hex)</label><input id="proofStatement" value='"I own this identity"' spellcheck="false"></div>
358
+ <div class="field"><label for="proofRecipient">Recipient</label><input id="proofRecipient" spellcheck="false">
359
+ <span class="hint">Who the proof is for, as they asked for it.</span></div>
360
+ </div>
361
+ <div class="field" data-for="retire"><label for="retireConfirm">Type the username to confirm</label><input id="retireConfirm" autocomplete="off" spellcheck="false"></div>
362
+ <div class="btn-row">
363
+ <button type="button" class="btn btn-primary" data-op="fresh" data-for="fresh">Start at this host</button>
364
+ <button type="button" class="btn btn-primary" data-op="restore" data-for="restore">Move to this host</button>
365
+ <button type="button" class="btn btn-primary" data-op="reset" data-for="reset">Reset password</button>
366
+ <button type="button" class="btn btn-primary" data-op="add" data-for="add">Add password</button>
367
+ <button type="button" class="btn btn-danger" data-op="revoke" data-for="revoke">Revoke password</button>
368
+ <button type="button" class="btn btn-primary" data-op="rotate" data-for="rotate">Rotate recovery key</button>
369
+ <button type="button" class="btn btn-primary" data-op="card" data-for="card">Show card</button>
370
+ <button type="button" class="btn btn-primary" data-op="prove" data-for="prove">Make root proof</button>
371
+ <button type="button" class="btn btn-danger" data-op="retire" data-for="retire">Retire identity</button>
372
+ </div>
373
+ </div>
105
374
  </section>
106
375
 
107
- <section><h2>Result</h2><div id="status"></div><pre id="out"></pre></section>
108
- <p class="lead">rootcard <span id="version"></span>. The format is specified in spec/SPEC.md of the rootcard repository.</p>
376
+ <section class="panel" id="result">
377
+ <div class="result-head"><h2>Result</h2><span id="status"></span></div>
378
+ <pre id="out">Nothing yet. Open your identity, create one or verify a proof.</pre>
379
+ </section>
380
+ <p class="foot">rootcard key tool. The format is specified in <code>spec/SPEC.md</code> of the rootcard repository.</p>
109
381
  </main>
110
382
  <script>
111
383
  "use strict";
112
384
  (() => {
113
- const ROOTCARD_VERSION = "0.2.1";
385
+ const ROOTCARD_VERSION = "0.3.1";
114
386
  const __modules = {};
115
387
  __modules["errors.js"] = (() => {
116
388
  // Error codes (SPEC §20).
@@ -689,14 +961,76 @@ async function publicOf(prefix, alg, secret, usages) {
689
961
  return x;
690
962
  }
691
963
 
964
+ /** How often a seed is drawn again before a generator is taken to be broken ([CRY-9]). */
965
+ const DRAW_ATTEMPTS = 64;
966
+
967
+ /** Whether an Ed25519 seed may be used for a new key: its first byte is not zero ([CRY-9]). */
968
+ function usableSeed(seed) {
969
+ return seed[0] !== 0;
970
+ }
971
+
972
+ // [IMP-6]: WebKit's WebCrypto rejects a pkcs8 Ed25519 seed whose first byte is zero, but signs with it when it is
973
+ // imported as a JWK with its public key. Public keys of seeds checked against a key on record (ed25519PublicIs) are
974
+ // kept here, keyed by the seed's buffer, so that later signatures with the same buffer can use that import. Nothing
975
+ // here is secret, and nothing is added without the check.
976
+ const CHECKED = new WeakMap();
977
+
978
+ function b64u(bytes) {
979
+ let b = "";
980
+ for (const x of bytes) b += String.fromCharCode(x);
981
+ return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
982
+ }
983
+
984
+ async function importJwk(seed, publicKey, extractable) {
985
+ const jwk = { kty: "OKP", crv: "Ed25519", d: b64u(seed), x: b64u(publicKey), ext: extractable };
986
+ try {
987
+ return await subtle.importKey("jwk", jwk, { name: "Ed25519" }, extractable, ["sign"]);
988
+ } finally {
989
+ jwk.d = "";
990
+ }
991
+ }
992
+
692
993
  /** The Ed25519 public key of a 32-byte seed ([CRY-2]). */
693
994
  async function ed25519Public(seed) {
694
- return publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
995
+ try {
996
+ return await publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
997
+ } catch (e) {
998
+ const known = CHECKED.get(seed);
999
+ if (known) return known.slice();
1000
+ throw e;
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Whether the Ed25519 public key of `seed` is `publicKey` (raw 32 bytes). Where the platform cannot import the seed
1006
+ * ([IMP-6]), the import of the seed with `publicKey` answers it, since the platform rejects a public key that does
1007
+ * not belong to the seed; the key is then remembered for signing with this seed buffer.
1008
+ */
1009
+ async function ed25519PublicIs(seed, publicKey) {
1010
+ let pub;
1011
+ try {
1012
+ pub = await publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
1013
+ } catch {
1014
+ try {
1015
+ await importJwk(seed, publicKey, false);
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ CHECKED.set(seed, Uint8Array.from(publicKey));
1020
+ return true;
1021
+ }
1022
+ return pub.length === publicKey.length && pub.every((b, i) => b === publicKey[i]);
695
1023
  }
696
1024
 
697
- /** Import an Ed25519 seed as a non-extractable signing key ([IMP-2]). */
1025
+ /** Import an Ed25519 seed as a non-extractable signing key ([IMP-2], [IMP-6]). */
698
1026
  async function ed25519SigningKey(seed) {
699
- return importPrivate(ED_PKCS8, "Ed25519", seed, false, ["sign"]);
1027
+ try {
1028
+ return await importPrivate(ED_PKCS8, "Ed25519", seed, false, ["sign"]);
1029
+ } catch (e) {
1030
+ const known = CHECKED.get(seed);
1031
+ if (known) return importJwk(seed, known, false);
1032
+ throw e;
1033
+ }
700
1034
  }
701
1035
 
702
1036
  /** A deterministic Ed25519 signature ([CRY-8]). `key` is a seed or a key from ed25519SigningKey. */
@@ -761,20 +1095,41 @@ async function ed25519Verify(publicKey, m, sig) {
761
1095
  if (!ok) fail("bad_signature");
762
1096
  }
763
1097
 
1098
+ // An X25519 secret as WebCrypto imports it. WebKit's WebCrypto on Linux rejects a secret whose first byte is zero
1099
+ // (DataError), probably because it handles the key as a big integer, so ~1 in 256 secrets failed there. X25519 clamps
1100
+ // every secret (RFC 7748 §5: the low 3 bits of the first byte cleared, the top bit of the last cleared and the next
1101
+ // set), so a zero first byte is given as 1 and a zero last byte as 0x40: the same scalar, the same keys and results.
1102
+ function x25519Secret(secret) {
1103
+ const c = Uint8Array.from(secret);
1104
+ if (c.length === 32) {
1105
+ if (c[0] === 0) c[0] = 1;
1106
+ if (c[31] === 0) c[31] = 0x40;
1107
+ }
1108
+ return c;
1109
+ }
1110
+
764
1111
  /** The X25519 public key of a 32-byte secret ([CRY-2]). */
765
1112
  async function x25519Public(secret) {
766
- return publicOf(X_PKCS8, "X25519", secret, ["deriveBits"]);
1113
+ const c = x25519Secret(secret);
1114
+ try {
1115
+ return await publicOf(X_PKCS8, "X25519", c, ["deriveBits"]);
1116
+ } finally {
1117
+ c.fill(0);
1118
+ }
767
1119
  }
768
1120
 
769
1121
  /** X25519 key agreement; an all-zero result fails ([CRY-4]). → bad_key */
770
1122
  async function x25519(secret, publicKey) {
771
1123
  let bits;
1124
+ const c = x25519Secret(secret);
772
1125
  try {
773
- const priv = await importPrivate(X_PKCS8, "X25519", secret, false, ["deriveBits"]);
1126
+ const priv = await importPrivate(X_PKCS8, "X25519", c, false, ["deriveBits"]);
774
1127
  const pub = await subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
775
1128
  bits = new Uint8Array(await subtle.deriveBits({ name: "X25519", public: pub }, priv, 256));
776
1129
  } catch {
777
1130
  fail("bad_key");
1131
+ } finally {
1132
+ c.fill(0);
778
1133
  }
779
1134
  if (bits.every((b) => b === 0)) fail("bad_key");
780
1135
  return bits;
@@ -796,7 +1151,7 @@ async function aesGcmDecrypt(key, nonce, ct, aad) {
796
1151
  }
797
1152
  }
798
1153
 
799
- return { checkSupport, frame, frameObject, sha256, hkdf, ed25519Public, ed25519SigningKey, ed25519Sign, ed25519PointOk, ed25519Verify, x25519Public, x25519, aesGcmEncrypt, aesGcmDecrypt };
1154
+ return { checkSupport, frame, frameObject, sha256, hkdf, DRAW_ATTEMPTS, usableSeed, ed25519Public, ed25519PublicIs, ed25519SigningKey, ed25519Sign, ed25519PointOk, ed25519Verify, x25519Public, x25519, aesGcmEncrypt, aesGcmDecrypt };
800
1155
  })();
801
1156
  __modules["keys.js"] = (() => {
802
1157
  // Keys in JSON (SPEC §3.3) and the schema helpers every structure uses ([CJ-11]–[CJ-13]).
@@ -1053,7 +1408,7 @@ __modules["log.js"] = (() => {
1053
1408
 
1054
1409
  const { fail } = __modules["errors.js"];
1055
1410
  const { base64urlEncode, base64urlDecode, bytesEqual } = __modules["encoding.js"];
1056
- const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1411
+ const { ed25519Public, ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1057
1412
  const { canonical, without, isObject, MAX_SAFE } = __modules["json.js"];
1058
1413
  const s = __modules["keys.js"];
1059
1414
  const { formatKey } = __modules["keys.js"];
@@ -1325,7 +1680,7 @@ async function addReceipt(e, hostSeed) {
1325
1680
 
1326
1681
  /** [RK-5] → wrong_recovery_key */
1327
1682
  async function checkRecoveryKey(state, seed) {
1328
- if (formatKey("ed25519", await ed25519Public(seed)) !== state.recovery) fail("wrong_recovery_key");
1683
+ if (!(await ed25519PublicIs(seed, s.parseKey(state.recovery, "ed25519")))) fail("wrong_recovery_key");
1329
1684
  }
1330
1685
 
1331
1686
  /** A pending instance ([LOG-9]). `home` is `{ key: "ed25519:…", url }`. */
@@ -1385,6 +1740,11 @@ function isVersionSegment(segment) {
1385
1740
  return /^[vV][0-9]+$/.test(segment);
1386
1741
  }
1387
1742
 
1743
+ // A base URL path segment ([HST-22]): non-empty, only unreserved characters (no percent-encoding), not a dot segment.
1744
+ function isPathSegment(segment) {
1745
+ return /^[A-Za-z0-9\-._~]+$/.test(segment) && segment !== "." && segment !== "..";
1746
+ }
1747
+
1388
1748
  function authorityHost(rest) {
1389
1749
  const authority = rest.split("/")[0];
1390
1750
  if (authority.startsWith("[")) {
@@ -1396,8 +1756,8 @@ function authorityHost(rest) {
1396
1756
 
1397
1757
  /**
1398
1758
  * Check a base URL ([HST-22]): `https://` and a host, an optional port and an optional path without a trailing slash,
1399
- * query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; and its last path
1400
- * segment is not an API version segment. → `bad_field`
1759
+ * query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; path segments of
1760
+ * unreserved characters only, never `.` or `..`; and its last path segment is not an API version segment. → `bad_field`
1401
1761
  */
1402
1762
  function checkBaseUrl(url) {
1403
1763
  checkUrl(url);
@@ -1413,7 +1773,7 @@ function checkBaseUrl(url) {
1413
1773
  if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
1414
1774
  if (slash >= 0) {
1415
1775
  const segments = rest.slice(slash + 1).split("/");
1416
- if (segments.some((x) => x === "")) fail("bad_field");
1776
+ if (!segments.every(isPathSegment)) fail("bad_field");
1417
1777
  if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
1418
1778
  }
1419
1779
  }
@@ -1425,7 +1785,7 @@ __modules["recovery.js"] = (() => {
1425
1785
 
1426
1786
  const { fail } = __modules["errors.js"];
1427
1787
  const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
1428
- const { ed25519Public, frame, frameObject, hkdf, sha256 } = __modules["crypto.js"];
1788
+ const { DRAW_ATTEMPTS, ed25519Public, frame, frameObject, hkdf, sha256, usableSeed } = __modules["crypto.js"];
1429
1789
  const { label } = __modules["labels.js"];
1430
1790
 
1431
1791
  async function checksum(key) {
@@ -1464,6 +1824,28 @@ async function deriveRecoveryKeys(rk, realm, username) {
1464
1824
  };
1465
1825
  }
1466
1826
 
1827
+ /**
1828
+ * Generate a recovery key for an identity ([RK-3]): 32 random bytes, drawn again while the derived signing seed is
1829
+ * unusable ([CRY-9]). Draws: 32 bytes per attempt. → bad_key only for a broken generator.
1830
+ */
1831
+ async function newRecoveryKey(realm, username, random) {
1832
+ for (let i = 0; i < DRAW_ATTEMPTS; i++) {
1833
+ const rk = random(32);
1834
+ const keys = await deriveRecoveryKeys(rk, realm, username);
1835
+ const ok = usableSeed(keys.signSeed);
1836
+ keys.signSeed.fill(0);
1837
+ keys.boxSecret.fill(0);
1838
+ if (ok) return rk;
1839
+ rk.fill(0);
1840
+ }
1841
+ fail("bad_key");
1842
+ }
1843
+
1844
+ /** A new recovery key must give a usable signing seed ([CRY-9]). → bad_key */
1845
+ function checkNewRecoveryKey(keys) {
1846
+ if (!usableSeed(keys.signSeed)) fail("bad_key");
1847
+ }
1848
+
1467
1849
  /** The public recovery signing key. */
1468
1850
  async function recoverySignPublic(keys) {
1469
1851
  return ed25519Public(keys.signSeed);
@@ -1471,7 +1853,7 @@ async function recoverySignPublic(keys) {
1471
1853
 
1472
1854
 
1473
1855
 
1474
- return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, recoverySignPublic };
1856
+ return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, newRecoveryKey, checkNewRecoveryKey, recoverySignPublic };
1475
1857
  })();
1476
1858
  __modules["vaultcheck.js"] = (() => {
1477
1859
  // The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
@@ -1841,7 +2223,7 @@ __modules["proof.js"] = (() => {
1841
2223
 
1842
2224
  const { fail } = __modules["errors.js"];
1843
2225
  const { base64urlEncode } = __modules["encoding.js"];
1844
- const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
2226
+ const { ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1845
2227
  const { utf8 } = __modules["encoding.js"];
1846
2228
  const { validate, without } = __modules["json.js"];
1847
2229
  const s = __modules["keys.js"];
@@ -1863,11 +2245,10 @@ function checkRecipient(r) {
1863
2245
  /** Make a proof ([PRF-1], [PRF-2]). `by` is "account" or "recovery"; `challenge` is 32 bytes. */
1864
2246
  async function prove(state, by, seed, challenge, recipient) {
1865
2247
  checkRecipient(recipient);
1866
- const pub = formatKey("ed25519", await ed25519Public(seed));
1867
2248
  const p = { v: 1, by, account: state.account, challenge: base64urlEncode(challenge), recipient };
1868
2249
  if (by === "account") {
1869
2250
  if (!state.instance) fail("no_instance");
1870
- if (state.instance.account_key !== pub) fail("wrong_account_key");
2251
+ if (!(await ed25519PublicIs(seed, s.parseKey(state.instance.account_key, "ed25519")))) fail("wrong_account_key");
1871
2252
  p.instance = state.instance.hash;
1872
2253
  } else {
1873
2254
  await checkRecoveryKey(state, seed);
@@ -2404,7 +2785,7 @@ __modules["vault.js"] = (() => {
2404
2785
 
2405
2786
  const { fail } = __modules["errors.js"];
2406
2787
  const { base64urlDecode, base64urlEncode, concat, utf8 } = __modules["encoding.js"];
2407
- const { aesGcmDecrypt, aesGcmEncrypt, ed25519Public, ed25519Sign, frameObject, hkdf, sha256, x25519, x25519Public } = __modules["crypto.js"];
2788
+ const { aesGcmDecrypt, aesGcmEncrypt, DRAW_ATTEMPTS, ed25519Public, ed25519PublicIs, ed25519Sign, frameObject, hkdf, sha256, usableSeed, x25519, x25519Public } = __modules["crypto.js"];
2408
2789
  const { argon2id } = __modules["argon2.js"];
2409
2790
  const { canonical, parseLimited } = __modules["json.js"];
2410
2791
  const s = __modules["keys.js"];
@@ -2529,9 +2910,13 @@ async function unlockVault(vault, state, secret) {
2529
2910
  const contents = checkContents(parseLimited(pt, CONTENTS_LIMIT));
2530
2911
  pt.fill(0);
2531
2912
  if (!state.instance) fail("no_instance");
2532
- if (formatKey("ed25519", await ed25519Public(contents.accountSeed)) !== state.instance.account_key) {
2913
+ if (!(await ed25519PublicIs(contents.accountSeed, s.parseKey(state.instance.account_key, "ed25519")))) {
2533
2914
  fail("wrong_account_key");
2534
2915
  }
2916
+ // Check the signing seed of the lock against its key on record, so that a platform that cannot import it signs
2917
+ // with it anyway ([IMP-6]); a mismatch is not this function's to judge (a login with it fails at the host).
2918
+ const signKey = lockId === "recovery" ? state.recovery : lock.value.auth;
2919
+ await ed25519PublicIs(lockSeed, s.parseKey(signKey, "ed25519"));
2535
2920
  return { vaultKey: vk, contents, lock: lockId, lockSeed, info };
2536
2921
  }
2537
2922
 
@@ -2560,13 +2945,29 @@ function kdfValue(kdf, salt) {
2560
2945
  return { alg: "argon2id", m: kdf.m, t: kdf.t, p: kdf.p, salt: base64urlEncode(salt) };
2561
2946
  }
2562
2947
 
2563
- /** Create a new password lock's public part. Draws: slot id (8), salt (16). Returns `[slot, lockPublic]`. */
2948
+ /** A new salt and its lock secrets, drawn again while the auth seed is unusable ([CRY-9]). → bad_key (broken generator) */
2949
+ async function newLockSecrets(account, lock, password, kdf, random) {
2950
+ for (let i = 0; i < DRAW_ATTEMPTS; i++) {
2951
+ const salt = random(16);
2952
+ const ls = await deriveLockSecrets(account, lock, password, kdf, salt);
2953
+ if (usableSeed(ls.authSeed)) return [salt, ls];
2954
+ ls.authSeed.fill(0);
2955
+ ls.boxSecret.fill(0);
2956
+ }
2957
+ fail("bad_key");
2958
+ }
2959
+
2960
+ /**
2961
+ * Create a new password lock's public part. `slot` is `{ label, password, username, kdf }`; the password must satisfy
2962
+ * [LCK-8] for `username` (`weak_password`), as in every function that creates a password lock. Draws: slot id (8),
2963
+ * salt (16, again while the auth seed is unusable, [CRY-9]). Returns `[slot, lockPublic]`.
2964
+ */
2564
2965
  async function newPasswordLock(account, slot, random) {
2966
+ checkPassword(slot.password, slot.username);
2565
2967
  checkLabel(slot.label);
2566
2968
  const id = base64urlEncode(random(8));
2567
- const salt = random(16);
2568
2969
  const kdf = { m: slot.kdf.m, t: slot.kdf.t, p: slot.kdf.p ?? 1 };
2569
- const ls = await deriveLockSecrets(account, id, slot.password, kdf, salt);
2970
+ const [salt, ls] = await newLockSecrets(account, id, slot.password, kdf, random);
2570
2971
  const pub = {
2571
2972
  id,
2572
2973
  kdf: kdfValue(kdf, salt),
@@ -2637,16 +3038,17 @@ async function updateContents(u, vault, contents, random) {
2637
3038
  }
2638
3039
 
2639
3040
  /**
2640
- * A password change ([CHG-1] step 1). Draws: salt (16), wrap (32 + 12), contents nonce (12).
3041
+ * A password change ([CHG-1] step 1); the new password must satisfy [LCK-8] for `username` (`weak_password`).
3042
+ * Draws: salt (16), wrap (32 + 12), contents nonce (12).
2641
3043
  * Returns `{ vault, new }` where `new` is `SHA-256(canonical(new lock))`.
2642
3044
  */
2643
- async function changePassword(u, vault, slot, password, kdf, random) {
3045
+ async function changePassword(u, vault, slot, password, username, kdf, random) {
3046
+ checkPassword(password, username);
2644
3047
  const { account, instance, grant } = idsOf(u, vault);
2645
3048
  const index = u.info.locks.findIndex((l) => l.kind === "password" && l.id === slot);
2646
3049
  if (index < 0) fail("bad_locks");
2647
- const salt = random(16);
2648
3050
  const k = { m: kdf.m, t: kdf.t, p: kdf.p ?? 1 };
2649
- const ls = await deriveLockSecrets(account, slot, password, k, salt);
3051
+ const [salt, ls] = await newLockSecrets(account, slot, password, k, random);
2650
3052
  const box = await x25519Public(ls.boxSecret);
2651
3053
  const pub = { id: slot, kdf: kdfValue(k, salt), auth: formatKey("ed25519", await ed25519Public(ls.authSeed)), box: formatKey("x25519", box) };
2652
3054
  ls.authSeed.fill(0);
@@ -2697,21 +3099,33 @@ const { base64urlEncode } = __modules["encoding.js"];
2697
3099
  const { inception } = __modules["id.js"];
2698
3100
  const { checkRecoveryKey, eventHash, pendingInstance, rotateRecoveryEvent, verifyLog } = __modules["log.js"];
2699
3101
  const { signPublic } = __modules["profile.js"];
2700
- const { deriveRecoveryKeys } = __modules["recovery.js"];
3102
+ const { checkNewRecoveryKey, deriveRecoveryKeys } = __modules["recovery.js"];
3103
+ const { DRAW_ATTEMPTS, usableSeed } = __modules["crypto.js"];
2701
3104
  const { recoveryCard } = __modules["file.js"];
2702
3105
  const { carriedLocks, checkPassword, instanceVault, regrant } = __modules["vault.js"];
2703
3106
 
3107
+ /** The account seed of a new instance: 32 random bytes, drawn again while unusable ([CRY-9]). → bad_key (broken generator) */
3108
+ function drawAccountSeed(random) {
3109
+ for (let i = 0; i < DRAW_ATTEMPTS; i++) {
3110
+ const seed = random(32);
3111
+ if (usableSeed(seed)) return seed;
3112
+ }
3113
+ fail("bad_key");
3114
+ }
3115
+
2704
3116
  /**
2705
- * Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]). `home` is `{ key, url }` with
2706
- * a key string. Draws: the account seed (32), then instanceVault with one new slot.
3117
+ * Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]; usable by [CRY-9], see
3118
+ * newRecoveryKey → bad_key). `home` is `{ key, url }` with a key string. Draws: the account seed (32, again while
3119
+ * unusable, [CRY-9]), then instanceVault with one new slot.
2707
3120
  * @returns {Promise<{ log, vault, public, card, account }>}
2708
3121
  */
2709
3122
  async function register(recoveryKey, realm, username, password, slotLabel, home, time, kdf, random) {
2710
3123
  checkPassword(password, username);
2711
3124
  const keys = await deriveRecoveryKeys(recoveryKey, realm, username);
3125
+ checkNewRecoveryKey(keys);
2712
3126
  const inc = await inception(keys, realm, username);
2713
3127
  const state = await verifyLog([inc], { realms: [realm] });
2714
- const seed = random(32);
3128
+ const seed = drawAccountSeed(random);
2715
3129
  const pending = await pendingInstance(state, keys.signSeed, seed, home, "register", time);
2716
3130
  const instance = base64urlEncode(await eventHash(pending));
2717
3131
  const vault = await instanceVault(
@@ -2721,7 +3135,7 @@ async function register(recoveryKey, realm, username, password, slotLabel, home,
2721
3135
  recovery: keys,
2722
3136
  contents: { accountSeed: seed, data: {} },
2723
3137
  keep: [],
2724
- add: { label: slotLabel, password, kdf },
3138
+ add: { label: slotLabel, password, username, kdf },
2725
3139
  },
2726
3140
  random,
2727
3141
  );
@@ -2736,12 +3150,19 @@ async function register(recoveryKey, realm, username, password, slotLabel, home,
2736
3150
  /**
2737
3151
  * Build a new instance on top of `state` ([KT-3]). `mode` is `{ restore: true }`, `{ revoke: [slot ids] }`,
2738
3152
  * `{ reset: slot }` or `{ fresh: slot }`, where a slot is `{ label, password, kdf }`. `old` is the current vault
2739
- * unlocked with the recovery lock (not needed for fresh). Draws: the account seed (32), then instanceVault.
3153
+ * unlocked with the recovery lock (not needed for fresh). Draws: the account seed (32, again while unusable,
3154
+ * [CRY-9]), then instanceVault.
2740
3155
  * @returns {Promise<{ event, vault, public }>}
2741
3156
  */
2742
3157
  async function newInstance(state, recovery, old, oldPublicData, mode, home, time, random) {
2743
3158
  await checkRecoveryKey(state, recovery.signSeed);
2744
- const seed = random(32);
3159
+ const slot = mode.reset ?? mode.fresh;
3160
+ if (slot) {
3161
+ // The new password is checked before anything is drawn ([LCK-8]); its username must be this identity's.
3162
+ if (slot.username !== state.username) fail("bad_field");
3163
+ checkPassword(slot.password, state.username);
3164
+ }
3165
+ const seed = drawAccountSeed(random);
2745
3166
  const reason = mode.restore ? "restore" : mode.fresh ? "fresh" : "revoke";
2746
3167
  const event = await pendingInstance(state, recovery.signSeed, seed, home, reason, time);
2747
3168
  const instance = base64urlEncode(await eventHash(event));
@@ -2768,11 +3189,12 @@ async function newInstance(state, recovery, old, oldPublicData, mode, home, time
2768
3189
  }
2769
3190
 
2770
3191
  /**
2771
- * Rotate the recovery key ([KT-5]) to `newKey`. `unlocked` is the current vault unlocked. Draws: regrant.
3192
+ * Rotate the recovery key ([KT-5]) to `newKey` (usable by [CRY-9] → bad_key; see newRecoveryKey). `unlocked` is the current vault unlocked. Draws: regrant.
2772
3193
  * @returns {Promise<{ event, vault, card }>}
2773
3194
  */
2774
3195
  async function rotateRecovery(state, log, oldKeys, newKey, oldVault, unlocked, time, random) {
2775
3196
  const next = await deriveRecoveryKeys(newKey, state.realm, state.username);
3197
+ checkNewRecoveryKey(next);
2776
3198
  const event = await rotateRecoveryEvent(state, oldKeys.signSeed, next.signSeed, time);
2777
3199
  const vault = await regrant(unlocked, oldVault, next, random);
2778
3200
  const card = await recoveryCard(state.realm, state.username, state.account, newKey, [...log, event]);
@@ -2838,6 +3260,11 @@ function show(text, ok = true) {
2838
3260
  $("out").textContent = text;
2839
3261
  }
2840
3262
 
3263
+ /** Show progress during an action: the status stays "Working…" until the action ends. */
3264
+ function progress(text) {
3265
+ $("out").textContent = text;
3266
+ }
3267
+
2841
3268
  function explain(e) {
2842
3269
  if (e instanceof rc.RootcardError) {
2843
3270
  const why = {
@@ -2856,8 +3283,9 @@ function explain(e) {
2856
3283
 
2857
3284
  async function run(f) {
2858
3285
  for (const b of document.querySelectorAll("button")) b.disabled = true;
2859
- $("status").className = "";
3286
+ $("status").className = "busy";
2860
3287
  $("status").textContent = "Working…";
3288
+ $("out").textContent = "";
2861
3289
  try {
2862
3290
  await rc.checkSupport();
2863
3291
  show(await f());
@@ -2865,6 +3293,8 @@ async function run(f) {
2865
3293
  show(explain(e), false);
2866
3294
  } finally {
2867
3295
  for (const b of document.querySelectorAll("button")) b.disabled = false;
3296
+ render();
3297
+ $("result").scrollIntoView({ block: "nearest" });
2868
3298
  }
2869
3299
  }
2870
3300
 
@@ -2884,17 +3314,33 @@ async function readFile(input) {
2884
3314
  }
2885
3315
 
2886
3316
  function checkUrl(url) {
2887
- if (!/^https:\/\//.test(url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(url)) throw new Error(`${url}: hosts are reached over https`);
3317
+ if (!/^https:\/\//.test(url) && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(url)) throw new Error(`${url}: hosts are reached over https`);
2888
3318
  return url.replace(/\/+$/, "");
2889
3319
  }
2890
3320
 
2891
- // A host address as the user typed it: trailing slashes removed, then checked as a base URL ([HST-22]).
2892
- function checkHost(url) {
2893
- const base = url.replace(/\/+$/, "");
3321
+ const LOCAL = /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/;
3322
+
3323
+ /**
3324
+ * A host address as the user typed it, made into a base URL: surrounding space and trailing slashes removed, and
3325
+ * `https://` added when no scheme is given (`http://` for this computer). The result is then checked ([HST-22]).
3326
+ */
3327
+ function normalizeHost(text) {
3328
+ let t = text.trim().replace(/\/+$/, "");
3329
+ if (t && !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(t)) t = (LOCAL.test(t) ? "http://" : "https://") + t;
3330
+ return t;
3331
+ }
3332
+
3333
+ function checkHost(text) {
3334
+ const base = normalizeHost(text);
3335
+ if (!base) throw new Error("Enter a host address, such as https://id.example.com.");
2894
3336
  try {
2895
3337
  rc.checkBaseUrl(base);
2896
3338
  } catch {
2897
- throw new Error(`${url}: not a host base URL (https://host[:port][/path], without /v0 at the end)`);
3339
+ let why = "use the form https://host, with an optional :port and /path";
3340
+ if (/\/[vV][0-9]+$/.test(base)) why = `leave out the /${rc.API_VERSION} at the end: enter the address without it`;
3341
+ else if (/^http:\/\//.test(base)) why = "hosts are reached over https:// (plain http:// only on this computer)";
3342
+ else if (/[?#]/.test(base)) why = "leave out the ? or # part";
3343
+ throw new Error(`"${text.trim()}" is not a host address: ${why}.`);
2898
3344
  }
2899
3345
  return base;
2900
3346
  }
@@ -2906,16 +3352,26 @@ function apiRoot(base) {
2906
3352
  }
2907
3353
 
2908
3354
  async function http(method, url, body) {
2909
- const res = await fetch(checkUrl(url), {
2910
- method,
2911
- headers: body === undefined ? {} : { "Content-Type": "application/json" },
2912
- body: body === undefined ? undefined : rc.canonicalString(body),
2913
- credentials: "omit",
2914
- referrerPolicy: "no-referrer",
2915
- cache: "no-store",
2916
- });
3355
+ let res;
3356
+ try {
3357
+ res = await fetch(checkUrl(url), {
3358
+ method,
3359
+ headers: body === undefined ? {} : { "Content-Type": "application/json" },
3360
+ body: body === undefined ? undefined : rc.canonicalString(body),
3361
+ credentials: "omit",
3362
+ referrerPolicy: "no-referrer",
3363
+ cache: "no-store",
3364
+ });
3365
+ } catch {
3366
+ throw new Error(`${url}: the host could not be reached. Check the address and the network connection.`);
3367
+ }
2917
3368
  const text = await res.text();
2918
- const value = text ? rc.parseJson(text) : true;
3369
+ let value;
3370
+ try {
3371
+ value = text ? rc.parseJson(text) : true;
3372
+ } catch {
3373
+ throw new Error(`${url}: HTTP ${res.status}, and the answer is not a rootcard host's. Check the address.`);
3374
+ }
2919
3375
  if (!res.ok) {
2920
3376
  const code = value?.error;
2921
3377
  if (code && rc.ERROR_CODES.includes(code)) throw new rc.RootcardError(code);
@@ -3012,8 +3468,9 @@ async function hostHome(base) {
3012
3468
 
3013
3469
  function newSlot() {
3014
3470
  const password = $("newPassword").value;
3015
- rc.checkPassword(password, current.state.username);
3016
- return { label: $("label").value.trim() || "Password", password, kdf: rc.KDF_DEFAULT };
3471
+ const username = current.state.username;
3472
+ rc.checkPassword(password, username);
3473
+ return { label: $("label").value.trim() || "Password", password, username, kdf: rc.KDF_DEFAULT };
3017
3474
  }
3018
3475
 
3019
3476
  async function submitInstance(base, mode, old) {
@@ -3042,6 +3499,10 @@ async function submitInstance(base, mode, old) {
3042
3499
  current.state = state;
3043
3500
  current.vault = ni.vault;
3044
3501
  current.public = ni.public;
3502
+ current.vaultError = undefined;
3503
+ try {
3504
+ refreshSlots(await rc.verifyVault(ni.vault, state));
3505
+ } catch {}
3045
3506
  download(rc.accountFileName(state), rc.exportAccount(state, v.events, ni.public, ni.vault));
3046
3507
  return describe(state) + "\n\nThe new account file was downloaded." + note;
3047
3508
  }
@@ -3059,10 +3520,10 @@ async function challengeOf(text) {
3059
3520
  const ops = {
3060
3521
  async restore() {
3061
3522
  const { u } = await currentVault();
3062
- return submitInstance(checkHost($("targetHost").value.trim()), { restore: true }, u);
3523
+ return submitInstance(checkHost($("targetHost").value), { restore: true }, u);
3063
3524
  },
3064
3525
  async fresh() {
3065
- return submitInstance(checkHost($("targetHost").value.trim()), { fresh: newSlot() }, undefined);
3526
+ return submitInstance(checkHost($("targetHost").value), { fresh: newSlot() }, undefined);
3066
3527
  },
3067
3528
  async reset() {
3068
3529
  const { u } = await currentVault();
@@ -3085,14 +3546,14 @@ const ops = {
3085
3546
  },
3086
3547
  async rotate() {
3087
3548
  const { vault, u } = await currentVault();
3088
- const newKey = random(32);
3549
+ const newKey = await rc.newRecoveryKey(current.state.realm, current.state.username, random);
3089
3550
  const r = await rc.rotateRecovery(current.state, current.log, current.keys, newKey, vault, u, now(), random);
3090
3551
  newKey.fill(0);
3091
3552
  // The new card is saved and shown before the rotation is sent: once the host has the event, the old key no longer
3092
3553
  // signs and the new card is the only copy of the new key ([KT-5]).
3093
3554
  download(`${current.state.username}-new.card.json`, r.card);
3094
3555
  const text = await rc.cardText(r.card);
3095
- show(text + "\nSending the rotation… Keep this card.", true);
3556
+ progress(text + "\nSending the rotation… Keep this card.");
3096
3557
  try {
3097
3558
  await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3098
3559
  } catch (e) {
@@ -3103,15 +3564,12 @@ const ops = {
3103
3564
  );
3104
3565
  }
3105
3566
  current = null;
3106
- $("actions").hidden = true;
3107
- return text + "\nThe new card was downloaded. Destroy the old card, then load the new one.";
3567
+ return text + "\nThe new card was downloaded. Destroy the old card, then open the identity with the new one.";
3108
3568
  },
3109
3569
  async retire() {
3110
3570
  if ($("retireConfirm").value !== current.state.username) throw new Error("Type the username to confirm.");
3111
3571
  const ev = await rc.retireEvent(current.state, current.keys.signSeed, now());
3112
- const base = current.state.instance?.home.url ?? hostList()[0];
3113
- if (!base) throw new Error("Enter a host.");
3114
- await http("POST", `${apiRoot(base)}/id/${current.state.account}/events`, { events: [ev] });
3572
+ await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [ev] });
3115
3573
  return `${current.state.handle} is retired.`;
3116
3574
  },
3117
3575
  async card() {
@@ -3123,35 +3581,291 @@ const ops = {
3123
3581
  },
3124
3582
  };
3125
3583
 
3584
+ // ---- The page: what each action needs, and whether the loaded identity has it ----
3585
+
3586
+ /** Where an action sends something: "target" (the host typed for it), "home" (the primary host) or nowhere. */
3587
+ const ACTIONS = {
3588
+ fresh: {
3589
+ title: "Start at a host",
3590
+ where: "target",
3591
+ text: "Puts the identity on a host with a new, empty account: a new vault with the password below, and an empty " +
3592
+ "profile. Use it for an identity that only exists on its card, or to start over. Data in an earlier account is " +
3593
+ "not carried over (use “Move to another host” for that).",
3594
+ host: "Host to start at",
3595
+ },
3596
+ restore: {
3597
+ title: "Move to another host",
3598
+ where: "target",
3599
+ text: "Makes a new account at the host you enter, carrying over data, profile and passwords from the current " +
3600
+ "vault. Use it to move, or to restore after a host is gone. The host may also be the current one.",
3601
+ host: "Host to move to",
3602
+ },
3603
+ reset: {
3604
+ title: "Reset a forgotten password",
3605
+ where: "home",
3606
+ text: "Starts a new account at the same host with one new password. Data and profile carry over; every old " +
3607
+ "password, vault copy and session stops working.",
3608
+ },
3609
+ add: { title: "Add a password", where: "home", text: "Adds another password to the vault. The others keep working." },
3610
+ revoke: {
3611
+ title: "Revoke a password",
3612
+ where: "home",
3613
+ text: "Starts a new account at the same host without the chosen password. The other passwords keep working, and " +
3614
+ "sessions signed in with the revoked one end.",
3615
+ },
3616
+ rotate: {
3617
+ title: "Rotate the recovery key",
3618
+ where: "home",
3619
+ text: "Replaces the recovery key. The new card is downloaded before anything is sent; keep it, and destroy the old " +
3620
+ "card once the rotation is done.",
3621
+ },
3622
+ card: { title: "Show the card", where: null, text: "Shows the recovery card as text, to print or copy onto paper." },
3623
+ prove: {
3624
+ title: "Make a root proof",
3625
+ where: null,
3626
+ text: "Signs a statement with the recovery key, so that anyone with your log can check that it came from you.",
3627
+ },
3628
+ retire: {
3629
+ title: "Retire the identity",
3630
+ where: "home",
3631
+ text: "Closes the identity for good: no host will accept anything for it again, and its handle stays taken. This " +
3632
+ "cannot be undone.",
3633
+ },
3634
+ };
3635
+
3636
+ const NO_HOST =
3637
+ "The identity is not at any host that this page knows of. If it is at one, enter that host's address under " +
3638
+ "“Host address” above (or load the account file) and open the identity again. Otherwise start it at a host first.";
3639
+
3640
+ /** Why `op` cannot be done for the loaded identity now, or undefined. */
3641
+ function blocked(op) {
3642
+ const st = current.state;
3643
+ if (st.retired) return op === "card" ? undefined : "The identity is retired. Nothing can change it any more.";
3644
+ if (op === "fresh" || op === "card" || op === "prove") return undefined;
3645
+ const home = st.instance?.home.url;
3646
+ if (!home) return NO_HOST;
3647
+ if (op === "retire") return undefined;
3648
+ if (current.vaultError) {
3649
+ return `This needs the vault, and it could not be opened: ${current.vaultError}. Load the account file, or check ` +
3650
+ "that the host can be reached.";
3651
+ }
3652
+ return undefined;
3653
+ }
3654
+
3655
+ let picked = null;
3656
+
3657
+ function splitHandle(handle) {
3658
+ const i = handle.lastIndexOf("#");
3659
+ return i < 0 ? [handle, ""] : [handle.slice(0, i), handle.slice(i)];
3660
+ }
3661
+
3662
+ function badge(text, kind) {
3663
+ const b = document.createElement("span");
3664
+ b.className = `badge ${kind}`;
3665
+ const dot = document.createElement("span");
3666
+ dot.className = "dot";
3667
+ b.append(dot, text);
3668
+ return b;
3669
+ }
3670
+
3671
+ /** Draw the loaded identity and which actions it allows. */
3672
+ function render() {
3673
+ $("actions").hidden = !current;
3674
+ if (!current) return;
3675
+ const st = current.state;
3676
+ const home = st.retired ? undefined : st.instance?.home.url;
3677
+
3678
+ const [name, tag] = splitHandle(st.handle);
3679
+ const h = $("icHandle");
3680
+ const t = document.createElement("span");
3681
+ t.className = "tag";
3682
+ t.textContent = tag;
3683
+ h.replaceChildren(name, t);
3684
+ $("icAccount").textContent = st.account;
3685
+ $("icKind").textContent = st.retired ? "Retired" : home ? "Identity" : "Card only";
3686
+ $("idcard").className = `idcard${st.retired ? " retired" : home ? "" : " local"}`;
3687
+
3688
+ const badges = [];
3689
+ if (st.retired) badges.push(badge("Retired", "bad"));
3690
+ else if (home) badges.push(badge("At a host", "ok"));
3691
+ else badges.push(badge("Not at any host", "warn"));
3692
+ if (home) badges.push(current.vaultError ? badge("Vault not available", "warn") : badge("Vault open", "teal"));
3693
+ $("badges").replaceChildren(...badges);
3694
+
3695
+ const facts = [
3696
+ ["Realm", st.realm],
3697
+ ["Host", home ?? (st.retired ? "none (retired)" : "none")],
3698
+ ["Version", `seq ${st.version.seq}`],
3699
+ ];
3700
+ $("facts").replaceChildren(
3701
+ ...facts.flatMap(([k, v]) => {
3702
+ const dt = document.createElement("dt");
3703
+ dt.textContent = k;
3704
+ const dd = document.createElement("dd");
3705
+ dd.textContent = v;
3706
+ return [dt, dd];
3707
+ }),
3708
+ );
3709
+
3710
+ const note = $("modeNote");
3711
+ if (st.retired) {
3712
+ note.className = "notice error";
3713
+ note.textContent = "This identity is retired. You can still show its card.";
3714
+ } else if (!home) {
3715
+ note.className = "notice warn";
3716
+ note.textContent =
3717
+ "This identity exists only on its card: no host stores it, so there is no account to sign in to yet. Choose " +
3718
+ "“Start at a host” to put it on one. Showing the card and making root proofs work offline. If the identity " +
3719
+ "already is at a host, enter that host's address above and open it again.";
3720
+ } else if (current.vaultError) {
3721
+ note.className = "notice warn";
3722
+ note.textContent = `The identity is at ${home}, but its vault could not be opened (${current.vaultError}). ` +
3723
+ "Actions that need the vault are unavailable; load the account file or try again when the host can be reached.";
3724
+ } else {
3725
+ note.className = "notice ok";
3726
+ note.textContent = `The identity is at ${home}. Changes are sent there, and after each one that makes a new ` +
3727
+ "account the new account file is downloaded.";
3728
+ }
3729
+
3730
+ for (const tile of document.querySelectorAll("[data-pick]")) {
3731
+ const why = blocked(tile.dataset.pick);
3732
+ tile.classList.toggle("unavailable", Boolean(why));
3733
+ let req = tile.querySelector(".req");
3734
+ if (!req) {
3735
+ req = document.createElement("span");
3736
+ tile.append(req);
3737
+ }
3738
+ const where = ACTIONS[tile.dataset.pick].where;
3739
+ req.className = `req badge ${why ? "warn" : where ? "teal" : "ok"}`;
3740
+ req.textContent = why ? (where && !home ? "Needs a host" : "Unavailable") : where ? "Uses a host" : "Offline";
3741
+ tile.setAttribute("aria-pressed", String(tile.dataset.pick === picked));
3742
+ }
3743
+ $("opform").hidden = !picked;
3744
+ if (!picked) return;
3745
+ const a = ACTIONS[picked];
3746
+ const why = blocked(picked);
3747
+ $("opTitle").textContent = a.title;
3748
+ $("opText").textContent = a.text;
3749
+ const where = $("opWhere");
3750
+ if (a.where === "home") {
3751
+ where.className = "badge teal";
3752
+ where.textContent = home ? `Sends to ${home}` : "Needs a host";
3753
+ } else if (a.where === "target") {
3754
+ where.className = "badge teal";
3755
+ where.textContent = "Sends to the host you enter";
3756
+ } else {
3757
+ where.className = "badge ok";
3758
+ where.textContent = "Offline";
3759
+ }
3760
+ $("opBlocked").hidden = !why;
3761
+ $("opBlocked").textContent = why ?? "";
3762
+ if (a.host) $("targetHostLabel").textContent = a.host;
3763
+ for (const el of document.querySelectorAll("#opform [data-for]")) {
3764
+ el.hidden = !el.dataset.for.split(" ").includes(picked);
3765
+ }
3766
+ for (const b of document.querySelectorAll("#opform [data-op]")) b.disabled = Boolean(blocked(b.dataset.op));
3767
+ }
3768
+
3769
+ /** Check a host address field while it is typed, and say what will be contacted. */
3770
+ function watchHost(input) {
3771
+ const hint = $(`${input.id}Check`);
3772
+ const idle = hint.textContent;
3773
+ const check = () => {
3774
+ const parts = input.dataset.host === "list" ? input.value.split(",") : [input.value];
3775
+ const typed = parts.map((s) => s.trim()).filter(Boolean);
3776
+ if (typed.length === 0) {
3777
+ hint.className = "hint";
3778
+ hint.textContent = idle;
3779
+ input.removeAttribute("aria-invalid");
3780
+ return;
3781
+ }
3782
+ try {
3783
+ const bases = typed.map(checkHost);
3784
+ hint.className = "hint good";
3785
+ hint.textContent = `✓ Will contact ${bases.join(", ")}`;
3786
+ input.removeAttribute("aria-invalid");
3787
+ } catch (e) {
3788
+ hint.className = "hint bad";
3789
+ hint.textContent = explain(e);
3790
+ input.setAttribute("aria-invalid", "true");
3791
+ }
3792
+ };
3793
+ input.addEventListener("input", check);
3794
+ check();
3795
+ }
3796
+
3797
+ for (const input of document.querySelectorAll("[data-host]")) watchHost(input);
3798
+
3799
+ for (const tab of document.querySelectorAll("[role=tab]")) {
3800
+ tab.addEventListener("click", () => {
3801
+ for (const t of document.querySelectorAll("[role=tab]")) {
3802
+ const on = t === tab;
3803
+ t.setAttribute("aria-selected", String(on));
3804
+ $(t.getAttribute("aria-controls")).hidden = !on;
3805
+ }
3806
+ });
3807
+ }
3808
+
3809
+ function createMode() {
3810
+ return $("nLocal").checked ? "local" : "host";
3811
+ }
3812
+ for (const r of document.querySelectorAll("[name=nWhere]")) {
3813
+ r.addEventListener("change", () => {
3814
+ for (const el of document.querySelectorAll("[data-when]")) el.hidden = el.dataset.when !== createMode();
3815
+ });
3816
+ }
3817
+
3818
+ for (const tile of document.querySelectorAll("[data-pick]")) {
3819
+ tile.addEventListener("click", () => {
3820
+ picked = tile.dataset.pick;
3821
+ render();
3822
+ $("opform").scrollIntoView({ block: "nearest" });
3823
+ });
3824
+ }
3825
+
3126
3826
  $("version").textContent = ROOTCARD_VERSION;
3127
3827
  $("load").addEventListener("click", () =>
3128
3828
  run(async () => {
3829
+ current = null;
3830
+ picked = null;
3129
3831
  const notes = await gather();
3130
- $("actions").hidden = false;
3131
3832
  let extra = "";
3132
- try {
3133
- const { u } = await currentVault();
3134
- refreshSlots(u.info);
3135
- } catch (e) {
3136
- extra = `\n(Vault not available: ${explain(e)})`;
3833
+ if (current.state.instance && !current.state.retired) {
3834
+ try {
3835
+ const { u } = await currentVault();
3836
+ refreshSlots(u.info);
3837
+ } catch (e) {
3838
+ current.vaultError = explain(e);
3839
+ extra = `\n(Vault not available: ${current.vaultError})`;
3840
+ }
3841
+ } else {
3842
+ $("slot").replaceChildren();
3137
3843
  }
3138
3844
  return describe(current.state) + extra + (notes.length ? "\n\n" + notes.join("\n") : "");
3139
3845
  }),
3140
3846
  );
3141
3847
  for (const b of document.querySelectorAll("[data-op]")) {
3142
- b.addEventListener("click", () => run(() => (current ? ops[b.dataset.op]() : Promise.reject(new Error("Load the identity first.")))));
3848
+ b.addEventListener("click", () =>
3849
+ run(() => {
3850
+ if (!current) throw new Error("Open the identity first.");
3851
+ const why = blocked(b.dataset.op);
3852
+ if (why) throw new Error(why);
3853
+ return ops[b.dataset.op]();
3854
+ }),
3855
+ );
3143
3856
  }
3144
3857
  $("create").addEventListener("click", () =>
3145
3858
  run(async () => {
3146
3859
  const realm = $("nRealm").value.trim();
3147
3860
  const username = $("nUser").value.trim();
3148
- const rk = random(32);
3149
- const base = $("nHost").value.trim();
3861
+ const base = createMode() === "host" ? checkHost($("nHost").value) : "";
3862
+ if (base) rc.checkPassword($("nPassword").value, username);
3863
+ const rk = await rc.newRecoveryKey(realm, username, random); // usable in every browser ([CRY-9])
3150
3864
  let card;
3151
3865
  let file;
3152
3866
  let state;
3153
3867
  if (base) {
3154
- const home = await hostHome(checkHost(base));
3868
+ const home = await hostHome(base);
3155
3869
  const reg = await rc.register(rk, realm, username, $("nPassword").value, "Password", home, now(), rc.KDF_DEFAULT, random);
3156
3870
  rk.fill(0);
3157
3871
  // The card is saved before anything is sent: once the host has the identity, the card is the only copy of its
@@ -3172,7 +3886,12 @@ $("create").addEventListener("click", () =>
3172
3886
  rk.fill(0);
3173
3887
  if (!base) download(`${username}-${rc.tag(state.account)}.card.json`, card);
3174
3888
  download(rc.accountFileName(state), file);
3175
- return (await rc.cardText(card)) + "\n" + describe(state) + "\n\nThe card and the account file were downloaded. Keep the card offline.";
3889
+ if (base) $("hosts").value = base;
3890
+ $("hosts").dispatchEvent(new Event("input"));
3891
+ const next = base
3892
+ ? `To manage it, choose “Open my identity”, load the card (the host ${base} is already filled in).`
3893
+ : "No host has it yet. To put it on one, choose “Open my identity”, load the card, then “Start at a host”.";
3894
+ return (await rc.cardText(card)) + "\n" + describe(state) + "\n\nThe card and the account file were downloaded. Keep the card offline.\n" + next;
3176
3895
  }),
3177
3896
  );
3178
3897
  $("verify").addEventListener("click", () =>