rootcard 0.2.0 → 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.0";
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]).
@@ -936,59 +1291,6 @@ function versionRef(v) {
936
1291
 
937
1292
  return { formatKey, parseKey, object, version, members, get, string, int, intIn, bool, member, array, bytes, key, sig, account, checkAccountId, versionRef };
938
1293
  })();
939
- __modules["recovery.js"] = (() => {
940
- // The recovery key (SPEC §6).
941
-
942
- const { fail } = __modules["errors.js"];
943
- const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
944
- const { ed25519Public, frame, frameObject, hkdf, sha256 } = __modules["crypto.js"];
945
- const { label } = __modules["labels.js"];
946
-
947
- async function checksum(key) {
948
- return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
949
- }
950
-
951
- /** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
952
- async function formatRecoveryKey(key) {
953
- const body = base32Encode(key) + (await checksum(key));
954
- const groups = [];
955
- for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
956
- return "RCR-" + groups.join("-");
957
- }
958
-
959
- /** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
960
- async function parseRecoveryKey(text) {
961
- let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
962
- if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
963
- if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
964
- const n = base32Normalize(s);
965
- for (const c of n) if (!B32.includes(c)) fail("bad_base32");
966
- const key = base32Decode(n.slice(0, 52), 32);
967
- if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
968
- return key;
969
- }
970
-
971
- /**
972
- * Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
973
- * @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
974
- */
975
- async function deriveRecoveryKeys(rk, realm, username) {
976
- const ctx = { realm, username };
977
- return {
978
- signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
979
- boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
980
- };
981
- }
982
-
983
- /** The public recovery signing key. */
984
- async function recoverySignPublic(keys) {
985
- return ed25519Public(keys.signSeed);
986
- }
987
-
988
-
989
-
990
- return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, recoverySignPublic };
991
- })();
992
1294
  __modules["id.js"] = (() => {
993
1295
  // Realm, username, inception, account id (SPEC §7), handles and tags (SPEC §8).
994
1296
 
@@ -1106,7 +1408,7 @@ __modules["log.js"] = (() => {
1106
1408
 
1107
1409
  const { fail } = __modules["errors.js"];
1108
1410
  const { base64urlEncode, base64urlDecode, bytesEqual } = __modules["encoding.js"];
1109
- const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1411
+ const { ed25519Public, ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1110
1412
  const { canonical, without, isObject, MAX_SAFE } = __modules["json.js"];
1111
1413
  const s = __modules["keys.js"];
1112
1414
  const { formatKey } = __modules["keys.js"];
@@ -1378,7 +1680,7 @@ async function addReceipt(e, hostSeed) {
1378
1680
 
1379
1681
  /** [RK-5] → wrong_recovery_key */
1380
1682
  async function checkRecoveryKey(state, seed) {
1381
- 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");
1382
1684
  }
1383
1685
 
1384
1686
  /** A pending instance ([LOG-9]). `home` is `{ key: "ed25519:…", url }`. */
@@ -1420,6 +1722,139 @@ function hashBytes(h) {
1420
1722
 
1421
1723
  return { MAX_EVENTS, EVENT_LIMIT, FUTURE_WINDOW, eventMessage, receiptMessage, eventHash, checkUrl, verifyLog, extendLog, verifyPending, compareLogs, unsignedEvent, signEvent, addReceipt, checkRecoveryKey, pendingInstance, rotateRecoveryEvent, retireEvent, hashBytes };
1422
1724
  })();
1725
+ __modules["binding.js"] = (() => {
1726
+ // The HTTP binding's version and base URLs (HOST_PROTOCOL.md §1, §1.3; SPEC [HST-22]). The core makes no requests;
1727
+ // these let hosts, clients and the key tool agree on where endpoints live. Mirrors `rootcard::binding`.
1728
+
1729
+ const { fail } = __modules["errors.js"];
1730
+ const { checkUrl } = __modules["log.js"];
1731
+
1732
+ /**
1733
+ * The version segment of the HTTP binding: every endpoint is under `<base URL>/v0` until 1.0.0, then under `/v1`.
1734
+ * It is never part of a base URL, `home.url` or anything signed.
1735
+ */
1736
+ const API_VERSION = "v0";
1737
+
1738
+ /** Whether a path segment has the form of an API version (`v0`, `V12`). */
1739
+ function isVersionSegment(segment) {
1740
+ return /^[vV][0-9]+$/.test(segment);
1741
+ }
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
+
1748
+ function authorityHost(rest) {
1749
+ const authority = rest.split("/")[0];
1750
+ if (authority.startsWith("[")) {
1751
+ const i = authority.indexOf("]");
1752
+ return i < 0 ? "" : authority.slice(0, i + 1);
1753
+ }
1754
+ return authority.split(":")[0];
1755
+ }
1756
+
1757
+ /**
1758
+ * Check a base URL ([HST-22]): `https://` and a host, an optional port and an optional path without a trailing slash,
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`
1761
+ */
1762
+ function checkBaseUrl(url) {
1763
+ checkUrl(url);
1764
+ let rest;
1765
+ if (url.startsWith("https://")) rest = url.slice(8);
1766
+ else if (url.startsWith("http://")) {
1767
+ rest = url.slice(7);
1768
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(authorityHost(rest))) fail("bad_field");
1769
+ } else fail("bad_field");
1770
+ if (/[?#@\\]/.test(rest)) fail("bad_field");
1771
+ const slash = rest.indexOf("/");
1772
+ const authority = slash < 0 ? rest : rest.slice(0, slash);
1773
+ if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
1774
+ if (slash >= 0) {
1775
+ const segments = rest.slice(slash + 1).split("/");
1776
+ if (!segments.every(isPathSegment)) fail("bad_field");
1777
+ if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
1778
+ }
1779
+ }
1780
+
1781
+ return { API_VERSION, isVersionSegment, checkBaseUrl };
1782
+ })();
1783
+ __modules["recovery.js"] = (() => {
1784
+ // The recovery key (SPEC §6).
1785
+
1786
+ const { fail } = __modules["errors.js"];
1787
+ const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
1788
+ const { DRAW_ATTEMPTS, ed25519Public, frame, frameObject, hkdf, sha256, usableSeed } = __modules["crypto.js"];
1789
+ const { label } = __modules["labels.js"];
1790
+
1791
+ async function checksum(key) {
1792
+ return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
1793
+ }
1794
+
1795
+ /** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
1796
+ async function formatRecoveryKey(key) {
1797
+ const body = base32Encode(key) + (await checksum(key));
1798
+ const groups = [];
1799
+ for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
1800
+ return "RCR-" + groups.join("-");
1801
+ }
1802
+
1803
+ /** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
1804
+ async function parseRecoveryKey(text) {
1805
+ let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
1806
+ if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
1807
+ if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
1808
+ const n = base32Normalize(s);
1809
+ for (const c of n) if (!B32.includes(c)) fail("bad_base32");
1810
+ const key = base32Decode(n.slice(0, 52), 32);
1811
+ if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
1812
+ return key;
1813
+ }
1814
+
1815
+ /**
1816
+ * Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
1817
+ * @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
1818
+ */
1819
+ async function deriveRecoveryKeys(rk, realm, username) {
1820
+ const ctx = { realm, username };
1821
+ return {
1822
+ signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
1823
+ boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
1824
+ };
1825
+ }
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
+
1849
+ /** The public recovery signing key. */
1850
+ async function recoverySignPublic(keys) {
1851
+ return ed25519Public(keys.signSeed);
1852
+ }
1853
+
1854
+
1855
+
1856
+ return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, newRecoveryKey, checkNewRecoveryKey, recoverySignPublic };
1857
+ })();
1423
1858
  __modules["vaultcheck.js"] = (() => {
1424
1859
  // The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
1425
1860
 
@@ -1788,7 +2223,7 @@ __modules["proof.js"] = (() => {
1788
2223
 
1789
2224
  const { fail } = __modules["errors.js"];
1790
2225
  const { base64urlEncode } = __modules["encoding.js"];
1791
- const { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
2226
+ const { ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } = __modules["crypto.js"];
1792
2227
  const { utf8 } = __modules["encoding.js"];
1793
2228
  const { validate, without } = __modules["json.js"];
1794
2229
  const s = __modules["keys.js"];
@@ -1810,11 +2245,10 @@ function checkRecipient(r) {
1810
2245
  /** Make a proof ([PRF-1], [PRF-2]). `by` is "account" or "recovery"; `challenge` is 32 bytes. */
1811
2246
  async function prove(state, by, seed, challenge, recipient) {
1812
2247
  checkRecipient(recipient);
1813
- const pub = formatKey("ed25519", await ed25519Public(seed));
1814
2248
  const p = { v: 1, by, account: state.account, challenge: base64urlEncode(challenge), recipient };
1815
2249
  if (by === "account") {
1816
2250
  if (!state.instance) fail("no_instance");
1817
- 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");
1818
2252
  p.instance = state.instance.hash;
1819
2253
  } else {
1820
2254
  await checkRecoveryKey(state, seed);
@@ -2351,7 +2785,7 @@ __modules["vault.js"] = (() => {
2351
2785
 
2352
2786
  const { fail } = __modules["errors.js"];
2353
2787
  const { base64urlDecode, base64urlEncode, concat, utf8 } = __modules["encoding.js"];
2354
- 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"];
2355
2789
  const { argon2id } = __modules["argon2.js"];
2356
2790
  const { canonical, parseLimited } = __modules["json.js"];
2357
2791
  const s = __modules["keys.js"];
@@ -2476,9 +2910,13 @@ async function unlockVault(vault, state, secret) {
2476
2910
  const contents = checkContents(parseLimited(pt, CONTENTS_LIMIT));
2477
2911
  pt.fill(0);
2478
2912
  if (!state.instance) fail("no_instance");
2479
- if (formatKey("ed25519", await ed25519Public(contents.accountSeed)) !== state.instance.account_key) {
2913
+ if (!(await ed25519PublicIs(contents.accountSeed, s.parseKey(state.instance.account_key, "ed25519")))) {
2480
2914
  fail("wrong_account_key");
2481
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"));
2482
2920
  return { vaultKey: vk, contents, lock: lockId, lockSeed, info };
2483
2921
  }
2484
2922
 
@@ -2507,13 +2945,29 @@ function kdfValue(kdf, salt) {
2507
2945
  return { alg: "argon2id", m: kdf.m, t: kdf.t, p: kdf.p, salt: base64urlEncode(salt) };
2508
2946
  }
2509
2947
 
2510
- /** 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
+ */
2511
2965
  async function newPasswordLock(account, slot, random) {
2966
+ checkPassword(slot.password, slot.username);
2512
2967
  checkLabel(slot.label);
2513
2968
  const id = base64urlEncode(random(8));
2514
- const salt = random(16);
2515
2969
  const kdf = { m: slot.kdf.m, t: slot.kdf.t, p: slot.kdf.p ?? 1 };
2516
- const ls = await deriveLockSecrets(account, id, slot.password, kdf, salt);
2970
+ const [salt, ls] = await newLockSecrets(account, id, slot.password, kdf, random);
2517
2971
  const pub = {
2518
2972
  id,
2519
2973
  kdf: kdfValue(kdf, salt),
@@ -2584,16 +3038,17 @@ async function updateContents(u, vault, contents, random) {
2584
3038
  }
2585
3039
 
2586
3040
  /**
2587
- * 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).
2588
3043
  * Returns `{ vault, new }` where `new` is `SHA-256(canonical(new lock))`.
2589
3044
  */
2590
- async function changePassword(u, vault, slot, password, kdf, random) {
3045
+ async function changePassword(u, vault, slot, password, username, kdf, random) {
3046
+ checkPassword(password, username);
2591
3047
  const { account, instance, grant } = idsOf(u, vault);
2592
3048
  const index = u.info.locks.findIndex((l) => l.kind === "password" && l.id === slot);
2593
3049
  if (index < 0) fail("bad_locks");
2594
- const salt = random(16);
2595
3050
  const k = { m: kdf.m, t: kdf.t, p: kdf.p ?? 1 };
2596
- const ls = await deriveLockSecrets(account, slot, password, k, salt);
3051
+ const [salt, ls] = await newLockSecrets(account, slot, password, k, random);
2597
3052
  const box = await x25519Public(ls.boxSecret);
2598
3053
  const pub = { id: slot, kdf: kdfValue(k, salt), auth: formatKey("ed25519", await ed25519Public(ls.authSeed)), box: formatKey("x25519", box) };
2599
3054
  ls.authSeed.fill(0);
@@ -2644,21 +3099,33 @@ const { base64urlEncode } = __modules["encoding.js"];
2644
3099
  const { inception } = __modules["id.js"];
2645
3100
  const { checkRecoveryKey, eventHash, pendingInstance, rotateRecoveryEvent, verifyLog } = __modules["log.js"];
2646
3101
  const { signPublic } = __modules["profile.js"];
2647
- const { deriveRecoveryKeys } = __modules["recovery.js"];
3102
+ const { checkNewRecoveryKey, deriveRecoveryKeys } = __modules["recovery.js"];
3103
+ const { DRAW_ATTEMPTS, usableSeed } = __modules["crypto.js"];
2648
3104
  const { recoveryCard } = __modules["file.js"];
2649
3105
  const { carriedLocks, checkPassword, instanceVault, regrant } = __modules["vault.js"];
2650
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
+
2651
3116
  /**
2652
- * Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]). `home` is `{ key, url }` with
2653
- * 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.
2654
3120
  * @returns {Promise<{ log, vault, public, card, account }>}
2655
3121
  */
2656
3122
  async function register(recoveryKey, realm, username, password, slotLabel, home, time, kdf, random) {
2657
3123
  checkPassword(password, username);
2658
3124
  const keys = await deriveRecoveryKeys(recoveryKey, realm, username);
3125
+ checkNewRecoveryKey(keys);
2659
3126
  const inc = await inception(keys, realm, username);
2660
3127
  const state = await verifyLog([inc], { realms: [realm] });
2661
- const seed = random(32);
3128
+ const seed = drawAccountSeed(random);
2662
3129
  const pending = await pendingInstance(state, keys.signSeed, seed, home, "register", time);
2663
3130
  const instance = base64urlEncode(await eventHash(pending));
2664
3131
  const vault = await instanceVault(
@@ -2668,7 +3135,7 @@ async function register(recoveryKey, realm, username, password, slotLabel, home,
2668
3135
  recovery: keys,
2669
3136
  contents: { accountSeed: seed, data: {} },
2670
3137
  keep: [],
2671
- add: { label: slotLabel, password, kdf },
3138
+ add: { label: slotLabel, password, username, kdf },
2672
3139
  },
2673
3140
  random,
2674
3141
  );
@@ -2683,12 +3150,19 @@ async function register(recoveryKey, realm, username, password, slotLabel, home,
2683
3150
  /**
2684
3151
  * Build a new instance on top of `state` ([KT-3]). `mode` is `{ restore: true }`, `{ revoke: [slot ids] }`,
2685
3152
  * `{ reset: slot }` or `{ fresh: slot }`, where a slot is `{ label, password, kdf }`. `old` is the current vault
2686
- * 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.
2687
3155
  * @returns {Promise<{ event, vault, public }>}
2688
3156
  */
2689
3157
  async function newInstance(state, recovery, old, oldPublicData, mode, home, time, random) {
2690
3158
  await checkRecoveryKey(state, recovery.signSeed);
2691
- 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);
2692
3166
  const reason = mode.restore ? "restore" : mode.fresh ? "fresh" : "revoke";
2693
3167
  const event = await pendingInstance(state, recovery.signSeed, seed, home, reason, time);
2694
3168
  const instance = base64urlEncode(await eventHash(event));
@@ -2715,11 +3189,12 @@ async function newInstance(state, recovery, old, oldPublicData, mode, home, time
2715
3189
  }
2716
3190
 
2717
3191
  /**
2718
- * 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.
2719
3193
  * @returns {Promise<{ event, vault, card }>}
2720
3194
  */
2721
3195
  async function rotateRecovery(state, log, oldKeys, newKey, oldVault, unlocked, time, random) {
2722
3196
  const next = await deriveRecoveryKeys(newKey, state.realm, state.username);
3197
+ checkNewRecoveryKey(next);
2723
3198
  const event = await rotateRecoveryEvent(state, oldKeys.signSeed, next.signSeed, time);
2724
3199
  const vault = await regrant(unlocked, oldVault, next, random);
2725
3200
  const card = await recoveryCard(state.realm, state.username, state.account, newKey, [...log, event]);
@@ -2770,8 +3245,8 @@ return { testRandom, systemRandom };
2770
3245
  // user enters ([KT-1]) and gathers the newest log before signing anything ([KT-2]).
2771
3246
 
2772
3247
  const rc = { ...__modules["index.js"] };
2773
- for (const name of ["errors.js", "json.js", "encoding.js", "labels.js", "crypto.js", "keys.js", "recovery.js", "id.js", "log.js",
2774
- "vaultcheck.js", "profile.js", "messages.js", "proof.js", "file.js", "vault.js", "account.js", "testing.js"]) {
3248
+ for (const name of ["errors.js", "json.js", "encoding.js", "labels.js", "binding.js", "crypto.js", "keys.js", "recovery.js", "id.js", "log.js",
3249
+ "vaultcheck.js", "profile.js", "messages.js", "proof.js", "file.js", "argon2.js", "vault.js", "account.js", "testing.js"]) {
2775
3250
  Object.assign(rc, __modules[name]);
2776
3251
  }
2777
3252
  const $ = (id) => document.getElementById(id);
@@ -2785,6 +3260,11 @@ function show(text, ok = true) {
2785
3260
  $("out").textContent = text;
2786
3261
  }
2787
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
+
2788
3268
  function explain(e) {
2789
3269
  if (e instanceof rc.RootcardError) {
2790
3270
  const why = {
@@ -2803,8 +3283,9 @@ function explain(e) {
2803
3283
 
2804
3284
  async function run(f) {
2805
3285
  for (const b of document.querySelectorAll("button")) b.disabled = true;
2806
- $("status").className = "";
3286
+ $("status").className = "busy";
2807
3287
  $("status").textContent = "Working…";
3288
+ $("out").textContent = "";
2808
3289
  try {
2809
3290
  await rc.checkSupport();
2810
3291
  show(await f());
@@ -2812,6 +3293,8 @@ async function run(f) {
2812
3293
  show(explain(e), false);
2813
3294
  } finally {
2814
3295
  for (const b of document.querySelectorAll("button")) b.disabled = false;
3296
+ render();
3297
+ $("result").scrollIntoView({ block: "nearest" });
2815
3298
  }
2816
3299
  }
2817
3300
 
@@ -2831,17 +3314,33 @@ async function readFile(input) {
2831
3314
  }
2832
3315
 
2833
3316
  function checkUrl(url) {
2834
- 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`);
2835
3318
  return url.replace(/\/+$/, "");
2836
3319
  }
2837
3320
 
2838
- // A host address as the user typed it: trailing slashes removed, then checked as a base URL ([HST-22]).
2839
- function checkHost(url) {
2840
- 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.");
2841
3336
  try {
2842
3337
  rc.checkBaseUrl(base);
2843
3338
  } catch {
2844
- 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}.`);
2845
3344
  }
2846
3345
  return base;
2847
3346
  }
@@ -2853,16 +3352,26 @@ function apiRoot(base) {
2853
3352
  }
2854
3353
 
2855
3354
  async function http(method, url, body) {
2856
- const res = await fetch(checkUrl(url), {
2857
- method,
2858
- headers: body === undefined ? {} : { "Content-Type": "application/json" },
2859
- body: body === undefined ? undefined : rc.canonicalString(body),
2860
- credentials: "omit",
2861
- referrerPolicy: "no-referrer",
2862
- cache: "no-store",
2863
- });
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
+ }
2864
3368
  const text = await res.text();
2865
- 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
+ }
2866
3375
  if (!res.ok) {
2867
3376
  const code = value?.error;
2868
3377
  if (code && rc.ERROR_CODES.includes(code)) throw new rc.RootcardError(code);
@@ -2959,8 +3468,9 @@ async function hostHome(base) {
2959
3468
 
2960
3469
  function newSlot() {
2961
3470
  const password = $("newPassword").value;
2962
- rc.checkPassword(password, current.state.username);
2963
- 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 };
2964
3474
  }
2965
3475
 
2966
3476
  async function submitInstance(base, mode, old) {
@@ -2989,6 +3499,10 @@ async function submitInstance(base, mode, old) {
2989
3499
  current.state = state;
2990
3500
  current.vault = ni.vault;
2991
3501
  current.public = ni.public;
3502
+ current.vaultError = undefined;
3503
+ try {
3504
+ refreshSlots(await rc.verifyVault(ni.vault, state));
3505
+ } catch {}
2992
3506
  download(rc.accountFileName(state), rc.exportAccount(state, v.events, ni.public, ni.vault));
2993
3507
  return describe(state) + "\n\nThe new account file was downloaded." + note;
2994
3508
  }
@@ -3006,10 +3520,10 @@ async function challengeOf(text) {
3006
3520
  const ops = {
3007
3521
  async restore() {
3008
3522
  const { u } = await currentVault();
3009
- return submitInstance(checkHost($("targetHost").value.trim()), { restore: true }, u);
3523
+ return submitInstance(checkHost($("targetHost").value), { restore: true }, u);
3010
3524
  },
3011
3525
  async fresh() {
3012
- return submitInstance(checkHost($("targetHost").value.trim()), { fresh: newSlot() }, undefined);
3526
+ return submitInstance(checkHost($("targetHost").value), { fresh: newSlot() }, undefined);
3013
3527
  },
3014
3528
  async reset() {
3015
3529
  const { u } = await currentVault();
@@ -3032,22 +3546,30 @@ const ops = {
3032
3546
  },
3033
3547
  async rotate() {
3034
3548
  const { vault, u } = await currentVault();
3035
- const newKey = random(32);
3549
+ const newKey = await rc.newRecoveryKey(current.state.realm, current.state.username, random);
3036
3550
  const r = await rc.rotateRecovery(current.state, current.log, current.keys, newKey, vault, u, now(), random);
3037
- await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3551
+ newKey.fill(0);
3552
+ // The new card is saved and shown before the rotation is sent: once the host has the event, the old key no longer
3553
+ // signs and the new card is the only copy of the new key ([KT-5]).
3038
3554
  download(`${current.state.username}-new.card.json`, r.card);
3039
3555
  const text = await rc.cardText(r.card);
3040
- newKey.fill(0);
3556
+ progress(text + "\nSending the rotation… Keep this card.");
3557
+ try {
3558
+ await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3559
+ } catch (e) {
3560
+ throw new Error(
3561
+ `${explain(e)}\n\nThe rotation may or may not have reached the primary host. Keep both cards: the new card ` +
3562
+ `below (also downloaded) and the old one. Load the identity with the old card: if it answers that the ` +
3563
+ `recovery key is not the current one, the rotation arrived and the new card is current.\n\n${text}`,
3564
+ );
3565
+ }
3041
3566
  current = null;
3042
- $("actions").hidden = true;
3043
- 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.";
3044
3568
  },
3045
3569
  async retire() {
3046
3570
  if ($("retireConfirm").value !== current.state.username) throw new Error("Type the username to confirm.");
3047
3571
  const ev = await rc.retireEvent(current.state, current.keys.signSeed, now());
3048
- const base = current.state.instance?.home.url ?? hostList()[0];
3049
- if (!base) throw new Error("Enter a host.");
3050
- 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] });
3051
3573
  return `${current.state.handle} is retired.`;
3052
3574
  },
3053
3575
  async card() {
@@ -3059,36 +3581,296 @@ const ops = {
3059
3581
  },
3060
3582
  };
3061
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
+
3062
3826
  $("version").textContent = ROOTCARD_VERSION;
3063
3827
  $("load").addEventListener("click", () =>
3064
3828
  run(async () => {
3829
+ current = null;
3830
+ picked = null;
3065
3831
  const notes = await gather();
3066
- $("actions").hidden = false;
3067
3832
  let extra = "";
3068
- try {
3069
- const { u } = await currentVault();
3070
- refreshSlots(u.info);
3071
- } catch (e) {
3072
- 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();
3073
3843
  }
3074
3844
  return describe(current.state) + extra + (notes.length ? "\n\n" + notes.join("\n") : "");
3075
3845
  }),
3076
3846
  );
3077
3847
  for (const b of document.querySelectorAll("[data-op]")) {
3078
- 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
+ );
3079
3856
  }
3080
3857
  $("create").addEventListener("click", () =>
3081
3858
  run(async () => {
3082
3859
  const realm = $("nRealm").value.trim();
3083
3860
  const username = $("nUser").value.trim();
3084
- const rk = random(32);
3085
- 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])
3086
3864
  let card;
3087
3865
  let file;
3088
3866
  let state;
3089
3867
  if (base) {
3090
- const home = await hostHome(checkHost(base));
3868
+ const home = await hostHome(base);
3091
3869
  const reg = await rc.register(rk, realm, username, $("nPassword").value, "Password", home, now(), rc.KDF_DEFAULT, random);
3870
+ rk.fill(0);
3871
+ // The card is saved before anything is sent: once the host has the identity, the card is the only copy of its
3872
+ // recovery key ([CLT-1]).
3873
+ download(`${username}-${rc.tag(reg.account)}.card.json`, reg.card);
3092
3874
  await http("POST", `${apiRoot(home.url)}/id/register`, { log: reg.log, vault: reg.vault, public: reg.public });
3093
3875
  const v = await http("GET", `${apiRoot(home.url)}/id/${reg.account}/log`);
3094
3876
  state = await rc.verifyLog(v.events, { realms: [realm], now: now() });
@@ -3102,9 +3884,14 @@ $("create").addEventListener("click", () =>
3102
3884
  file = rc.exportAccount(state, [inc]);
3103
3885
  }
3104
3886
  rk.fill(0);
3105
- download(`${username}-${rc.tag(state.account)}.card.json`, card);
3887
+ if (!base) download(`${username}-${rc.tag(state.account)}.card.json`, card);
3106
3888
  download(rc.accountFileName(state), file);
3107
- 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;
3108
3895
  }),
3109
3896
  );
3110
3897
  $("verify").addEventListener("click", () =>