rootcard 0.1.0 → 0.2.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 CHANGED
@@ -4,6 +4,24 @@ All notable changes to this package are recorded here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.1 - 2026-11-05
8
+
9
+ - **Fixed:** `keytool.html` 0.2.0 refused every host address ("not a host base URL"), because the page did not include
10
+ `checkBaseUrl` and `API_VERSION`. A test now checks that the page has every function the package exports.
11
+ - **Fixed (loss of the recovery key):** the key tool page sent a recovery key rotation before it downloaded the new
12
+ card, and registered at a host before it downloaded the card. Both cards are now downloaded and shown first; if a
13
+ rotation cannot be sent, the page says to keep both cards.
14
+
15
+ ## 0.2.0 - 2026-11-05
16
+
17
+ - License: MIT OR Apache-2.0, at the user's option (0.1.0 was MIT only). The package now ships `LICENSE-MIT` and
18
+ `LICENSE-APACHE`.
19
+ - **Breaking (HTTP binding):** `keytool.html` calls hosts under `<base URL>/v0` (HOST_PROTOCOL.md §1.3). The core
20
+ library makes no requests and is unchanged.
21
+ - `API_VERSION` (`"v0"`), `checkBaseUrl` and `isVersionSegment`, equal to the Rust crate's ([HST-22], shared vectors).
22
+ - `keytool.html` checks every host address as a base URL; a trailing slash is still removed from typed addresses.
23
+ - README points to the project README.
24
+
7
25
  ## 0.1.0 - 2026-11-04
8
26
 
9
27
  - First release of the JavaScript implementation of format version 1, byte for byte equal to the Rust crate on every
package/LICENSE-APACHE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
  logins. Zero dependencies, no build step, WebCrypto only; runs in browsers and Node 20 or later. It produces and checks
5
5
  exactly the bytes of the Rust crate `rootcard`; both are tested against the shared vectors of the specification.
6
6
 
7
+ > **New to rootcard?** Start with the [project README](https://github.com/Hexagon/rootcard#readme): what rootcard is,
8
+ > the two ways to use it (a safer username-and-password login for your own service, or an ID card across services),
9
+ > how it compares with other login methods, and the roadmap.
10
+
7
11
  ```js
8
12
  import { verifyLog, verifyProof } from "rootcard"; // verification: hosts, relying parties, clients
9
13
  import { register, unlockVault, changePassword } from "rootcard/vault"; // passwords, Argon2id, vault encryption
@@ -22,3 +26,14 @@ const state = await verifyLog(events, { realms: ["global"], now: Math.floor(Date
22
26
 
23
27
  Functions that need randomness take `random(n)`; pass `systemRandom` (WebCrypto `getRandomValues`). Errors are
24
28
  `RootcardError` with the stable `code` of SPEC §20.
29
+
30
+ ## HTTP binding
31
+
32
+ The core makes no requests. Application clients that talk to a host build URLs as `` `${base}/${API_VERSION}/…` ``
33
+ (`API_VERSION` is `"v0"` until 1.0.0, HOST_PROTOCOL.md §1.3) and check host addresses with `checkBaseUrl` (SPEC
34
+ [HST-22]) before using them or putting them into an instance. `keytool.html` does both; a `keytool.html` from 0.1.0
35
+ does not reach hosts of 0.2.0.
36
+
37
+ ## License
38
+
39
+ Licensed under either of MIT or Apache-2.0, at your option (`LICENSE-MIT`, `LICENSE-APACHE`).
package/keytool.html CHANGED
@@ -110,7 +110,7 @@
110
110
  <script>
111
111
  "use strict";
112
112
  (() => {
113
- const ROOTCARD_VERSION = "0.1.0";
113
+ const ROOTCARD_VERSION = "0.2.1";
114
114
  const __modules = {};
115
115
  __modules["errors.js"] = (() => {
116
116
  // Error codes (SPEC §20).
@@ -936,59 +936,6 @@ function versionRef(v) {
936
936
 
937
937
  return { formatKey, parseKey, object, version, members, get, string, int, intIn, bool, member, array, bytes, key, sig, account, checkAccountId, versionRef };
938
938
  })();
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
939
  __modules["id.js"] = (() => {
993
940
  // Realm, username, inception, account id (SPEC §7), handles and tags (SPEC §8).
994
941
 
@@ -1420,6 +1367,112 @@ function hashBytes(h) {
1420
1367
 
1421
1368
  return { MAX_EVENTS, EVENT_LIMIT, FUTURE_WINDOW, eventMessage, receiptMessage, eventHash, checkUrl, verifyLog, extendLog, verifyPending, compareLogs, unsignedEvent, signEvent, addReceipt, checkRecoveryKey, pendingInstance, rotateRecoveryEvent, retireEvent, hashBytes };
1422
1369
  })();
1370
+ __modules["binding.js"] = (() => {
1371
+ // The HTTP binding's version and base URLs (HOST_PROTOCOL.md §1, §1.3; SPEC [HST-22]). The core makes no requests;
1372
+ // these let hosts, clients and the key tool agree on where endpoints live. Mirrors `rootcard::binding`.
1373
+
1374
+ const { fail } = __modules["errors.js"];
1375
+ const { checkUrl } = __modules["log.js"];
1376
+
1377
+ /**
1378
+ * The version segment of the HTTP binding: every endpoint is under `<base URL>/v0` until 1.0.0, then under `/v1`.
1379
+ * It is never part of a base URL, `home.url` or anything signed.
1380
+ */
1381
+ const API_VERSION = "v0";
1382
+
1383
+ /** Whether a path segment has the form of an API version (`v0`, `V12`). */
1384
+ function isVersionSegment(segment) {
1385
+ return /^[vV][0-9]+$/.test(segment);
1386
+ }
1387
+
1388
+ function authorityHost(rest) {
1389
+ const authority = rest.split("/")[0];
1390
+ if (authority.startsWith("[")) {
1391
+ const i = authority.indexOf("]");
1392
+ return i < 0 ? "" : authority.slice(0, i + 1);
1393
+ }
1394
+ return authority.split(":")[0];
1395
+ }
1396
+
1397
+ /**
1398
+ * Check a base URL ([HST-22]): `https://` and a host, an optional port and an optional path without a trailing slash,
1399
+ * query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; and its last path
1400
+ * segment is not an API version segment. → `bad_field`
1401
+ */
1402
+ function checkBaseUrl(url) {
1403
+ checkUrl(url);
1404
+ let rest;
1405
+ if (url.startsWith("https://")) rest = url.slice(8);
1406
+ else if (url.startsWith("http://")) {
1407
+ rest = url.slice(7);
1408
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(authorityHost(rest))) fail("bad_field");
1409
+ } else fail("bad_field");
1410
+ if (/[?#@\\]/.test(rest)) fail("bad_field");
1411
+ const slash = rest.indexOf("/");
1412
+ const authority = slash < 0 ? rest : rest.slice(0, slash);
1413
+ if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
1414
+ if (slash >= 0) {
1415
+ const segments = rest.slice(slash + 1).split("/");
1416
+ if (segments.some((x) => x === "")) fail("bad_field");
1417
+ if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
1418
+ }
1419
+ }
1420
+
1421
+ return { API_VERSION, isVersionSegment, checkBaseUrl };
1422
+ })();
1423
+ __modules["recovery.js"] = (() => {
1424
+ // The recovery key (SPEC §6).
1425
+
1426
+ const { fail } = __modules["errors.js"];
1427
+ const { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } = __modules["encoding.js"];
1428
+ const { ed25519Public, frame, frameObject, hkdf, sha256 } = __modules["crypto.js"];
1429
+ const { label } = __modules["labels.js"];
1430
+
1431
+ async function checksum(key) {
1432
+ return base32Encode(await sha256(frame(label("recovery/check"), key))).slice(0, 4);
1433
+ }
1434
+
1435
+ /** The written form `RCR-XXXX-…-CCCC` ([RK-1]). */
1436
+ async function formatRecoveryKey(key) {
1437
+ const body = base32Encode(key) + (await checksum(key));
1438
+ const groups = [];
1439
+ for (let i = 0; i < 14; i++) groups.push(body.slice(4 * i, 4 * i + 4));
1440
+ return "RCR-" + groups.join("-");
1441
+ }
1442
+
1443
+ /** Parse a typed recovery key ([RK-2]). → bad_base32, bad_checksum */
1444
+ async function parseRecoveryKey(text) {
1445
+ let s = [...text].filter((c) => c !== "-" && c !== " ").join("");
1446
+ if ([...s].length === 59 && s.slice(0, 3).toUpperCase() === "RCR") s = s.slice(3);
1447
+ if (s.length !== 56 || !/^[\x00-\x7f]*$/.test(s)) fail("bad_base32");
1448
+ const n = base32Normalize(s);
1449
+ for (const c of n) if (!B32.includes(c)) fail("bad_base32");
1450
+ const key = base32Decode(n.slice(0, 52), 32);
1451
+ if (n.slice(52) !== (await checksum(key))) fail("bad_checksum");
1452
+ return key;
1453
+ }
1454
+
1455
+ /**
1456
+ * Derive an identity's recovery keys ([RK-4]): `signSeed` (Ed25519) and `boxSecret` (X25519).
1457
+ * @returns {Promise<{ signSeed: Uint8Array, boxSecret: Uint8Array }>}
1458
+ */
1459
+ async function deriveRecoveryKeys(rk, realm, username) {
1460
+ const ctx = { realm, username };
1461
+ return {
1462
+ signSeed: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/sign"), ctx)),
1463
+ boxSecret: await hkdf(rk, new Uint8Array(0), frameObject(label("recovery/box"), ctx)),
1464
+ };
1465
+ }
1466
+
1467
+ /** The public recovery signing key. */
1468
+ async function recoverySignPublic(keys) {
1469
+ return ed25519Public(keys.signSeed);
1470
+ }
1471
+
1472
+
1473
+
1474
+ return { bytesEqual, formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, recoverySignPublic };
1475
+ })();
1423
1476
  __modules["vaultcheck.js"] = (() => {
1424
1477
  // The vault's structure and signatures (SPEC §11), without any decryption: what hosts need ([VLT-3], [VLT-4]).
1425
1478
 
@@ -2770,8 +2823,8 @@ return { testRandom, systemRandom };
2770
2823
  // user enters ([KT-1]) and gathers the newest log before signing anything ([KT-2]).
2771
2824
 
2772
2825
  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"]) {
2826
+ for (const name of ["errors.js", "json.js", "encoding.js", "labels.js", "binding.js", "crypto.js", "keys.js", "recovery.js", "id.js", "log.js",
2827
+ "vaultcheck.js", "profile.js", "messages.js", "proof.js", "file.js", "argon2.js", "vault.js", "account.js", "testing.js"]) {
2775
2828
  Object.assign(rc, __modules[name]);
2776
2829
  }
2777
2830
  const $ = (id) => document.getElementById(id);
@@ -2835,6 +2888,23 @@ function checkUrl(url) {
2835
2888
  return url.replace(/\/+$/, "");
2836
2889
  }
2837
2890
 
2891
+ // A host address as the user typed it: trailing slashes removed, then checked as a base URL ([HST-22]).
2892
+ function checkHost(url) {
2893
+ const base = url.replace(/\/+$/, "");
2894
+ try {
2895
+ rc.checkBaseUrl(base);
2896
+ } catch {
2897
+ throw new Error(`${url}: not a host base URL (https://host[:port][/path], without /v0 at the end)`);
2898
+ }
2899
+ return base;
2900
+ }
2901
+
2902
+ // The API root of a host: every endpoint is under `<base URL>/v0` until 1.0.0 (HOST_PROTOCOL.md §1.3). Base URLs
2903
+ // (`home.url`, and `url` in signed messages) never include the version.
2904
+ function apiRoot(base) {
2905
+ return `${checkHost(base)}/${rc.API_VERSION}`;
2906
+ }
2907
+
2838
2908
  async function http(method, url, body) {
2839
2909
  const res = await fetch(checkUrl(url), {
2840
2910
  method,
@@ -2855,7 +2925,7 @@ async function http(method, url, body) {
2855
2925
  }
2856
2926
 
2857
2927
  function hostList() {
2858
- return $("hosts").value.split(",").map((s) => s.trim()).filter(Boolean).map(checkUrl);
2928
+ return $("hosts").value.split(",").map((s) => s.trim()).filter(Boolean).map(checkHost);
2859
2929
  }
2860
2930
 
2861
2931
  /** [KT-2]: the newest log from the card, the account file and the hosts; a fork stops. */
@@ -2894,7 +2964,7 @@ async function gather() {
2894
2964
  const notes = [];
2895
2965
  for (const h of [...new Set(hosts)]) {
2896
2966
  try {
2897
- const v = await http("GET", `${h}/id/${card.account}/log`);
2967
+ const v = await http("GET", `${apiRoot(h)}/id/${card.account}/log`);
2898
2968
  await consider(v.events, h);
2899
2969
  } catch (e) {
2900
2970
  notes.push(`${h}: ${explain(e)}`);
@@ -2928,14 +2998,14 @@ async function currentVault() {
2928
2998
  if (!current.state.instance) throw new Error("No instance found: load the account file or enter the primary host.");
2929
2999
  const base = current.state.instance.home.url;
2930
3000
  const account = current.state.account;
2931
- const ch = rc.parseChallenge(await http("POST", `${base}/id/${account}/challenge`, { purpose: "fetch" }));
3001
+ const ch = rc.parseChallenge(await http("POST", `${apiRoot(base)}/id/${account}/challenge`, { purpose: "fetch" }));
2932
3002
  const sig = await rc.ed25519Sign(current.keys.signSeed, rc.fetchMessage(account, ch.host, "recovery", ch.nonce, base));
2933
- const v = await http("POST", `${base}/id/${account}/fetch`, { lock: "recovery", nonce: ch.nonce, url: base, sig: rc.base64urlEncode(sig) });
3003
+ const v = await http("POST", `${apiRoot(base)}/id/${account}/fetch`, { lock: "recovery", nonce: ch.nonce, url: base, sig: rc.base64urlEncode(sig) });
2934
3004
  return { vault: v.vault, u: await rc.unlockVault(v.vault, current.state, { recovery: current.keys }) };
2935
3005
  }
2936
3006
 
2937
3007
  async function hostHome(base) {
2938
- const d = await http("GET", `${base}/host`);
3008
+ const d = await http("GET", `${apiRoot(base)}/host`);
2939
3009
  rc.parseKey(d.key, "ed25519");
2940
3010
  return { key: d.key, url: base };
2941
3011
  }
@@ -2956,14 +3026,14 @@ async function submitInstance(base, mode, old) {
2956
3026
  }
2957
3027
  const ni = await rc.newInstance(current.state, current.keys, old, publicData, mode, home, now(), random);
2958
3028
  const account = current.state.account;
2959
- await http("POST", `${base}/id/${account}/instance`, { events: current.log, instance: ni.event, vault: ni.vault, public: ni.public });
2960
- const v = await http("GET", `${base}/id/${account}/log`);
3029
+ await http("POST", `${apiRoot(base)}/id/${account}/instance`, { events: current.log, instance: ni.event, vault: ni.vault, public: ni.public });
3030
+ const v = await http("GET", `${apiRoot(base)}/id/${account}/log`);
2961
3031
  const state = await rc.verifyLog(v.events, { realms: [current.state.realm], now: now() });
2962
3032
  const oldHome = current.state.instance?.home.url;
2963
3033
  let note = "";
2964
3034
  if (oldHome && oldHome !== base) {
2965
3035
  try {
2966
- await http("POST", `${oldHome}/id/${account}/events`, { events: v.events.slice(current.log.length) });
3036
+ await http("POST", `${apiRoot(oldHome)}/id/${account}/events`, { events: v.events.slice(current.log.length) });
2967
3037
  } catch (e) {
2968
3038
  note = `\nCould not tell the previous primary host: ${explain(e)}`;
2969
3039
  }
@@ -2989,10 +3059,10 @@ async function challengeOf(text) {
2989
3059
  const ops = {
2990
3060
  async restore() {
2991
3061
  const { u } = await currentVault();
2992
- return submitInstance(checkUrl($("targetHost").value.trim()), { restore: true }, u);
3062
+ return submitInstance(checkHost($("targetHost").value.trim()), { restore: true }, u);
2993
3063
  },
2994
3064
  async fresh() {
2995
- return submitInstance(checkUrl($("targetHost").value.trim()), { fresh: newSlot() }, undefined);
3065
+ return submitInstance(checkHost($("targetHost").value.trim()), { fresh: newSlot() }, undefined);
2996
3066
  },
2997
3067
  async reset() {
2998
3068
  const { u } = await currentVault();
@@ -3007,7 +3077,7 @@ const ops = {
3007
3077
  async add() {
3008
3078
  const { vault, u } = await currentVault();
3009
3079
  const v = await rc.addSlot(u, vault, current.keys, newSlot(), random);
3010
- await http("PUT", `${current.state.instance.home.url}/id/${current.state.account}/vault`, { vault: v });
3080
+ await http("PUT", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/vault`, { vault: v });
3011
3081
  current.vault = v;
3012
3082
  const info = await rc.verifyVault(v, current.state);
3013
3083
  refreshSlots(info);
@@ -3017,10 +3087,21 @@ const ops = {
3017
3087
  const { vault, u } = await currentVault();
3018
3088
  const newKey = random(32);
3019
3089
  const r = await rc.rotateRecovery(current.state, current.log, current.keys, newKey, vault, u, now(), random);
3020
- await http("POST", `${current.state.instance.home.url}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3090
+ newKey.fill(0);
3091
+ // The new card is saved and shown before the rotation is sent: once the host has the event, the old key no longer
3092
+ // signs and the new card is the only copy of the new key ([KT-5]).
3021
3093
  download(`${current.state.username}-new.card.json`, r.card);
3022
3094
  const text = await rc.cardText(r.card);
3023
- newKey.fill(0);
3095
+ show(text + "\nSending the rotation… Keep this card.", true);
3096
+ try {
3097
+ await http("POST", `${apiRoot(current.state.instance.home.url)}/id/${current.state.account}/events`, { events: [r.event], vault: r.vault });
3098
+ } catch (e) {
3099
+ throw new Error(
3100
+ `${explain(e)}\n\nThe rotation may or may not have reached the primary host. Keep both cards: the new card ` +
3101
+ `below (also downloaded) and the old one. Load the identity with the old card: if it answers that the ` +
3102
+ `recovery key is not the current one, the rotation arrived and the new card is current.\n\n${text}`,
3103
+ );
3104
+ }
3024
3105
  current = null;
3025
3106
  $("actions").hidden = true;
3026
3107
  return text + "\nThe new card was downloaded. Destroy the old card, then load the new one.";
@@ -3030,7 +3111,7 @@ const ops = {
3030
3111
  const ev = await rc.retireEvent(current.state, current.keys.signSeed, now());
3031
3112
  const base = current.state.instance?.home.url ?? hostList()[0];
3032
3113
  if (!base) throw new Error("Enter a host.");
3033
- await http("POST", `${base}/id/${current.state.account}/events`, { events: [ev] });
3114
+ await http("POST", `${apiRoot(base)}/id/${current.state.account}/events`, { events: [ev] });
3034
3115
  return `${current.state.handle} is retired.`;
3035
3116
  },
3036
3117
  async card() {
@@ -3070,10 +3151,14 @@ $("create").addEventListener("click", () =>
3070
3151
  let file;
3071
3152
  let state;
3072
3153
  if (base) {
3073
- const home = await hostHome(checkUrl(base));
3154
+ const home = await hostHome(checkHost(base));
3074
3155
  const reg = await rc.register(rk, realm, username, $("nPassword").value, "Password", home, now(), rc.KDF_DEFAULT, random);
3075
- await http("POST", `${home.url}/id/register`, { log: reg.log, vault: reg.vault, public: reg.public });
3076
- const v = await http("GET", `${home.url}/id/${reg.account}/log`);
3156
+ rk.fill(0);
3157
+ // The card is saved before anything is sent: once the host has the identity, the card is the only copy of its
3158
+ // recovery key ([CLT-1]).
3159
+ download(`${username}-${rc.tag(reg.account)}.card.json`, reg.card);
3160
+ await http("POST", `${apiRoot(home.url)}/id/register`, { log: reg.log, vault: reg.vault, public: reg.public });
3161
+ const v = await http("GET", `${apiRoot(home.url)}/id/${reg.account}/log`);
3077
3162
  state = await rc.verifyLog(v.events, { realms: [realm], now: now() });
3078
3163
  card = reg.card;
3079
3164
  file = rc.exportAccount(state, v.events, reg.public, reg.vault);
@@ -3085,7 +3170,7 @@ $("create").addEventListener("click", () =>
3085
3170
  file = rc.exportAccount(state, [inc]);
3086
3171
  }
3087
3172
  rk.fill(0);
3088
- download(`${username}-${rc.tag(state.account)}.card.json`, card);
3173
+ if (!base) download(`${username}-${rc.tag(state.account)}.card.json`, card);
3089
3174
  download(rc.accountFileName(state), file);
3090
3175
  return (await rc.cardText(card)) + "\n" + describe(state) + "\n\nThe card and the account file were downloaded. Keep the card offline.";
3091
3176
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootcard",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A portable, self-certifying identity for online logins (core library, zero dependencies, WebCrypto)",
5
5
  "keywords": [
6
6
  "identity",
@@ -10,7 +10,7 @@
10
10
  "webcrypto",
11
11
  "self-certifying"
12
12
  ],
13
- "license": "MIT",
13
+ "license": "(MIT OR Apache-2.0)",
14
14
  "homepage": "https://github.com/Hexagon/rootcard",
15
15
  "repository": {
16
16
  "type": "git",
@@ -31,7 +31,8 @@
31
31
  "keytool.html",
32
32
  "README.md",
33
33
  "CHANGELOG.md",
34
- "LICENSE"
34
+ "LICENSE-MIT",
35
+ "LICENSE-APACHE"
35
36
  ],
36
37
  "sideEffects": false,
37
38
  "engines": {
package/src/binding.js ADDED
@@ -0,0 +1,49 @@
1
+ // The HTTP binding's version and base URLs (HOST_PROTOCOL.md §1, §1.3; SPEC [HST-22]). The core makes no requests;
2
+ // these let hosts, clients and the key tool agree on where endpoints live. Mirrors `rootcard::binding`.
3
+
4
+ import { fail } from "./errors.js";
5
+ import { checkUrl } from "./log.js";
6
+
7
+ /**
8
+ * The version segment of the HTTP binding: every endpoint is under `<base URL>/v0` until 1.0.0, then under `/v1`.
9
+ * It is never part of a base URL, `home.url` or anything signed.
10
+ */
11
+ export const API_VERSION = "v0";
12
+
13
+ /** Whether a path segment has the form of an API version (`v0`, `V12`). */
14
+ export function isVersionSegment(segment) {
15
+ return /^[vV][0-9]+$/.test(segment);
16
+ }
17
+
18
+ function authorityHost(rest) {
19
+ const authority = rest.split("/")[0];
20
+ if (authority.startsWith("[")) {
21
+ const i = authority.indexOf("]");
22
+ return i < 0 ? "" : authority.slice(0, i + 1);
23
+ }
24
+ return authority.split(":")[0];
25
+ }
26
+
27
+ /**
28
+ * Check a base URL ([HST-22]): `https://` and a host, an optional port and an optional path without a trailing slash,
29
+ * query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; and its last path
30
+ * segment is not an API version segment. → `bad_field`
31
+ */
32
+ export function checkBaseUrl(url) {
33
+ checkUrl(url);
34
+ let rest;
35
+ if (url.startsWith("https://")) rest = url.slice(8);
36
+ else if (url.startsWith("http://")) {
37
+ rest = url.slice(7);
38
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(authorityHost(rest))) fail("bad_field");
39
+ } else fail("bad_field");
40
+ if (/[?#@\\]/.test(rest)) fail("bad_field");
41
+ const slash = rest.indexOf("/");
42
+ const authority = slash < 0 ? rest : rest.slice(0, slash);
43
+ if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
44
+ if (slash >= 0) {
45
+ const segments = rest.slice(slash + 1).split("/");
46
+ if (segments.some((x) => x === "")) fail("bad_field");
47
+ if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
48
+ }
49
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // Status: 0.x. Nothing here is stable before 1.0.0.
8
8
 
9
9
  export { FORMAT_VERSION, LABEL_PREFIX, label } from "./labels.js";
10
+ export { API_VERSION, checkBaseUrl, isVersionSegment } from "./binding.js";
10
11
  export { ERROR_CODES, RootcardError } from "./errors.js";
11
12
  export { canonical, canonicalString, parseJson, parseLimited, validate } from "./json.js";
12
13
  export {
File without changes