rootcard 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +6 -4
- package/keytool.html +996 -209
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rootcard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A portable, self-certifying identity for online logins (core library, zero dependencies, WebCrypto)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"identity",
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"self-certifying"
|
|
12
12
|
],
|
|
13
13
|
"license": "(MIT OR Apache-2.0)",
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/rootcard/rootcard",
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/
|
|
17
|
+
"url": "git+https://github.com/rootcard/rootcard.git",
|
|
18
18
|
"directory": "packages/rootcard"
|
|
19
19
|
},
|
|
20
|
-
"bugs": "https://github.com/
|
|
20
|
+
"bugs": "https://github.com/rootcard/rootcard/issues",
|
|
21
21
|
"type": "module",
|
|
22
22
|
"exports": {
|
|
23
23
|
".": "./src/index.js",
|
package/src/account.js
CHANGED
|
@@ -6,21 +6,33 @@ import { base64urlEncode } from "./encoding.js";
|
|
|
6
6
|
import { inception } from "./id.js";
|
|
7
7
|
import { checkRecoveryKey, eventHash, pendingInstance, rotateRecoveryEvent, verifyLog } from "./log.js";
|
|
8
8
|
import { signPublic } from "./profile.js";
|
|
9
|
-
import { deriveRecoveryKeys } from "./recovery.js";
|
|
9
|
+
import { checkNewRecoveryKey, deriveRecoveryKeys } from "./recovery.js";
|
|
10
|
+
import { DRAW_ATTEMPTS, usableSeed } from "./crypto.js";
|
|
10
11
|
import { recoveryCard } from "./file.js";
|
|
11
12
|
import { carriedLocks, checkPassword, instanceVault, regrant } from "./vault.js";
|
|
12
13
|
|
|
14
|
+
/** The account seed of a new instance: 32 random bytes, drawn again while unusable ([CRY-9]). → bad_key (broken generator) */
|
|
15
|
+
function drawAccountSeed(random) {
|
|
16
|
+
for (let i = 0; i < DRAW_ATTEMPTS; i++) {
|
|
17
|
+
const seed = random(32);
|
|
18
|
+
if (usableSeed(seed)) return seed;
|
|
19
|
+
}
|
|
20
|
+
fail("bad_key");
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
|
-
* Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]
|
|
15
|
-
* a key string. Draws: the account seed (32
|
|
24
|
+
* Build a registration ([CLT-1]) from a recovery key the caller generated ([RK-3]; usable by [CRY-9], see
|
|
25
|
+
* newRecoveryKey → bad_key). `home` is `{ key, url }` with a key string. Draws: the account seed (32, again while
|
|
26
|
+
* unusable, [CRY-9]), then instanceVault with one new slot.
|
|
16
27
|
* @returns {Promise<{ log, vault, public, card, account }>}
|
|
17
28
|
*/
|
|
18
29
|
export async function register(recoveryKey, realm, username, password, slotLabel, home, time, kdf, random) {
|
|
19
30
|
checkPassword(password, username);
|
|
20
31
|
const keys = await deriveRecoveryKeys(recoveryKey, realm, username);
|
|
32
|
+
checkNewRecoveryKey(keys);
|
|
21
33
|
const inc = await inception(keys, realm, username);
|
|
22
34
|
const state = await verifyLog([inc], { realms: [realm] });
|
|
23
|
-
const seed = random
|
|
35
|
+
const seed = drawAccountSeed(random);
|
|
24
36
|
const pending = await pendingInstance(state, keys.signSeed, seed, home, "register", time);
|
|
25
37
|
const instance = base64urlEncode(await eventHash(pending));
|
|
26
38
|
const vault = await instanceVault(
|
|
@@ -30,7 +42,7 @@ export async function register(recoveryKey, realm, username, password, slotLabel
|
|
|
30
42
|
recovery: keys,
|
|
31
43
|
contents: { accountSeed: seed, data: {} },
|
|
32
44
|
keep: [],
|
|
33
|
-
add: { label: slotLabel, password, kdf },
|
|
45
|
+
add: { label: slotLabel, password, username, kdf },
|
|
34
46
|
},
|
|
35
47
|
random,
|
|
36
48
|
);
|
|
@@ -45,12 +57,19 @@ export async function register(recoveryKey, realm, username, password, slotLabel
|
|
|
45
57
|
/**
|
|
46
58
|
* Build a new instance on top of `state` ([KT-3]). `mode` is `{ restore: true }`, `{ revoke: [slot ids] }`,
|
|
47
59
|
* `{ reset: slot }` or `{ fresh: slot }`, where a slot is `{ label, password, kdf }`. `old` is the current vault
|
|
48
|
-
* unlocked with the recovery lock (not needed for fresh). Draws: the account seed (32
|
|
60
|
+
* unlocked with the recovery lock (not needed for fresh). Draws: the account seed (32, again while unusable,
|
|
61
|
+
* [CRY-9]), then instanceVault.
|
|
49
62
|
* @returns {Promise<{ event, vault, public }>}
|
|
50
63
|
*/
|
|
51
64
|
export async function newInstance(state, recovery, old, oldPublicData, mode, home, time, random) {
|
|
52
65
|
await checkRecoveryKey(state, recovery.signSeed);
|
|
53
|
-
const
|
|
66
|
+
const slot = mode.reset ?? mode.fresh;
|
|
67
|
+
if (slot) {
|
|
68
|
+
// The new password is checked before anything is drawn ([LCK-8]); its username must be this identity's.
|
|
69
|
+
if (slot.username !== state.username) fail("bad_field");
|
|
70
|
+
checkPassword(slot.password, state.username);
|
|
71
|
+
}
|
|
72
|
+
const seed = drawAccountSeed(random);
|
|
54
73
|
const reason = mode.restore ? "restore" : mode.fresh ? "fresh" : "revoke";
|
|
55
74
|
const event = await pendingInstance(state, recovery.signSeed, seed, home, reason, time);
|
|
56
75
|
const instance = base64urlEncode(await eventHash(event));
|
|
@@ -77,11 +96,12 @@ export async function newInstance(state, recovery, old, oldPublicData, mode, hom
|
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
/**
|
|
80
|
-
* Rotate the recovery key ([KT-5]) to `newKey
|
|
99
|
+
* Rotate the recovery key ([KT-5]) to `newKey` (usable by [CRY-9] → bad_key; see newRecoveryKey). `unlocked` is the current vault unlocked. Draws: regrant.
|
|
81
100
|
* @returns {Promise<{ event, vault, card }>}
|
|
82
101
|
*/
|
|
83
102
|
export async function rotateRecovery(state, log, oldKeys, newKey, oldVault, unlocked, time, random) {
|
|
84
103
|
const next = await deriveRecoveryKeys(newKey, state.realm, state.username);
|
|
104
|
+
checkNewRecoveryKey(next);
|
|
85
105
|
const event = await rotateRecoveryEvent(state, oldKeys.signSeed, next.signSeed, time);
|
|
86
106
|
const vault = await regrant(unlocked, oldVault, next, random);
|
|
87
107
|
const card = await recoveryCard(state.realm, state.username, state.account, newKey, [...log, event]);
|
package/src/binding.js
CHANGED
|
@@ -15,6 +15,11 @@ export function isVersionSegment(segment) {
|
|
|
15
15
|
return /^[vV][0-9]+$/.test(segment);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// A base URL path segment ([HST-22]): non-empty, only unreserved characters (no percent-encoding), not a dot segment.
|
|
19
|
+
function isPathSegment(segment) {
|
|
20
|
+
return /^[A-Za-z0-9\-._~]+$/.test(segment) && segment !== "." && segment !== "..";
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
function authorityHost(rest) {
|
|
19
24
|
const authority = rest.split("/")[0];
|
|
20
25
|
if (authority.startsWith("[")) {
|
|
@@ -26,8 +31,8 @@ function authorityHost(rest) {
|
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* 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]`;
|
|
30
|
-
* segment is not an API version segment. → `bad_field`
|
|
34
|
+
* query, fragment or user info; plain `http://` only for `localhost`, `127.0.0.1` and `[::1]`; path segments of
|
|
35
|
+
* unreserved characters only, never `.` or `..`; and its last path segment is not an API version segment. → `bad_field`
|
|
31
36
|
*/
|
|
32
37
|
export function checkBaseUrl(url) {
|
|
33
38
|
checkUrl(url);
|
|
@@ -43,7 +48,7 @@ export function checkBaseUrl(url) {
|
|
|
43
48
|
if (authorityHost(authority) === "" || !/^[A-Za-z0-9.\-:[\]]+$/.test(authority)) fail("bad_field");
|
|
44
49
|
if (slash >= 0) {
|
|
45
50
|
const segments = rest.slice(slash + 1).split("/");
|
|
46
|
-
if (segments.
|
|
51
|
+
if (!segments.every(isPathSegment)) fail("bad_field");
|
|
47
52
|
if (isVersionSegment(segments[segments.length - 1])) fail("bad_field");
|
|
48
53
|
}
|
|
49
54
|
}
|
package/src/crypto.js
CHANGED
|
@@ -78,14 +78,76 @@ async function publicOf(prefix, alg, secret, usages) {
|
|
|
78
78
|
return x;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** How often a seed is drawn again before a generator is taken to be broken ([CRY-9]). */
|
|
82
|
+
export const DRAW_ATTEMPTS = 64;
|
|
83
|
+
|
|
84
|
+
/** Whether an Ed25519 seed may be used for a new key: its first byte is not zero ([CRY-9]). */
|
|
85
|
+
export function usableSeed(seed) {
|
|
86
|
+
return seed[0] !== 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// [IMP-6]: WebKit's WebCrypto rejects a pkcs8 Ed25519 seed whose first byte is zero, but signs with it when it is
|
|
90
|
+
// imported as a JWK with its public key. Public keys of seeds checked against a key on record (ed25519PublicIs) are
|
|
91
|
+
// kept here, keyed by the seed's buffer, so that later signatures with the same buffer can use that import. Nothing
|
|
92
|
+
// here is secret, and nothing is added without the check.
|
|
93
|
+
const CHECKED = new WeakMap();
|
|
94
|
+
|
|
95
|
+
function b64u(bytes) {
|
|
96
|
+
let b = "";
|
|
97
|
+
for (const x of bytes) b += String.fromCharCode(x);
|
|
98
|
+
return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function importJwk(seed, publicKey, extractable) {
|
|
102
|
+
const jwk = { kty: "OKP", crv: "Ed25519", d: b64u(seed), x: b64u(publicKey), ext: extractable };
|
|
103
|
+
try {
|
|
104
|
+
return await subtle.importKey("jwk", jwk, { name: "Ed25519" }, extractable, ["sign"]);
|
|
105
|
+
} finally {
|
|
106
|
+
jwk.d = "";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
/** The Ed25519 public key of a 32-byte seed ([CRY-2]). */
|
|
82
111
|
export async function ed25519Public(seed) {
|
|
83
|
-
|
|
112
|
+
try {
|
|
113
|
+
return await publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const known = CHECKED.get(seed);
|
|
116
|
+
if (known) return known.slice();
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
84
119
|
}
|
|
85
120
|
|
|
86
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Whether the Ed25519 public key of `seed` is `publicKey` (raw 32 bytes). Where the platform cannot import the seed
|
|
123
|
+
* ([IMP-6]), the import of the seed with `publicKey` answers it, since the platform rejects a public key that does
|
|
124
|
+
* not belong to the seed; the key is then remembered for signing with this seed buffer.
|
|
125
|
+
*/
|
|
126
|
+
export async function ed25519PublicIs(seed, publicKey) {
|
|
127
|
+
let pub;
|
|
128
|
+
try {
|
|
129
|
+
pub = await publicOf(ED_PKCS8, "Ed25519", seed, ["sign"]);
|
|
130
|
+
} catch {
|
|
131
|
+
try {
|
|
132
|
+
await importJwk(seed, publicKey, false);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
CHECKED.set(seed, Uint8Array.from(publicKey));
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return pub.length === publicKey.length && pub.every((b, i) => b === publicKey[i]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Import an Ed25519 seed as a non-extractable signing key ([IMP-2], [IMP-6]). */
|
|
87
143
|
export async function ed25519SigningKey(seed) {
|
|
88
|
-
|
|
144
|
+
try {
|
|
145
|
+
return await importPrivate(ED_PKCS8, "Ed25519", seed, false, ["sign"]);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
const known = CHECKED.get(seed);
|
|
148
|
+
if (known) return importJwk(seed, known, false);
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
89
151
|
}
|
|
90
152
|
|
|
91
153
|
/** A deterministic Ed25519 signature ([CRY-8]). `key` is a seed or a key from ed25519SigningKey. */
|
|
@@ -150,20 +212,41 @@ export async function ed25519Verify(publicKey, m, sig) {
|
|
|
150
212
|
if (!ok) fail("bad_signature");
|
|
151
213
|
}
|
|
152
214
|
|
|
215
|
+
// An X25519 secret as WebCrypto imports it. WebKit's WebCrypto on Linux rejects a secret whose first byte is zero
|
|
216
|
+
// (DataError), probably because it handles the key as a big integer, so ~1 in 256 secrets failed there. X25519 clamps
|
|
217
|
+
// every secret (RFC 7748 §5: the low 3 bits of the first byte cleared, the top bit of the last cleared and the next
|
|
218
|
+
// 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.
|
|
219
|
+
function x25519Secret(secret) {
|
|
220
|
+
const c = Uint8Array.from(secret);
|
|
221
|
+
if (c.length === 32) {
|
|
222
|
+
if (c[0] === 0) c[0] = 1;
|
|
223
|
+
if (c[31] === 0) c[31] = 0x40;
|
|
224
|
+
}
|
|
225
|
+
return c;
|
|
226
|
+
}
|
|
227
|
+
|
|
153
228
|
/** The X25519 public key of a 32-byte secret ([CRY-2]). */
|
|
154
229
|
export async function x25519Public(secret) {
|
|
155
|
-
|
|
230
|
+
const c = x25519Secret(secret);
|
|
231
|
+
try {
|
|
232
|
+
return await publicOf(X_PKCS8, "X25519", c, ["deriveBits"]);
|
|
233
|
+
} finally {
|
|
234
|
+
c.fill(0);
|
|
235
|
+
}
|
|
156
236
|
}
|
|
157
237
|
|
|
158
238
|
/** X25519 key agreement; an all-zero result fails ([CRY-4]). → bad_key */
|
|
159
239
|
export async function x25519(secret, publicKey) {
|
|
160
240
|
let bits;
|
|
241
|
+
const c = x25519Secret(secret);
|
|
161
242
|
try {
|
|
162
|
-
const priv = await importPrivate(X_PKCS8, "X25519",
|
|
243
|
+
const priv = await importPrivate(X_PKCS8, "X25519", c, false, ["deriveBits"]);
|
|
163
244
|
const pub = await subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
|
|
164
245
|
bits = new Uint8Array(await subtle.deriveBits({ name: "X25519", public: pub }, priv, 256));
|
|
165
246
|
} catch {
|
|
166
247
|
fail("bad_key");
|
|
248
|
+
} finally {
|
|
249
|
+
c.fill(0);
|
|
167
250
|
}
|
|
168
251
|
if (bits.every((b) => b === 0)) fail("bad_key");
|
|
169
252
|
return bits;
|
package/src/index.js
CHANGED
|
@@ -13,9 +13,9 @@ export { canonical, canonicalString, parseJson, parseLimited, validate } from ".
|
|
|
13
13
|
export {
|
|
14
14
|
base64urlEncode, base64urlDecode, base32Encode, base32Decode, base32DecodeLenient, hexEncode, hexDecode,
|
|
15
15
|
} from "./encoding.js";
|
|
16
|
-
export { checkSupport, sha256, ed25519Public, ed25519Sign, ed25519Verify, ed25519SigningKey } from "./crypto.js";
|
|
16
|
+
export { checkSupport, sha256, ed25519Public, ed25519PublicIs, ed25519Sign, ed25519Verify, ed25519SigningKey, usableSeed } from "./crypto.js";
|
|
17
17
|
export { formatKey, parseKey } from "./keys.js";
|
|
18
|
-
export { formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys } from "./recovery.js";
|
|
18
|
+
export { formatRecoveryKey, parseRecoveryKey, deriveRecoveryKeys, newRecoveryKey, checkNewRecoveryKey } from "./recovery.js";
|
|
19
19
|
export {
|
|
20
20
|
checkRealm, checkUsername, defaultUsernamePolicy, similarUsernames, accountId, inception, tag, handle, parseHandle,
|
|
21
21
|
handleMatches, extendedTags,
|
package/src/log.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { fail } from "./errors.js";
|
|
5
5
|
import { base64urlEncode, base64urlDecode, bytesEqual } from "./encoding.js";
|
|
6
|
-
import { ed25519Public, ed25519Sign, ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
6
|
+
import { ed25519Public, ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
7
7
|
import { canonical, without, isObject, MAX_SAFE } from "./json.js";
|
|
8
8
|
import * as s from "./keys.js";
|
|
9
9
|
import { formatKey } from "./keys.js";
|
|
@@ -275,7 +275,7 @@ export async function addReceipt(e, hostSeed) {
|
|
|
275
275
|
|
|
276
276
|
/** [RK-5] → wrong_recovery_key */
|
|
277
277
|
export async function checkRecoveryKey(state, seed) {
|
|
278
|
-
if (
|
|
278
|
+
if (!(await ed25519PublicIs(seed, s.parseKey(state.recovery, "ed25519")))) fail("wrong_recovery_key");
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
/** A pending instance ([LOG-9]). `home` is `{ key: "ed25519:…", url }`. */
|
package/src/proof.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { fail } from "./errors.js";
|
|
4
4
|
import { base64urlEncode } from "./encoding.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ed25519PublicIs, ed25519Sign, ed25519Verify, frameObject, sha256 } from "./crypto.js";
|
|
6
6
|
import { utf8 } from "./encoding.js";
|
|
7
7
|
import { validate, without } from "./json.js";
|
|
8
8
|
import * as s from "./keys.js";
|
|
@@ -24,11 +24,10 @@ function checkRecipient(r) {
|
|
|
24
24
|
/** Make a proof ([PRF-1], [PRF-2]). `by` is "account" or "recovery"; `challenge` is 32 bytes. */
|
|
25
25
|
export async function prove(state, by, seed, challenge, recipient) {
|
|
26
26
|
checkRecipient(recipient);
|
|
27
|
-
const pub = formatKey("ed25519", await ed25519Public(seed));
|
|
28
27
|
const p = { v: 1, by, account: state.account, challenge: base64urlEncode(challenge), recipient };
|
|
29
28
|
if (by === "account") {
|
|
30
29
|
if (!state.instance) fail("no_instance");
|
|
31
|
-
if (state.instance.account_key
|
|
30
|
+
if (!(await ed25519PublicIs(seed, s.parseKey(state.instance.account_key, "ed25519")))) fail("wrong_account_key");
|
|
32
31
|
p.instance = state.instance.hash;
|
|
33
32
|
} else {
|
|
34
33
|
await checkRecoveryKey(state, seed);
|
package/src/recovery.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { fail } from "./errors.js";
|
|
4
4
|
import { B32, base32Decode, base32Encode, base32Normalize, bytesEqual } from "./encoding.js";
|
|
5
|
-
import { ed25519Public, frame, frameObject, hkdf, sha256 } from "./crypto.js";
|
|
5
|
+
import { DRAW_ATTEMPTS, ed25519Public, frame, frameObject, hkdf, sha256, usableSeed } from "./crypto.js";
|
|
6
6
|
import { label } from "./labels.js";
|
|
7
7
|
|
|
8
8
|
async function checksum(key) {
|
|
@@ -41,6 +41,28 @@ export async function deriveRecoveryKeys(rk, realm, username) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Generate a recovery key for an identity ([RK-3]): 32 random bytes, drawn again while the derived signing seed is
|
|
46
|
+
* unusable ([CRY-9]). Draws: 32 bytes per attempt. → bad_key only for a broken generator.
|
|
47
|
+
*/
|
|
48
|
+
export async function newRecoveryKey(realm, username, random) {
|
|
49
|
+
for (let i = 0; i < DRAW_ATTEMPTS; i++) {
|
|
50
|
+
const rk = random(32);
|
|
51
|
+
const keys = await deriveRecoveryKeys(rk, realm, username);
|
|
52
|
+
const ok = usableSeed(keys.signSeed);
|
|
53
|
+
keys.signSeed.fill(0);
|
|
54
|
+
keys.boxSecret.fill(0);
|
|
55
|
+
if (ok) return rk;
|
|
56
|
+
rk.fill(0);
|
|
57
|
+
}
|
|
58
|
+
fail("bad_key");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A new recovery key must give a usable signing seed ([CRY-9]). → bad_key */
|
|
62
|
+
export function checkNewRecoveryKey(keys) {
|
|
63
|
+
if (!usableSeed(keys.signSeed)) fail("bad_key");
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
/** The public recovery signing key. */
|
|
45
67
|
export async function recoverySignPublic(keys) {
|
|
46
68
|
return ed25519Public(keys.signSeed);
|
package/src/vault.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { fail } from "./errors.js";
|
|
8
8
|
import { base64urlDecode, base64urlEncode, concat, utf8 } from "./encoding.js";
|
|
9
9
|
import {
|
|
10
|
-
aesGcmDecrypt, aesGcmEncrypt, ed25519Public, ed25519Sign, frameObject, hkdf, sha256, x25519, x25519Public,
|
|
10
|
+
aesGcmDecrypt, aesGcmEncrypt, DRAW_ATTEMPTS, ed25519Public, ed25519PublicIs, ed25519Sign, frameObject, hkdf, sha256, usableSeed, x25519, x25519Public,
|
|
11
11
|
} from "./crypto.js";
|
|
12
12
|
import { argon2id } from "./argon2.js";
|
|
13
13
|
import { canonical, parseLimited } from "./json.js";
|
|
@@ -136,9 +136,13 @@ export async function unlockVault(vault, state, secret) {
|
|
|
136
136
|
const contents = checkContents(parseLimited(pt, CONTENTS_LIMIT));
|
|
137
137
|
pt.fill(0);
|
|
138
138
|
if (!state.instance) fail("no_instance");
|
|
139
|
-
if (
|
|
139
|
+
if (!(await ed25519PublicIs(contents.accountSeed, s.parseKey(state.instance.account_key, "ed25519")))) {
|
|
140
140
|
fail("wrong_account_key");
|
|
141
141
|
}
|
|
142
|
+
// Check the signing seed of the lock against its key on record, so that a platform that cannot import it signs
|
|
143
|
+
// with it anyway ([IMP-6]); a mismatch is not this function's to judge (a login with it fails at the host).
|
|
144
|
+
const signKey = lockId === "recovery" ? state.recovery : lock.value.auth;
|
|
145
|
+
await ed25519PublicIs(lockSeed, s.parseKey(signKey, "ed25519"));
|
|
142
146
|
return { vaultKey: vk, contents, lock: lockId, lockSeed, info };
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -167,13 +171,29 @@ function kdfValue(kdf, salt) {
|
|
|
167
171
|
return { alg: "argon2id", m: kdf.m, t: kdf.t, p: kdf.p, salt: base64urlEncode(salt) };
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
/**
|
|
174
|
+
/** A new salt and its lock secrets, drawn again while the auth seed is unusable ([CRY-9]). → bad_key (broken generator) */
|
|
175
|
+
async function newLockSecrets(account, lock, password, kdf, random) {
|
|
176
|
+
for (let i = 0; i < DRAW_ATTEMPTS; i++) {
|
|
177
|
+
const salt = random(16);
|
|
178
|
+
const ls = await deriveLockSecrets(account, lock, password, kdf, salt);
|
|
179
|
+
if (usableSeed(ls.authSeed)) return [salt, ls];
|
|
180
|
+
ls.authSeed.fill(0);
|
|
181
|
+
ls.boxSecret.fill(0);
|
|
182
|
+
}
|
|
183
|
+
fail("bad_key");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create a new password lock's public part. `slot` is `{ label, password, username, kdf }`; the password must satisfy
|
|
188
|
+
* [LCK-8] for `username` (`weak_password`), as in every function that creates a password lock. Draws: slot id (8),
|
|
189
|
+
* salt (16, again while the auth seed is unusable, [CRY-9]). Returns `[slot, lockPublic]`.
|
|
190
|
+
*/
|
|
171
191
|
export async function newPasswordLock(account, slot, random) {
|
|
192
|
+
checkPassword(slot.password, slot.username);
|
|
172
193
|
checkLabel(slot.label);
|
|
173
194
|
const id = base64urlEncode(random(8));
|
|
174
|
-
const salt = random(16);
|
|
175
195
|
const kdf = { m: slot.kdf.m, t: slot.kdf.t, p: slot.kdf.p ?? 1 };
|
|
176
|
-
const ls = await
|
|
196
|
+
const [salt, ls] = await newLockSecrets(account, id, slot.password, kdf, random);
|
|
177
197
|
const pub = {
|
|
178
198
|
id,
|
|
179
199
|
kdf: kdfValue(kdf, salt),
|
|
@@ -244,16 +264,17 @@ export async function updateContents(u, vault, contents, random) {
|
|
|
244
264
|
}
|
|
245
265
|
|
|
246
266
|
/**
|
|
247
|
-
* A password change ([CHG-1] step 1)
|
|
267
|
+
* A password change ([CHG-1] step 1); the new password must satisfy [LCK-8] for `username` (`weak_password`).
|
|
268
|
+
* Draws: salt (16), wrap (32 + 12), contents nonce (12).
|
|
248
269
|
* Returns `{ vault, new }` where `new` is `SHA-256(canonical(new lock))`.
|
|
249
270
|
*/
|
|
250
|
-
export async function changePassword(u, vault, slot, password, kdf, random) {
|
|
271
|
+
export async function changePassword(u, vault, slot, password, username, kdf, random) {
|
|
272
|
+
checkPassword(password, username);
|
|
251
273
|
const { account, instance, grant } = idsOf(u, vault);
|
|
252
274
|
const index = u.info.locks.findIndex((l) => l.kind === "password" && l.id === slot);
|
|
253
275
|
if (index < 0) fail("bad_locks");
|
|
254
|
-
const salt = random(16);
|
|
255
276
|
const k = { m: kdf.m, t: kdf.t, p: kdf.p ?? 1 };
|
|
256
|
-
const ls = await
|
|
277
|
+
const [salt, ls] = await newLockSecrets(account, slot, password, k, random);
|
|
257
278
|
const box = await x25519Public(ls.boxSecret);
|
|
258
279
|
const pub = { id: slot, kdf: kdfValue(k, salt), auth: formatKey("ed25519", await ed25519Public(ls.authSeed)), box: formatKey("x25519", box) };
|
|
259
280
|
ls.authSeed.fill(0);
|