@johnhenry/keyvault 0.0.0-stage → 0.0.0
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 +20 -0
- package/LICENSE +9 -0
- package/README.md +151 -2
- package/package.json +69 -4
- package/src/argon2.mjs +50 -0
- package/src/bytes.mjs +56 -0
- package/src/derive.mjs +67 -0
- package/src/errors.mjs +39 -0
- package/src/index.mjs +5 -0
- package/src/kdf.mjs +71 -0
- package/src/passkey.mjs +40 -0
- package/src/seal.mjs +162 -0
- package/types/argon2.d.ts +10 -0
- package/types/index.d.ts +85 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@johnhenry/keyvault`. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow semver (0.x: minor bumps may break).
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - Unreleased
|
|
6
|
+
|
|
7
|
+
First release. Consolidates the password-based crypto that p2pws (`backup.mjs`, `login.mjs`) and
|
|
8
|
+
`@johnhenry/browsermesh-core` (`VaultIdentityStorage`) each carried a copy of.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `seal()` / `open()` / `openText()` / `openJson()`: PBKDF2-SHA256 (600,000 by default) to AES-256-GCM. The whole header, including the KDF parameters, is AES-GCM additional data. Byte-compatible with p2pws's `v: 1` backup files in both directions.
|
|
12
|
+
- `open()` also reads browsermesh `VaultIdentityStorage` envelopes (and whole stored records), exactly as that class reads them (no Unicode normalisation, no header binding).
|
|
13
|
+
- `needsUpgrade()`: true when a record's KDF is weaker than `defaultKdf()` (or a target you pass), or it is a header-less browsermesh vault envelope.
|
|
14
|
+
- `deriveKey()` with an explicit domain-separation `purpose`; `deriveLoginKeys()` / `deriveVaultKey()` reproduce p2pws's `salt || "\np2pws-login/" + purpose` derivation exactly (`P2PWS_LOGIN_KDF` = its 310,000 iterations).
|
|
15
|
+
- `VaultError` with stable codes `bad_passphrase`, `corrupt`, `unsupported`, `wrong_type`, `weak_secret`.
|
|
16
|
+
- Caps on hostile input: PBKDF2 iterations 1,000 to 10,000,000, Argon2id memory/passes/lanes, unknown KDF names, oversized records and payloads, empty / undefined / non-string secrets.
|
|
17
|
+
- `prfFromPasskey()`: a secret from a passkey via the WebAuthn PRF extension.
|
|
18
|
+
- Optional `@johnhenry/keyvault/argon2`: Argon2id through `hash-wasm` (optional peer dependency) or your own implementation.
|
|
19
|
+
- Zero runtime dependencies; ESM; browsers, Node >= 20, Deno. TypeScript declarations.
|
|
20
|
+
- Tests in Node, Deno and headless Chromium (Playwright), with fixtures written by the current p2pws and browsermesh-core code.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Henry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @johnhenry/keyvault
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn a human secret (a password, a passphrase, a passkey) into keys, and seal small secrets such as private keys with them.
|
|
4
|
+
|
|
5
|
+
- **Zero runtime dependencies.** WebCrypto only. ESM. Browsers, Node >= 20 and Deno.
|
|
6
|
+
- **Self-describing.** The KDF name and parameters are written into every record and authenticated, so you can raise them later without orphaning old records (`needsUpgrade()`).
|
|
7
|
+
- **Safe on hostile input.** Iteration, memory and size caps, unknown KDFs rejected, empty / `undefined` / non-string secrets refused.
|
|
8
|
+
- **Compatible.** Reads (and writes) the sealed files p2pws's `backup.mjs` produces, and reads the records browsermesh's `VaultIdentityStorage` stores.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @johnhenry/keyvault
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import { seal, open, needsUpgrade, VaultError } from '@johnhenry/keyvault';
|
|
18
|
+
|
|
19
|
+
// Seal a private key (bytes or a string). The header is readable, and tamper-proof.
|
|
20
|
+
const text = await seal(pkcs8Bytes, 'correct horse battery staple', {
|
|
21
|
+
type: 'my-key-backup',
|
|
22
|
+
header: { kid: 'abc', label: 'laptop', createdAt: Date.now() },
|
|
23
|
+
});
|
|
24
|
+
// `text` is JSON: store it anywhere.
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const { header, payload } = await open(text, passphrase, { type: 'my-key-backup' });
|
|
28
|
+
// payload is a Uint8Array; header.kid etc. are authenticated.
|
|
29
|
+
if (needsUpgrade(text)) await save(await seal(payload, passphrase, { type: 'my-key-backup', header }));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (e instanceof VaultError && e.code === 'bad_passphrase') { /* ask again */ }
|
|
32
|
+
else throw e;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
### Sealing
|
|
39
|
+
|
|
40
|
+
| Function | |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `seal(payload, secret, { type, header?, kdf?, minLength? })` | `payload`: `Uint8Array` or string (at most 1 MiB). `type` is required. `header`: extra readable fields (strings, safe integers, booleans, `null`, arrays, plain objects; not `v`, `t`, `kdf`, `iv`, `ct`). `kdf` defaults to `defaultKdf()`. `minLength` is your password policy (default 1). Returns JSON text. |
|
|
43
|
+
| `open(text, secret, { type?, limits? })` | Resolves `{ header, type, kdf, payload: Uint8Array, legacy }`. Pass `type`: a record of another type is refused with `wrong_type` before any key derivation. `text` may also be an already-parsed object. |
|
|
44
|
+
| `openText(...)` / `openJson(...)` | The same, decoding `payload` as UTF-8 / JSON (`corrupt` if it is not). |
|
|
45
|
+
| `needsUpgrade(text, target = defaultKdf())` | `true` when the record's KDF is weaker than `target` (fewer PBKDF2 iterations; PBKDF2 vs Argon2id; less Argon2 memory or fewer passes), or it is a header-less browsermesh vault envelope. Does not decrypt. Throws `VaultError` for something that is not a valid record. |
|
|
46
|
+
| `defaultKdf()` | `{ name: 'PBKDF2-SHA256', iterations: 600000 }` today (OWASP's figure). A fresh object each call. |
|
|
47
|
+
|
|
48
|
+
`open()` returns `header` only after authenticating it, so its fields can be trusted, but it is still *your* data: check it the way you would any input (for example that a key id matches the key you decrypted).
|
|
49
|
+
|
|
50
|
+
### Deriving keys
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import { deriveKey, deriveLoginKeys, p2pwsLoginPurpose, P2PWS_LOGIN_KDF, defaultKdf } from '@johnhenry/keyvault';
|
|
54
|
+
|
|
55
|
+
// One password, two unrelated keys: authKey goes to a server (which should store only a verifier of it),
|
|
56
|
+
// vaultKey never leaves the device and is the `secret` for seal()/open().
|
|
57
|
+
const { authKey, vaultKey } = await deriveLoginKeys(password, salt, defaultKdf());
|
|
58
|
+
|
|
59
|
+
// The general primitive. `purpose` is required; keys for different purposes are unrelated.
|
|
60
|
+
const bytes = await deriveKey(secret, salt, defaultKdf(), 'my-app/session-key'); // Uint8Array(32)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`deriveKey(secret, salt, params, purpose, { bits = 256, normalize = false, limits })` runs the KDF over the salt bytes followed by the UTF-8 of `purpose`: `salt || purpose`. `salt` is bytes or a base64url string.
|
|
64
|
+
|
|
65
|
+
**p2pws compatibility.** p2pws derives its sign-in keys with salt `salt || "\np2pws-login/" + purpose`, PBKDF2-SHA256 at 310,000 iterations, no Unicode normalisation, and base64url output. `deriveLoginKeys(password, salt, P2PWS_LOGIN_KDF)` and `deriveVaultKey(...)` reproduce those keys exactly (checked against fixtures written by p2pws's own `login.mjs`), so existing logins keep working; `deriveKey(..., p2pwsLoginPurpose('auth'))` is the same thing spelled out. Move to `defaultKdf()` on the next successful sign-in.
|
|
66
|
+
|
|
67
|
+
### Normalisation
|
|
68
|
+
|
|
69
|
+
`seal`/`open` NFKC-normalise the secret, as p2pws's backups do (so a fullwidth `pass` and `pass` are the same passphrase). `deriveKey` does **not** unless you pass `normalize: 'NFKC'`, because p2pws's login derivation does not. browsermesh vault records are read without normalisation, exactly as `VaultIdentityStorage` wrote them.
|
|
70
|
+
|
|
71
|
+
### Errors
|
|
72
|
+
|
|
73
|
+
Every expected failure is a `VaultError` with a stable `code`:
|
|
74
|
+
|
|
75
|
+
| code | meaning |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `bad_passphrase` | Wrong secret, **or the record was modified**. AES-GCM cannot tell the two apart. |
|
|
78
|
+
| `corrupt` | Not JSON, truncated, wrong shape, bad base64url, ciphertext shorter than its tag, a KDF parameter of the wrong type. |
|
|
79
|
+
| `unsupported` | A valid shape that is refused: unknown record version, unknown KDF name, parameters outside the caps (iterations 1,000 to 10,000,000 for PBKDF2), Argon2id not enabled, payload over 1 MiB. |
|
|
80
|
+
| `wrong_type` | The record's `type` is not the `type` you asked for. |
|
|
81
|
+
| `weak_secret` | The secret is empty, `undefined`, `null`, not a string, or shorter than `minLength`. |
|
|
82
|
+
|
|
83
|
+
Mistakes in your own call (a missing `type`, a reserved or non-integer header field, an empty `purpose`) throw `TypeError` instead.
|
|
84
|
+
|
|
85
|
+
Caps can be tightened per call with `limits: { minIterations, maxIterations, maxArgon2Passes, maxMemoryKiB, maxParallelism }`.
|
|
86
|
+
|
|
87
|
+
### Argon2id (optional)
|
|
88
|
+
|
|
89
|
+
WebCrypto has no Argon2, so it lives behind a separate entry point and the core never loads it.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { enableArgon2id, argon2idKdf } from '@johnhenry/keyvault/argon2';
|
|
93
|
+
await enableArgon2id(); // uses the optional peer dependency `hash-wasm`
|
|
94
|
+
const text = await seal(bytes, secret, { type: 'my-key-backup', kdf: argon2idKdf() }); // 19 MiB, 2 passes, 1 lane
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`npm install hash-wasm` (a bare `import('hash-wasm')` needs a bundler or an import map in browsers), or pass your own implementation: `enableArgon2id({ hash: ({ password, salt, iterations, memoryKiB, parallelism, hashLength }) => Uint8Array })`. Until it is enabled, Argon2id records are refused with `unsupported` and a message naming this entry point. Whether Argon2id becomes `defaultKdf()` is a decision for the consuming apps; today it is opt-in.
|
|
98
|
+
|
|
99
|
+
### Passkeys
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
import { prfFromPasskey, seal, open } from '@johnhenry/keyvault';
|
|
103
|
+
const secret = await prfFromPasskey({ credentialId, salt: appSalt, rpId: 'example.com' }); // base64url, 32 bytes
|
|
104
|
+
const text = await seal(bytes, secret, { type: 'my-key-backup' });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Uses the WebAuthn `prf` extension: the secret exists only when the authenticator is used, so nothing a server stores can be guessed offline. Needs a secure context and an authenticator that supports PRF (otherwise `unsupported`). Create the credential yourself with `extensions: { prf: {} }`. Because the secret is high entropy, you may pass `kdf: { name: 'PBKDF2-SHA256', iterations: 1000 }` to skip the stretching.
|
|
108
|
+
|
|
109
|
+
## File format
|
|
110
|
+
|
|
111
|
+
Plain JSON, version 1:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "v": 1, "t": "my-key-backup",
|
|
115
|
+
"kdf": { "name": "PBKDF2-SHA256", "iterations": 600000, "salt": "<base64url, 16 bytes>" },
|
|
116
|
+
"kid": "...", "label": "laptop", "createdAt": 1760000000000,
|
|
117
|
+
"iv": "<base64url, 12 bytes>", "ct": "<base64url AES-256-GCM ciphertext + tag>" }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The key is PBKDF2-HMAC-SHA256 of the NFKC-normalised secret, 256 bits. The AES-GCM additional data is the UTF-8 of the **canonical JSON** (keys sorted, safe integers only) of every field except `ct`. That is the construction p2pws's `backup.mjs` uses, so its files are valid records, and records written here with the same header fields (`kid`, `account`, `label`, `createdAt`) open with p2pws's `openBackup` / `openIdentityBundle`.
|
|
121
|
+
|
|
122
|
+
p2pws-specific rules (key ids, `acct_` ids, the 1 MiB bundle limit) are not enforced here; validate them in the application after `open()`.
|
|
123
|
+
|
|
124
|
+
Accepted from browsermesh: the `{ kdf?, iterations?, salt, iv, ciphertext }` envelope of `VaultIdentityStorage` (type `'browsermesh-vault'`, `legacy: true`), or the stored record that contains it as `encryptedPrivateKey`. `kdf` and `iterations` may be missing in old records (PBKDF2, 310,000). Those envelopes bind no header, so `needsUpgrade()` is always `true` for them.
|
|
125
|
+
|
|
126
|
+
## Security notes
|
|
127
|
+
|
|
128
|
+
- A sealed record is only as strong as its secret. The KDF slows guessing down; it does not stop it. Prefer passkeys or long passphrases, and Argon2id where WASM is acceptable.
|
|
129
|
+
- `bad_passphrase` also means "tampered". Do not tell users which.
|
|
130
|
+
- Nothing is zeroed in JavaScript memory beyond the raw derived key bytes; do not treat this as a hardware boundary.
|
|
131
|
+
- Payloads are capped at 1 MiB: this is for keys, not files.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
npm test # Node: unit cases, node:crypto cross-checks, Argon2id
|
|
137
|
+
npm run test:deno # the same cases under Deno
|
|
138
|
+
npx playwright install chromium && npm run test:browser # the same cases in headless Chromium, plus Argon2id and a real WebAuthn PRF round trip via a virtual authenticator
|
|
139
|
+
npm run typecheck
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The cases include the RFC 6070 (PBKDF2-HMAC-SHA1, as a WebCrypto platform check) and RFC 7914 section 11 (PBKDF2-HMAC-SHA-256) vectors, round trips, tampering with every authenticated field, truncation, and hostile iteration counts, KDF names and secrets.
|
|
143
|
+
|
|
144
|
+
`test/fixtures/` holds files written by the *current* p2pws (`createBackup`, `sealIdentityBundle` for `p2pws-account-recovery` and `p2pws-identity-bundle`, `deriveLoginKeys`) and browsermesh-core `VaultIdentityStorage` code, at 1,000 and the real default 600,000 / 310,000 iterations. `test/fixtures/manifest.json` records which commit/version wrote them. To regenerate: `P2PWS_DIR=/path/to/p2pws npm run fixtures`.
|
|
145
|
+
|
|
146
|
+
## Releasing
|
|
147
|
+
|
|
148
|
+
Main is the release branch: a push to `main` whose `package.json` version is not on npm publishes it (trusted publishing, with provenance) and tags `v<version>`. Do not rename `.github/workflows/publish.yml`; the npm trusted-publisher entry is bound to that filename.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/keyvault",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Zero-dependency WebCrypto key vault: turn a human secret into keys and seal small secrets with them (browser, Node, Deno)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.mjs",
|
|
7
|
+
"types": "./types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
11
|
+
"default": "./src/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./argon2": {
|
|
14
|
+
"types": "./types/argon2.d.ts",
|
|
15
|
+
"default": "./src/argon2.mjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/",
|
|
21
|
+
"types/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/node/*.test.mjs",
|
|
29
|
+
"test:browser": "node test/browser/run.mjs",
|
|
30
|
+
"test:deno": "deno test --no-check --allow-read test/node/core.test.mjs",
|
|
31
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
32
|
+
"fixtures": "node scripts/gen-fixtures.mjs",
|
|
33
|
+
"prepublishOnly": "npm test && npm run typecheck"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"webcrypto",
|
|
37
|
+
"pbkdf2",
|
|
38
|
+
"argon2id",
|
|
39
|
+
"aes-gcm",
|
|
40
|
+
"passphrase",
|
|
41
|
+
"key-derivation",
|
|
42
|
+
"vault",
|
|
43
|
+
"passkey",
|
|
44
|
+
"webauthn-prf"
|
|
45
|
+
],
|
|
46
|
+
"author": "John Henry <john@iamjohnhenry.com>",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/johnhenry/keyvault.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/johnhenry/keyvault#readme",
|
|
53
|
+
"bugs": "https://github.com/johnhenry/keyvault/issues",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"hash-wasm": "^4.11.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"hash-wasm": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/node": "^22.0.0",
|
|
67
|
+
"hash-wasm": "^4.11.0",
|
|
68
|
+
"playwright": "^1.50.0",
|
|
69
|
+
"typescript": "^5.6.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/argon2.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Optional Argon2id. WebCrypto has no Argon2, so this entry point drives a WASM implementation. The core never imports it.
|
|
2
|
+
//
|
|
3
|
+
// import { enableArgon2id, argon2idKdf } from '@johnhenry/keyvault/argon2';
|
|
4
|
+
// await enableArgon2id(); // loads the optional peer dependency `hash-wasm`
|
|
5
|
+
// const text = await seal(bytes, secret, { type, kdf: argon2idKdf() });
|
|
6
|
+
//
|
|
7
|
+
// Once enabled, open() reads Argon2id files and needsUpgrade() ranks Argon2id above PBKDF2.
|
|
8
|
+
import { fail } from './errors.mjs';
|
|
9
|
+
import { ARGON2ID, registerKdf, DEFAULT_LIMITS } from './kdf.mjs';
|
|
10
|
+
|
|
11
|
+
const int = (v) => typeof v === 'number' && Number.isSafeInteger(v);
|
|
12
|
+
|
|
13
|
+
/** OWASP's minimum Argon2id profile (19 MiB, 2 passes, 1 lane). A fresh object each call. */
|
|
14
|
+
export function argon2idKdf(overrides = {}) {
|
|
15
|
+
return { name: ARGON2ID, iterations: 2, memoryKiB: 19_456, parallelism: 1, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register Argon2id.
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {(a: {password: Uint8Array, salt: Uint8Array, iterations: number, memoryKiB: number, parallelism: number, hashLength: number}) => Promise<Uint8Array>|Uint8Array} [options.hash]
|
|
22
|
+
* your own implementation (e.g. one backed by a bundled WASM build). Default: `hash-wasm`'s argon2id, imported on demand.
|
|
23
|
+
*/
|
|
24
|
+
export async function enableArgon2id({ hash } = {}) {
|
|
25
|
+
let impl = hash;
|
|
26
|
+
if (!impl) {
|
|
27
|
+
let mod;
|
|
28
|
+
try { mod = await import('hash-wasm'); } catch { fail('unsupported', "Argon2id needs the optional dependency 'hash-wasm' (npm install hash-wasm), or pass enableArgon2id({ hash })"); }
|
|
29
|
+
impl = ({ password, salt, iterations, memoryKiB, parallelism, hashLength }) =>
|
|
30
|
+
mod.argon2id({ password, salt, iterations, memorySize: memoryKiB, parallelism, hashLength, outputType: 'binary' });
|
|
31
|
+
}
|
|
32
|
+
registerKdf({
|
|
33
|
+
name: ARGON2ID,
|
|
34
|
+
rank: 2,
|
|
35
|
+
validate(p, lim) {
|
|
36
|
+
for (const k of ['iterations', 'memoryKiB', 'parallelism']) if (!int(p[k])) fail('corrupt', `kdf.${k} must be an integer`);
|
|
37
|
+
const L = { ...DEFAULT_LIMITS, ...lim };
|
|
38
|
+
if (p.iterations < 1 || p.iterations > L.maxArgon2Passes) fail('unsupported', `kdf.iterations ${p.iterations} is outside 1..${L.maxArgon2Passes}`);
|
|
39
|
+
if (p.parallelism < 1 || p.parallelism > L.maxParallelism) fail('unsupported', `kdf.parallelism ${p.parallelism} is outside 1..${L.maxParallelism}`);
|
|
40
|
+
if (p.memoryKiB < 8 * p.parallelism || p.memoryKiB > L.maxMemoryKiB) fail('unsupported', `kdf.memoryKiB ${p.memoryKiB} is outside ${8 * p.parallelism}..${L.maxMemoryKiB}`);
|
|
41
|
+
return { name: ARGON2ID, iterations: p.iterations, memoryKiB: p.memoryKiB, parallelism: p.parallelism };
|
|
42
|
+
},
|
|
43
|
+
async derive(secret, salt, p, bits) {
|
|
44
|
+
const out = await impl({ password: secret, salt, iterations: p.iterations, memoryKiB: p.memoryKiB, parallelism: p.parallelism, hashLength: bits / 8 });
|
|
45
|
+
if (!(out instanceof Uint8Array) || out.length !== bits / 8) fail('corrupt', 'Argon2id implementation returned the wrong output');
|
|
46
|
+
return out;
|
|
47
|
+
},
|
|
48
|
+
weaker: (p, t) => p.memoryKiB < t.memoryKiB || p.iterations < t.iterations,
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/bytes.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
export const te = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
export function b64uEncode(bytes) {
|
|
6
|
+
let s = '';
|
|
7
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
8
|
+
return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Strict base64url (no padding, no other alphabets). Throws VaultError('corrupt'). */
|
|
12
|
+
export function b64uDecode(str, what = 'value') {
|
|
13
|
+
if (typeof str !== 'string' || !/^[A-Za-z0-9_-]*$/.test(str) || str.length % 4 === 1) fail('corrupt', `${what} is not base64url`);
|
|
14
|
+
const s = atob(str.replaceAll('-', '+').replaceAll('_', '/'));
|
|
15
|
+
const out = new Uint8Array(s.length);
|
|
16
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Uint8Array | ArrayBuffer | base64url string -> Uint8Array copy-free where possible. */
|
|
21
|
+
export function toBytes(v, what = 'value') {
|
|
22
|
+
if (v instanceof Uint8Array) return v;
|
|
23
|
+
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
24
|
+
if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
|
|
25
|
+
if (typeof v === 'string') return b64uDecode(v, what);
|
|
26
|
+
throw new TypeError(`${what} must be a Uint8Array, ArrayBuffer or base64url string`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const concat = (...parts) => {
|
|
30
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
31
|
+
let o = 0;
|
|
32
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sorted-key JSON (JCS-like subset: objects, arrays, strings, booleans, null, safe integers). Identical to the
|
|
38
|
+
* canonicalisation p2pws uses for its header, so p2pws files authenticate the same bytes. Throws TypeError on anything else.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalize(v) {
|
|
41
|
+
if (v === null) return 'null';
|
|
42
|
+
switch (typeof v) {
|
|
43
|
+
case 'string': return JSON.stringify(v);
|
|
44
|
+
case 'boolean': return v ? 'true' : 'false';
|
|
45
|
+
case 'number':
|
|
46
|
+
if (!Number.isSafeInteger(v)) throw new TypeError('only safe integers are allowed in a header');
|
|
47
|
+
return String(v);
|
|
48
|
+
case 'object': {
|
|
49
|
+
if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
|
|
50
|
+
const proto = Object.getPrototypeOf(v);
|
|
51
|
+
if (proto !== Object.prototype && proto !== null) throw new TypeError('plain objects only');
|
|
52
|
+
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
|
|
53
|
+
}
|
|
54
|
+
default: throw new TypeError(`unsupported header value of type ${typeof v}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/derive.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fail, requireSecret } from './errors.mjs';
|
|
2
|
+
import { te, toBytes, concat, b64uEncode } from './bytes.mjs';
|
|
3
|
+
import { defaultKdf, validateKdf, kdfOf } from './kdf.mjs';
|
|
4
|
+
|
|
5
|
+
/** The domain-separation prefix p2pws uses for its sign-in keys: salt || "\np2pws-login/" + purpose. */
|
|
6
|
+
export const P2PWS_LOGIN_PREFIX = '\np2pws-login/';
|
|
7
|
+
/** The `purpose` string that reproduces p2pws's login derivation for `name` ('auth' or 'vault'). */
|
|
8
|
+
export const p2pwsLoginPurpose = (name) => P2PWS_LOGIN_PREFIX + name;
|
|
9
|
+
/** The PBKDF2 parameters p2pws's login.mjs used before keyvault. Pass these to derive the same keys as existing logins. */
|
|
10
|
+
export const P2PWS_LOGIN_KDF = Object.freeze({ name: 'PBKDF2-SHA256', iterations: 310_000 });
|
|
11
|
+
|
|
12
|
+
const NORMALIZE = new Set([false, 'NFKC']);
|
|
13
|
+
|
|
14
|
+
/** @returns {Promise<Uint8Array>} the raw derived bytes. Callers own (and should wipe) them. */
|
|
15
|
+
export async function deriveBytes(secret, saltBytes, params, bits, normalize = false) {
|
|
16
|
+
if (!NORMALIZE.has(normalize)) throw new TypeError("normalize must be false or 'NFKC'");
|
|
17
|
+
const kdf = kdfOf(params);
|
|
18
|
+
return kdf.derive(te.encode(normalize ? secret.normalize(normalize) : secret), saltBytes, params, bits);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stretch a human secret into a 32-byte key, domain-separated by `purpose`.
|
|
23
|
+
*
|
|
24
|
+
* The bytes actually fed to the KDF as salt are `salt || utf8(purpose)`. Two different purposes therefore give unrelated keys
|
|
25
|
+
* from the same secret and salt. `purpose` is required and is used verbatim, so p2pws's exact construction is
|
|
26
|
+
* `deriveKey(pw, salt, params, p2pwsLoginPurpose('auth'))`.
|
|
27
|
+
*
|
|
28
|
+
* The secret is used exactly as given (no Unicode normalisation) unless `options.normalize` is `'NFKC'`; this matches
|
|
29
|
+
* p2pws's login derivation, which does not normalise. Sealing (`seal`/`open`) always applies NFKC, as p2pws backups do.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} secret
|
|
32
|
+
* @param {Uint8Array|ArrayBuffer|string} salt bytes, or a base64url string
|
|
33
|
+
* @param {{name: string, iterations: number}} [params] defaults to defaultKdf()
|
|
34
|
+
* @param {string} purpose non-empty domain-separation string
|
|
35
|
+
* @param {{bits?: number, normalize?: false|'NFKC', limits?: object}} [options]
|
|
36
|
+
* @returns {Promise<Uint8Array>}
|
|
37
|
+
*/
|
|
38
|
+
export async function deriveKey(secret, salt, params, purpose, options = {}) {
|
|
39
|
+
requireSecret(secret);
|
|
40
|
+
if (typeof purpose !== 'string' || purpose.length === 0) throw new TypeError('purpose must be a non-empty string');
|
|
41
|
+
const p = validateKdf(params ?? defaultKdf(), options.limits);
|
|
42
|
+
const bits = options.bits ?? 256;
|
|
43
|
+
if (!Number.isInteger(bits) || bits < 128 || bits > 512 || bits % 8) throw new TypeError('bits must be a multiple of 8 between 128 and 512');
|
|
44
|
+
const saltBytes = concat(toBytes(salt, 'salt'), te.encode(purpose));
|
|
45
|
+
return deriveBytes(secret, saltBytes, p, bits, options.normalize ?? false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* From one password derive an `authKey` (send it to a server, which should keep only a verifier of it) and a `vaultKey`
|
|
50
|
+
* (never sent; use it as the secret for `seal`). The two are unrelated. Both are base64url strings of 32 bytes.
|
|
51
|
+
*
|
|
52
|
+
* Existing p2pws logins were derived with 310,000 iterations: pass `P2PWS_LOGIN_KDF` to reproduce them, and re-seal with
|
|
53
|
+
* `defaultKdf()` on the next successful sign-in.
|
|
54
|
+
*
|
|
55
|
+
* @example const { authKey, vaultKey } = await deriveLoginKeys('correct horse battery', salt, P2PWS_LOGIN_KDF);
|
|
56
|
+
*/
|
|
57
|
+
export async function deriveLoginKeys(password, salt, params = defaultKdf(), options = {}) {
|
|
58
|
+
requireSecret(password, 1, 'password');
|
|
59
|
+
const [authKey, vaultKey] = await Promise.all(['auth', 'vault'].map(async (n) => b64uEncode(await deriveKey(password, salt, params, p2pwsLoginPurpose(n), options))));
|
|
60
|
+
return { authKey, vaultKey };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Only the vault half of `deriveLoginKeys` (p2pws's `deriveVaultKey`, for external logins). */
|
|
64
|
+
export async function deriveVaultKey(passphrase, salt, params = defaultKdf(), options = {}) {
|
|
65
|
+
requireSecret(passphrase, 1, 'passphrase');
|
|
66
|
+
return b64uEncode(await deriveKey(passphrase, salt, params, p2pwsLoginPurpose('vault'), options));
|
|
67
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure a caller is expected to handle is a VaultError with one of these stable codes.
|
|
3
|
+
* Programmer mistakes (wrong argument types for options, for example) throw TypeError instead.
|
|
4
|
+
*/
|
|
5
|
+
export const CODES = Object.freeze({
|
|
6
|
+
/** Wrong secret, or the file was modified (AES-GCM cannot tell the two apart). */
|
|
7
|
+
bad_passphrase: 'bad_passphrase',
|
|
8
|
+
/** Not a keyvault/p2pws/browsermesh record, truncated, or structurally invalid. */
|
|
9
|
+
corrupt: 'corrupt',
|
|
10
|
+
/** Valid shape but something we refuse: unknown version or KDF, parameters outside the accepted limits, KDF not enabled. */
|
|
11
|
+
unsupported: 'unsupported',
|
|
12
|
+
/** The record's `t` is not the type the caller asked for. */
|
|
13
|
+
wrong_type: 'wrong_type',
|
|
14
|
+
/** The secret is empty, undefined, not a string, or shorter than the caller's minimum. */
|
|
15
|
+
weak_secret: 'weak_secret',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export class VaultError extends Error {
|
|
19
|
+
/** @param {keyof typeof CODES} code @param {string} [message] */
|
|
20
|
+
constructor(code, message) {
|
|
21
|
+
super(message ?? code);
|
|
22
|
+
this.name = 'VaultError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const fail = (code, message) => { throw new VaultError(code, message); };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Refuse a secret that would not protect anything. PBKDF2 accepts an empty key and TextEncoder renders `undefined` as the
|
|
31
|
+
* bytes "undefined", so either would produce a well-formed ciphertext that anyone can open.
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function requireSecret(secret, minLength = 1, what = 'secret') {
|
|
35
|
+
if (typeof secret !== 'string') fail('weak_secret', `${what} must be a string, got ${secret === null ? 'null' : typeof secret}`);
|
|
36
|
+
if (secret.length === 0) fail('weak_secret', `${what} must not be empty`);
|
|
37
|
+
if (secret.length < minLength) fail('weak_secret', `${what} must be at least ${minLength} characters`);
|
|
38
|
+
return secret;
|
|
39
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { VaultError, CODES } from './errors.mjs';
|
|
2
|
+
export { defaultKdf, DEFAULT_LIMITS } from './kdf.mjs';
|
|
3
|
+
export { deriveKey, deriveLoginKeys, deriveVaultKey, p2pwsLoginPurpose, P2PWS_LOGIN_PREFIX, P2PWS_LOGIN_KDF } from './derive.mjs';
|
|
4
|
+
export { seal, open, openText, openJson, needsUpgrade, FORMAT_VERSION, BROWSERMESH_VAULT } from './seal.mjs';
|
|
5
|
+
export { prfFromPasskey } from './passkey.mjs';
|
package/src/kdf.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// KDF registry. PBKDF2-SHA256 is built in (WebCrypto). Argon2id is registered by '@johnhenry/keyvault/argon2'.
|
|
2
|
+
// Every KDF parameter set that is read from untrusted input passes through `validateKdf`, which enforces the caps.
|
|
3
|
+
import { fail } from './errors.mjs';
|
|
4
|
+
|
|
5
|
+
export const PBKDF2 = 'PBKDF2-SHA256';
|
|
6
|
+
export const ARGON2ID = 'Argon2id';
|
|
7
|
+
|
|
8
|
+
/** Hard defaults for what a file may ask of this machine. Callers can pass tighter (or looser) `limits`. */
|
|
9
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
10
|
+
minIterations: 1000, // floor when reading; below this a "KDF" is no protection at all
|
|
11
|
+
maxIterations: 10_000_000, // PBKDF2 cap: a hostile file cannot hang the tab (p2pws's cap)
|
|
12
|
+
maxArgon2Passes: 20,
|
|
13
|
+
maxMemoryKiB: 262_144, // 256 MiB: Argon2 cap, so a hostile file cannot exhaust memory
|
|
14
|
+
maxParallelism: 16,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const int = (v) => typeof v === 'number' && Number.isSafeInteger(v);
|
|
18
|
+
|
|
19
|
+
/** Raw PBKDF2 over WebCrypto. `hash` is only ever 'SHA-256' in the public API; SHA-1 exists so RFC 6070 can be checked. */
|
|
20
|
+
export async function pbkdf2Bits(secret, salt, iterations, bits, hash = 'SHA-256') {
|
|
21
|
+
const base = await crypto.subtle.importKey('raw', secret, 'PBKDF2', false, ['deriveBits']);
|
|
22
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash, salt, iterations }, base, bits));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const registry = new Map();
|
|
26
|
+
|
|
27
|
+
registry.set(PBKDF2, {
|
|
28
|
+
name: PBKDF2,
|
|
29
|
+
rank: 1,
|
|
30
|
+
validate(p, lim) {
|
|
31
|
+
if (!int(p.iterations)) fail('corrupt', 'kdf.iterations must be an integer');
|
|
32
|
+
if (p.iterations < lim.minIterations || p.iterations > lim.maxIterations) {
|
|
33
|
+
fail('unsupported', `kdf.iterations ${p.iterations} is outside the accepted range ${lim.minIterations}..${lim.maxIterations}`);
|
|
34
|
+
}
|
|
35
|
+
return { name: PBKDF2, iterations: p.iterations };
|
|
36
|
+
},
|
|
37
|
+
derive: (secret, salt, p, bits) => pbkdf2Bits(secret, salt, p.iterations, bits),
|
|
38
|
+
weaker: (p, target) => p.iterations < target.iterations,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Used by '@johnhenry/keyvault/argon2'. `impl(secret, salt, params, bits) -> Promise<Uint8Array>`. */
|
|
42
|
+
export function registerKdf(def) { registry.set(def.name, def); }
|
|
43
|
+
export const hasKdf = (name) => registry.has(name);
|
|
44
|
+
|
|
45
|
+
/** The parameters new files are written with today. A fresh object each call. */
|
|
46
|
+
export function defaultKdf() { return { name: PBKDF2, iterations: 600_000 }; }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate KDF parameters from anywhere (a file, a caller). Returns a clean copy without `salt`.
|
|
50
|
+
* @throws {VaultError} corrupt (wrong shape), unsupported (unknown name / outside limits / not enabled)
|
|
51
|
+
*/
|
|
52
|
+
export function validateKdf(params, limits) {
|
|
53
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) fail('corrupt', 'kdf must be an object');
|
|
54
|
+
if (typeof params.name !== 'string') fail('corrupt', 'kdf.name must be a string');
|
|
55
|
+
const def = registry.get(params.name);
|
|
56
|
+
if (!def) {
|
|
57
|
+
fail('unsupported', params.name === ARGON2ID
|
|
58
|
+
? "Argon2id is not enabled: import '@johnhenry/keyvault/argon2' and call enableArgon2id() first"
|
|
59
|
+
: `unknown key derivation function ${JSON.stringify(String(params.name).slice(0, 40))}`);
|
|
60
|
+
}
|
|
61
|
+
return def.validate(params, { ...DEFAULT_LIMITS, ...limits });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const kdfOf = (params) => registry.get(params.name);
|
|
65
|
+
|
|
66
|
+
/** Is `params` (validated) weaker than `target` (validated)? Different KDFs are ranked: Argon2id beats PBKDF2. */
|
|
67
|
+
export function isWeaker(params, target) {
|
|
68
|
+
const a = kdfOf(params), b = kdfOf(target);
|
|
69
|
+
if (a.rank !== b.rank) return a.rank < b.rank;
|
|
70
|
+
return a.weaker(params, target);
|
|
71
|
+
}
|
package/src/passkey.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
import { toBytes, b64uEncode } from './bytes.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get a stable per-credential secret from a passkey via the WebAuthn `prf` extension. The secret exists only when the
|
|
6
|
+
* authenticator is used, so nothing a server stores can be guessed offline. Use the result as the `secret` for `seal`/`open`.
|
|
7
|
+
*
|
|
8
|
+
* Needs a secure context and a browser/authenticator with PRF support (otherwise VaultError 'unsupported'). The user-presence
|
|
9
|
+
* prompt and its cancellation surface as the browser's own DOMException (NotAllowedError); they are not wrapped.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {Uint8Array|ArrayBuffer|string} options.credentialId the credential to use (bytes, or base64url)
|
|
13
|
+
* @param {Uint8Array|ArrayBuffer|string} options.salt application salt, 1..64 bytes; a different salt gives an unrelated secret
|
|
14
|
+
* @param {string} [options.rpId]
|
|
15
|
+
* @param {'required'|'preferred'|'discouraged'} [options.userVerification='preferred']
|
|
16
|
+
* @param {number} [options.timeout]
|
|
17
|
+
* @param {{get: Function}} [options.credentials=navigator.credentials] injectable for tests
|
|
18
|
+
* @returns {Promise<string>} the 32-byte PRF output as base64url (a string, so it satisfies the "secret is a string" rule)
|
|
19
|
+
*/
|
|
20
|
+
export async function prfFromPasskey({ credentialId, salt, rpId, userVerification = 'preferred', timeout, credentials = globalThis.navigator?.credentials } = {}) {
|
|
21
|
+
if (!credentials || typeof credentials.get !== 'function') fail('unsupported', 'WebAuthn is not available in this environment');
|
|
22
|
+
const id = toBytes(credentialId, 'credentialId');
|
|
23
|
+
const saltBytes = toBytes(salt, 'salt');
|
|
24
|
+
if (id.length === 0) throw new TypeError('credentialId must not be empty');
|
|
25
|
+
if (saltBytes.length === 0 || saltBytes.length > 64) throw new TypeError('salt must be 1..64 bytes');
|
|
26
|
+
const publicKey = {
|
|
27
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)), // the assertion is never verified server-side; PRF is the point
|
|
28
|
+
allowCredentials: [{ type: 'public-key', id }],
|
|
29
|
+
userVerification,
|
|
30
|
+
extensions: { prf: { eval: { first: saltBytes } } },
|
|
31
|
+
};
|
|
32
|
+
if (rpId) publicKey.rpId = rpId;
|
|
33
|
+
if (timeout) publicKey.timeout = timeout;
|
|
34
|
+
const cred = await credentials.get({ publicKey });
|
|
35
|
+
const first = cred?.getClientExtensionResults?.()?.prf?.results?.first;
|
|
36
|
+
if (!first) fail('unsupported', 'this passkey or browser does not support the WebAuthn PRF extension');
|
|
37
|
+
const out = ArrayBuffer.isView(first) ? new Uint8Array(first.buffer, first.byteOffset, first.byteLength) : new Uint8Array(first);
|
|
38
|
+
if (out.length !== 32) fail('corrupt', 'unexpected PRF output length');
|
|
39
|
+
return b64uEncode(out);
|
|
40
|
+
}
|
package/src/seal.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { fail, requireSecret, VaultError } from './errors.mjs';
|
|
2
|
+
import { te, b64uEncode, b64uDecode, canonicalize } from './bytes.mjs';
|
|
3
|
+
import { defaultKdf, validateKdf, isWeaker, PBKDF2 } from './kdf.mjs';
|
|
4
|
+
import { deriveBytes } from './derive.mjs';
|
|
5
|
+
|
|
6
|
+
export const FORMAT_VERSION = 1;
|
|
7
|
+
/** `type` reported by open() for records written by browsermesh's VaultIdentityStorage. */
|
|
8
|
+
export const BROWSERMESH_VAULT = 'browsermesh-vault';
|
|
9
|
+
const RESERVED = new Set(['v', 't', 'kdf', 'iv', 'ct']);
|
|
10
|
+
const SALT_BYTES = 16, IV_BYTES = 12, TAG_BYTES = 16;
|
|
11
|
+
const MAX_FILE_CHARS = 2 * 1024 * 1024; // reading: a hostile blob cannot be parsed forever
|
|
12
|
+
const MAX_PAYLOAD_BYTES = 1024 * 1024; // sealing: this is for keys, not files
|
|
13
|
+
const MAX_HEADER_CHARS = 64 * 1024;
|
|
14
|
+
const VAULT_DEFAULT_ITERATIONS = 310_000; // VaultIdentityStorage's constant, used when its envelope has no `iterations`
|
|
15
|
+
|
|
16
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
|
|
18
|
+
async function aesKey(secret, salt, params, usage, normalize) {
|
|
19
|
+
const raw = await deriveBytes(secret, salt, params, 256, normalize);
|
|
20
|
+
try { return await crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [usage]); } finally { raw.fill(0); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function kdfToFile(params, salt) {
|
|
24
|
+
const { name, ...rest } = params;
|
|
25
|
+
return { name, ...rest, salt: b64uEncode(salt) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Seal a small secret under a human secret.
|
|
30
|
+
*
|
|
31
|
+
* The file is plain JSON: `{ v: 1, t: type, kdf: {name, ...params, salt}, ...header, iv, ct }`. Everything except `ct` is bound
|
|
32
|
+
* as AES-GCM additional data, so editing the type, the KDF parameters or any header field makes `open` fail. This is
|
|
33
|
+
* byte-compatible with the files p2pws's backup.mjs writes (the secret is NFKC-normalised, as there).
|
|
34
|
+
*
|
|
35
|
+
* @param {Uint8Array|string} payload bytes, or a string (UTF-8). At most 1 MiB.
|
|
36
|
+
* @param {string} secret non-empty string (see `minLength`)
|
|
37
|
+
* @param {object} options
|
|
38
|
+
* @param {string} options.type what this record is, e.g. 'p2pws-key-backup'. Checked by open().
|
|
39
|
+
* @param {object} [options.header] extra authenticated, readable fields (strings, safe integers, booleans, null, arrays, plain objects). Not v/t/kdf/iv/ct.
|
|
40
|
+
* @param {{name: string, iterations: number}} [options.kdf] defaults to defaultKdf()
|
|
41
|
+
* @param {number} [options.minLength=1] minimum secret length, your password policy
|
|
42
|
+
* @param {object} [options.limits] see DEFAULT_LIMITS
|
|
43
|
+
* @returns {Promise<string>} JSON text
|
|
44
|
+
*/
|
|
45
|
+
export async function seal(payload, secret, options = {}) {
|
|
46
|
+
const { type, header = {}, kdf, minLength = 1, limits } = options;
|
|
47
|
+
requireSecret(secret, minLength);
|
|
48
|
+
if (typeof type !== 'string' || type.length === 0) throw new TypeError('options.type must be a non-empty string');
|
|
49
|
+
if (!isObject(header)) throw new TypeError('options.header must be an object');
|
|
50
|
+
for (const k of Object.keys(header)) if (RESERVED.has(k)) throw new TypeError(`header field "${k}" is reserved`);
|
|
51
|
+
const bytes = typeof payload === 'string' ? te.encode(payload) : payload;
|
|
52
|
+
if (!(bytes instanceof Uint8Array)) throw new TypeError('payload must be a string or Uint8Array');
|
|
53
|
+
if (bytes.length > MAX_PAYLOAD_BYTES) fail('unsupported', 'payload is larger than 1 MiB; keyvault seals keys, not files');
|
|
54
|
+
|
|
55
|
+
const params = validateKdf(kdf ?? defaultKdf(), limits);
|
|
56
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
57
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
58
|
+
const file = { v: FORMAT_VERSION, t: type, kdf: kdfToFile(params, salt), ...header, iv: b64uEncode(iv) };
|
|
59
|
+
let aad;
|
|
60
|
+
try { aad = te.encode(canonicalize(file)); } catch (e) { throw new TypeError(`header is not canonicalisable: ${e.message}`); }
|
|
61
|
+
if (aad.length > MAX_HEADER_CHARS) throw new TypeError('header is too large');
|
|
62
|
+
const key = await aesKey(secret, salt, params, 'encrypt', 'NFKC');
|
|
63
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, bytes));
|
|
64
|
+
return JSON.stringify({ ...file, ct: b64uEncode(ct) }, null, 2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse and structurally validate a record without decrypting it. Throws VaultError. Used by open() and needsUpgrade().
|
|
69
|
+
* @returns {{legacy: boolean, type: string, kdf: object, salt: Uint8Array, iv: Uint8Array, ct: Uint8Array, header: object, file: object}}
|
|
70
|
+
*/
|
|
71
|
+
export function parse(input, { limits, maxChars = MAX_FILE_CHARS } = {}) {
|
|
72
|
+
let f = input;
|
|
73
|
+
if (typeof input === 'string') {
|
|
74
|
+
if (input.length > maxChars) fail('corrupt', 'record is too large');
|
|
75
|
+
try { f = JSON.parse(input); } catch { fail('corrupt', 'not JSON (truncated or not a keyvault record)'); }
|
|
76
|
+
}
|
|
77
|
+
if (!isObject(f)) fail('corrupt', 'record must be a JSON object');
|
|
78
|
+
// A whole VaultIdentityStorage record: the envelope is its encryptedPrivateKey.
|
|
79
|
+
if (isObject(f.encryptedPrivateKey) && f.v === undefined && f.ct === undefined) f = f.encryptedPrivateKey;
|
|
80
|
+
if (f.v === undefined && f.t === undefined && typeof f.ciphertext === 'string') return parseVaultEnvelope(f, limits);
|
|
81
|
+
|
|
82
|
+
if (f.v !== FORMAT_VERSION) fail(typeof f.v === 'number' ? 'unsupported' : 'corrupt', typeof f.v === 'number' ? `unsupported record version ${f.v}` : 'not a keyvault record (no version)');
|
|
83
|
+
if (typeof f.t !== 'string' || f.t.length === 0) fail('corrupt', 'record has no type');
|
|
84
|
+
const kdf = validateKdf(f.kdf, limits);
|
|
85
|
+
const salt = b64uDecode(f.kdf.salt, 'kdf.salt');
|
|
86
|
+
if (salt.length < 8 || salt.length > 64) fail('corrupt', 'kdf.salt has a bad length');
|
|
87
|
+
const iv = b64uDecode(f.iv, 'iv');
|
|
88
|
+
if (iv.length !== IV_BYTES) fail('corrupt', 'iv has a bad length');
|
|
89
|
+
const ct = b64uDecode(f.ct, 'ct');
|
|
90
|
+
if (ct.length < TAG_BYTES) fail('corrupt', 'ciphertext is truncated');
|
|
91
|
+
const { kdf: _k, iv: _i, ct: _c, ...header } = f;
|
|
92
|
+
return { legacy: false, type: f.t, kdf, salt, iv, ct, header, file: f };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseVaultEnvelope(e, limits) {
|
|
96
|
+
if (e.kdf !== undefined && e.kdf !== PBKDF2) fail('unsupported', 'unsupported key derivation function in vault record');
|
|
97
|
+
const kdf = validateKdf({ name: PBKDF2, iterations: e.iterations ?? VAULT_DEFAULT_ITERATIONS }, limits);
|
|
98
|
+
const salt = b64uDecode(e.salt, 'salt');
|
|
99
|
+
if (salt.length < 8 || salt.length > 64) fail('corrupt', 'salt has a bad length');
|
|
100
|
+
const iv = b64uDecode(e.iv, 'iv');
|
|
101
|
+
if (iv.length !== IV_BYTES) fail('corrupt', 'iv has a bad length');
|
|
102
|
+
const ct = b64uDecode(e.ciphertext, 'ciphertext');
|
|
103
|
+
if (ct.length < TAG_BYTES) fail('corrupt', 'ciphertext is truncated');
|
|
104
|
+
return { legacy: true, type: BROWSERMESH_VAULT, kdf, salt, iv, ct, header: { t: BROWSERMESH_VAULT }, file: e };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Open a record written by `seal`, by p2pws's backup.mjs (types p2pws-key-backup, p2pws-identity-bundle,
|
|
109
|
+
* p2pws-account-recovery), or by browsermesh's VaultIdentityStorage (type 'browsermesh-vault'; the envelope or the whole record).
|
|
110
|
+
*
|
|
111
|
+
* Header fields come back only after authentication, so they can be trusted (browsermesh vault envelopes have no header).
|
|
112
|
+
* Apply your own checks to them (e.g. that a key id matches the decrypted key).
|
|
113
|
+
*
|
|
114
|
+
* @param {string|object} input the JSON text (or parsed object)
|
|
115
|
+
* @param {string} secret
|
|
116
|
+
* @param {{type?: string, limits?: object}} [options] `type`: refuse (wrong_type) anything else. Pass it.
|
|
117
|
+
* @returns {Promise<{header: object, type: string, kdf: object, payload: Uint8Array, legacy: boolean}>}
|
|
118
|
+
* @throws {VaultError} weak_secret | corrupt | unsupported | wrong_type | bad_passphrase
|
|
119
|
+
*/
|
|
120
|
+
export async function open(input, secret, options = {}) {
|
|
121
|
+
requireSecret(secret);
|
|
122
|
+
const { type, limits } = options;
|
|
123
|
+
if (type !== undefined && typeof type !== 'string') throw new TypeError('options.type must be a string');
|
|
124
|
+
const r = parse(input, { limits });
|
|
125
|
+
if (type !== undefined && r.type !== type) fail('wrong_type', `this is a ${JSON.stringify(r.type.slice(0, 60))} record, not ${JSON.stringify(type)}`);
|
|
126
|
+
const params = { name: 'AES-GCM', iv: r.iv };
|
|
127
|
+
if (!r.legacy) {
|
|
128
|
+
const { ct, ...rest } = r.file;
|
|
129
|
+
try { params.additionalData = te.encode(canonicalize(rest)); } catch { fail('corrupt', 'record header is not canonical'); }
|
|
130
|
+
}
|
|
131
|
+
// Vault envelopes were written without header binding and without NFKC; keep reading them exactly as written.
|
|
132
|
+
const key = await aesKey(secret, r.salt, r.kdf, 'decrypt', r.legacy ? false : 'NFKC');
|
|
133
|
+
let payload;
|
|
134
|
+
try { payload = new Uint8Array(await crypto.subtle.decrypt(params, key, r.ct)); } catch { fail('bad_passphrase', 'wrong secret, or the record was modified'); }
|
|
135
|
+
return { header: r.header, type: r.type, kdf: { ...r.kdf }, payload, legacy: r.legacy };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `open`, decoding the payload as UTF-8 text. */
|
|
139
|
+
export async function openText(input, secret, options) {
|
|
140
|
+
const r = await open(input, secret, options);
|
|
141
|
+
try { return { ...r, payload: new TextDecoder('utf-8', { fatal: true }).decode(r.payload) }; } catch { fail('corrupt', 'payload is not UTF-8 text'); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `open`, decoding the payload as JSON. */
|
|
145
|
+
export async function openJson(input, secret, options) {
|
|
146
|
+
const r = await openText(input, secret, options);
|
|
147
|
+
try { return { ...r, payload: JSON.parse(r.payload) }; } catch { fail('corrupt', 'payload is not JSON'); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when the record should be re-sealed: its KDF is weaker than `target` (default `defaultKdf()`), or it is a
|
|
152
|
+
* browsermesh vault envelope (no header binding). Does not decrypt, so call it before or after a successful open().
|
|
153
|
+
* @param {string|object} input
|
|
154
|
+
* @param {{name: string, iterations: number}} [target]
|
|
155
|
+
*/
|
|
156
|
+
export function needsUpgrade(input, target = defaultKdf(), options = {}) {
|
|
157
|
+
const r = parse(input, options);
|
|
158
|
+
if (r.legacy) return true;
|
|
159
|
+
return isWeaker(r.kdf, validateKdf(target, options.limits));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { VaultError };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Argon2idParams } from './index.js';
|
|
2
|
+
|
|
3
|
+
/** OWASP's minimum Argon2id profile (19 MiB, 2 passes, 1 lane), with optional overrides. */
|
|
4
|
+
export function argon2idKdf(overrides?: Partial<Omit<Argon2idParams, 'name'>>): Argon2idParams;
|
|
5
|
+
|
|
6
|
+
export interface Argon2idImplementation {
|
|
7
|
+
(args: { password: Uint8Array; salt: Uint8Array; iterations: number; memoryKiB: number; parallelism: number; hashLength: number }): Promise<Uint8Array> | Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
/** Register Argon2id so `seal`, `open`, `deriveKey` and `needsUpgrade` understand it. Loads the optional `hash-wasm` unless `hash` is given. */
|
|
10
|
+
export function enableArgon2id(options?: { hash?: Argon2idImplementation }): Promise<void>;
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type VaultErrorCode = 'bad_passphrase' | 'corrupt' | 'unsupported' | 'wrong_type' | 'weak_secret';
|
|
2
|
+
|
|
3
|
+
export class VaultError extends Error {
|
|
4
|
+
constructor(code: VaultErrorCode, message?: string);
|
|
5
|
+
readonly name: 'VaultError';
|
|
6
|
+
readonly code: VaultErrorCode;
|
|
7
|
+
}
|
|
8
|
+
export const CODES: Readonly<Record<VaultErrorCode, VaultErrorCode>>;
|
|
9
|
+
|
|
10
|
+
/** PBKDF2 parameters. */
|
|
11
|
+
export interface Pbkdf2Params { name: 'PBKDF2-SHA256'; iterations: number }
|
|
12
|
+
/** Argon2id parameters (needs `@johnhenry/keyvault/argon2`). `iterations` is the number of passes. */
|
|
13
|
+
export interface Argon2idParams { name: 'Argon2id'; iterations: number; memoryKiB: number; parallelism: number }
|
|
14
|
+
export type KdfParams = Pbkdf2Params | Argon2idParams;
|
|
15
|
+
|
|
16
|
+
export interface Limits {
|
|
17
|
+
minIterations?: number;
|
|
18
|
+
maxIterations?: number;
|
|
19
|
+
maxArgon2Passes?: number;
|
|
20
|
+
maxMemoryKiB?: number;
|
|
21
|
+
maxParallelism?: number;
|
|
22
|
+
}
|
|
23
|
+
export const DEFAULT_LIMITS: Readonly<Required<Limits>>;
|
|
24
|
+
|
|
25
|
+
/** `{ name: 'PBKDF2-SHA256', iterations: 600000 }` today. A fresh object each call. */
|
|
26
|
+
export function defaultKdf(): Pbkdf2Params;
|
|
27
|
+
|
|
28
|
+
export type Bytes = Uint8Array | ArrayBuffer;
|
|
29
|
+
|
|
30
|
+
export interface DeriveOptions {
|
|
31
|
+
/** Output size in bits, a multiple of 8 from 128 to 512. Default 256. */
|
|
32
|
+
bits?: number;
|
|
33
|
+
/** Unicode-normalise the secret first. Default `false`, which is what p2pws's login derivation does. */
|
|
34
|
+
normalize?: false | 'NFKC';
|
|
35
|
+
limits?: Limits;
|
|
36
|
+
}
|
|
37
|
+
export function deriveKey(secret: string, salt: Bytes | string, params: KdfParams | undefined, purpose: string, options?: DeriveOptions): Promise<Uint8Array>;
|
|
38
|
+
export function deriveLoginKeys(password: string, salt: Bytes | string, params?: KdfParams, options?: DeriveOptions): Promise<{ authKey: string; vaultKey: string }>;
|
|
39
|
+
export function deriveVaultKey(passphrase: string, salt: Bytes | string, params?: KdfParams, options?: DeriveOptions): Promise<string>;
|
|
40
|
+
export function p2pwsLoginPurpose(name: 'auth' | 'vault' | (string & {})): string;
|
|
41
|
+
export const P2PWS_LOGIN_PREFIX: '\np2pws-login/';
|
|
42
|
+
/** `{ name: 'PBKDF2-SHA256', iterations: 310000 }`: what existing p2pws logins were derived with. */
|
|
43
|
+
export const P2PWS_LOGIN_KDF: Readonly<Pbkdf2Params>;
|
|
44
|
+
|
|
45
|
+
export type HeaderValue = string | number | boolean | null | HeaderValue[] | { [key: string]: HeaderValue };
|
|
46
|
+
export interface SealOptions {
|
|
47
|
+
/** What this record is, e.g. `'p2pws-key-backup'`. Checked by `open`. */
|
|
48
|
+
type: string;
|
|
49
|
+
/** Extra readable, authenticated fields. Not `v`, `t`, `kdf`, `iv` or `ct`. Numbers must be safe integers. */
|
|
50
|
+
header?: { [key: string]: HeaderValue };
|
|
51
|
+
kdf?: KdfParams;
|
|
52
|
+
/** Your password policy: refuse secrets shorter than this. Default 1. */
|
|
53
|
+
minLength?: number;
|
|
54
|
+
limits?: Limits;
|
|
55
|
+
}
|
|
56
|
+
export function seal(payload: Uint8Array | string, secret: string, options: SealOptions): Promise<string>;
|
|
57
|
+
|
|
58
|
+
export interface OpenOptions { type?: string; limits?: Limits }
|
|
59
|
+
export interface Opened<P = Uint8Array> {
|
|
60
|
+
/** Authenticated fields (`v`, `t` and everything given as `header`). For browsermesh vault records: `{ t: 'browsermesh-vault' }`. */
|
|
61
|
+
header: { v?: number; t: string; [key: string]: HeaderValue | undefined };
|
|
62
|
+
type: string;
|
|
63
|
+
kdf: KdfParams;
|
|
64
|
+
payload: P;
|
|
65
|
+
/** True for browsermesh VaultIdentityStorage envelopes (no header binding): re-seal them. */
|
|
66
|
+
legacy: boolean;
|
|
67
|
+
}
|
|
68
|
+
export function open(input: string | object, secret: string, options?: OpenOptions): Promise<Opened>;
|
|
69
|
+
export function openText(input: string | object, secret: string, options?: OpenOptions): Promise<Opened<string>>;
|
|
70
|
+
export function openJson<T = unknown>(input: string | object, secret: string, options?: OpenOptions): Promise<Opened<T>>;
|
|
71
|
+
export function needsUpgrade(input: string | object, target?: KdfParams, options?: { limits?: Limits }): boolean;
|
|
72
|
+
|
|
73
|
+
export const FORMAT_VERSION: 1;
|
|
74
|
+
export const BROWSERMESH_VAULT: 'browsermesh-vault';
|
|
75
|
+
|
|
76
|
+
export interface PrfOptions {
|
|
77
|
+
credentialId: Bytes | string;
|
|
78
|
+
salt: Bytes | string;
|
|
79
|
+
rpId?: string;
|
|
80
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
81
|
+
timeout?: number;
|
|
82
|
+
credentials?: { get(options: any): Promise<any> };
|
|
83
|
+
}
|
|
84
|
+
/** The 32-byte PRF output as base64url; use it as the `secret` for `seal`/`open`. */
|
|
85
|
+
export function prfFromPasskey(options: PrfOptions): Promise<string>;
|