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/CHANGELOG.md +40 -0
- package/README.md +6 -4
- package/keytool.html +876 -157
- package/package.json +4 -4
- package/src/account.js +28 -8
- package/src/binding.js +8 -3
- package/src/crypto.js +88 -5
- package/src/index.js +2 -2
- package/src/log.js +2 -2
- package/src/proof.js +2 -3
- package/src/recovery.js +23 -1
- package/src/vault.js +30 -9
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<div
|
|
62
|
-
<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
|
-
|
|
85
|
-
|
|
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="
|
|
88
|
-
<div><label for="
|
|
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
|
-
<
|
|
91
|
-
|
|
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
|
-
<
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
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]`;
|
|
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.
|
|
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 {
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
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)
|
|
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
|
|
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]
|
|
2706
|
-
* a key string. Draws: the account seed (32
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3016
|
-
|
|
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
|
|
3523
|
+
return submitInstance(checkHost($("targetHost").value), { restore: true }, u);
|
|
3063
3524
|
},
|
|
3064
3525
|
async fresh() {
|
|
3065
|
-
return submitInstance(checkHost($("targetHost").value
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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", () =>
|
|
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
|
|
3149
|
-
|
|
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(
|
|
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
|
-
|
|
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", () =>
|