@getmyenv/crypto 0.4.1 → 0.6.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/dist/backup.d.ts +6 -5
- package/dist/backup.js +47 -9
- package/dist/envelope.d.ts +2 -2
- package/dist/envelope.js +6 -52
- package/dist/fingerprint.d.ts +6 -0
- package/dist/fingerprint.js +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/recovery.d.ts +29 -0
- package/dist/recovery.js +146 -0
- package/dist/types.d.ts +8 -3
- package/dist/types.js +3 -1
- package/dist/wrap.d.ts +16 -0
- package/dist/wrap.js +60 -0
- package/package.json +1 -1
package/dist/backup.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare function createBackupEnvelope(input: {
|
|
|
3
3
|
projectName: string;
|
|
4
4
|
publicKey: string;
|
|
5
5
|
wrappedPrivateKey: WrappedPrivateKey;
|
|
6
|
+
recoveryWrappedKey?: WrappedPrivateKey | null;
|
|
6
7
|
contexts: BackupContext[];
|
|
7
8
|
keys: string[];
|
|
8
9
|
tags?: Record<string, string>;
|
|
@@ -11,15 +12,15 @@ export declare function createBackupEnvelope(input: {
|
|
|
11
12
|
export declare function serializeBackup(envelope: EncryptedBackupEnvelope): string;
|
|
12
13
|
export declare function parseBackup(raw: string): EncryptedBackupEnvelope;
|
|
13
14
|
/**
|
|
14
|
-
* Open every context key in a backup with the Vault password
|
|
15
|
-
* Keyed by context slug. Caller wipes the returned keys
|
|
15
|
+
* Open every context key in a backup with the Vault password or the recovery
|
|
16
|
+
* code it was made with. Keyed by context slug. Caller wipes the returned keys.
|
|
16
17
|
*/
|
|
17
|
-
export declare function openBackupKeys(
|
|
18
|
+
export declare function openBackupKeys(secret: string, envelope: EncryptedBackupEnvelope): Promise<Map<string, Uint8Array>>;
|
|
18
19
|
/**
|
|
19
|
-
* Decrypt every value in a backup with the Vault password.
|
|
20
|
+
* Decrypt every value in a backup with the Vault password or the recovery code.
|
|
20
21
|
* Works without getmyenv being reachable.
|
|
21
22
|
*/
|
|
22
|
-
export declare function restoreValuesFromBackup(
|
|
23
|
+
export declare function restoreValuesFromBackup(secret: string, envelope: EncryptedBackupEnvelope): Promise<{
|
|
23
24
|
contexts: BackupContext[];
|
|
24
25
|
keys: string[];
|
|
25
26
|
values: Array<{
|
package/dist/backup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { BACKUP_FORMAT_VERSION } from "./types.js";
|
|
1
|
+
import { BACKUP_FORMAT_VERSION, READABLE_BACKUP_VERSIONS } from "./types.js";
|
|
2
2
|
import { decryptSecret, openSealedKey, parseSealedKey, parseWrappedKey, publicKeyFromPrivate, unwrapPrivateKey, wipe, } from "./envelope.js";
|
|
3
|
+
import { looksLikeRecoveryCode, parseRecoveryCode, unwrapWithRecoveryCode } from "./recovery.js";
|
|
3
4
|
export function createBackupEnvelope(input) {
|
|
4
5
|
const hasTags = input.tags && Object.keys(input.tags).length > 0;
|
|
5
6
|
return {
|
|
@@ -9,6 +10,7 @@ export function createBackupEnvelope(input) {
|
|
|
9
10
|
projectName: input.projectName,
|
|
10
11
|
publicKey: input.publicKey,
|
|
11
12
|
wrappedPrivateKey: input.wrappedPrivateKey,
|
|
13
|
+
recoveryWrappedKey: input.recoveryWrappedKey ?? null,
|
|
12
14
|
contexts: input.contexts,
|
|
13
15
|
keys: input.keys,
|
|
14
16
|
...(hasTags ? { tags: input.tags } : {}),
|
|
@@ -23,7 +25,7 @@ export function parseBackup(raw) {
|
|
|
23
25
|
if (parsed.format !== "getmyenv-backup") {
|
|
24
26
|
throw new Error("Not a getmyenv backup file");
|
|
25
27
|
}
|
|
26
|
-
if (parsed.version
|
|
28
|
+
if (!READABLE_BACKUP_VERSIONS.includes(parsed.version)) {
|
|
27
29
|
throw new Error(`Unsupported backup version: ${String(parsed.version)}`);
|
|
28
30
|
}
|
|
29
31
|
if (typeof parsed.projectName !== "string" ||
|
|
@@ -34,6 +36,9 @@ export function parseBackup(raw) {
|
|
|
34
36
|
throw new Error("Invalid backup file");
|
|
35
37
|
}
|
|
36
38
|
parseWrappedKey(JSON.stringify(parsed.wrappedPrivateKey));
|
|
39
|
+
if (parsed.recoveryWrappedKey != null) {
|
|
40
|
+
parseWrappedKey(JSON.stringify(parsed.recoveryWrappedKey));
|
|
41
|
+
}
|
|
37
42
|
for (const c of parsed.contexts) {
|
|
38
43
|
if (!Number.isInteger(c.id) || typeof c.slug !== "string") {
|
|
39
44
|
throw new Error("Invalid backup file");
|
|
@@ -43,12 +48,45 @@ export function parseBackup(raw) {
|
|
|
43
48
|
}
|
|
44
49
|
return parsed;
|
|
45
50
|
}
|
|
51
|
+
function recoveryCodeOrNull(secret) {
|
|
52
|
+
if (!looksLikeRecoveryCode(secret))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
return parseRecoveryCode(secret);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The backup's private key, from the Vault password or the recovery code it was made with. */
|
|
62
|
+
async function openBackupPrivateKey(secret, envelope) {
|
|
63
|
+
const code = recoveryCodeOrNull(secret);
|
|
64
|
+
if (code && envelope.recoveryWrappedKey) {
|
|
65
|
+
try {
|
|
66
|
+
return await unwrapWithRecoveryCode(code, envelope.recoveryWrappedKey);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error("That recovery code does not open this backup.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return await unwrapPrivateKey(secret, envelope.wrappedPrivateKey);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (code) {
|
|
77
|
+
throw new Error(envelope.version === 3
|
|
78
|
+
? "This backup was made before recovery codes. Use the Vault password it was made with."
|
|
79
|
+
: "This backup has no recovery code. Use the Vault password it was made with.");
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
46
84
|
/**
|
|
47
|
-
* Open every context key in a backup with the Vault password
|
|
48
|
-
* Keyed by context slug. Caller wipes the returned keys
|
|
85
|
+
* Open every context key in a backup with the Vault password or the recovery
|
|
86
|
+
* code it was made with. Keyed by context slug. Caller wipes the returned keys.
|
|
49
87
|
*/
|
|
50
|
-
export async function openBackupKeys(
|
|
51
|
-
const privateKey = await
|
|
88
|
+
export async function openBackupKeys(secret, envelope) {
|
|
89
|
+
const privateKey = await openBackupPrivateKey(secret, envelope);
|
|
52
90
|
try {
|
|
53
91
|
if (publicKeyFromPrivate(privateKey) !== envelope.publicKey) {
|
|
54
92
|
throw new Error("Backup key mismatch");
|
|
@@ -65,11 +103,11 @@ export async function openBackupKeys(vaultPassword, envelope) {
|
|
|
65
103
|
}
|
|
66
104
|
}
|
|
67
105
|
/**
|
|
68
|
-
* Decrypt every value in a backup with the Vault password.
|
|
106
|
+
* Decrypt every value in a backup with the Vault password or the recovery code.
|
|
69
107
|
* Works without getmyenv being reachable.
|
|
70
108
|
*/
|
|
71
|
-
export async function restoreValuesFromBackup(
|
|
72
|
-
const contextKeys = await openBackupKeys(
|
|
109
|
+
export async function restoreValuesFromBackup(secret, envelope) {
|
|
110
|
+
const contextKeys = await openBackupKeys(secret, envelope);
|
|
73
111
|
try {
|
|
74
112
|
return {
|
|
75
113
|
contexts: envelope.contexts,
|
package/dist/envelope.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type EncryptedSecretPayload, type SealedKey, type WrappedPrivateKey } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { wipe } from "./wrap.js";
|
|
3
|
+
export { wipe };
|
|
4
4
|
/** A fresh X25519 keypair. Used for server tokens and guest projects. */
|
|
5
5
|
export declare function generateKeypair(): {
|
|
6
6
|
publicKey: string;
|
package/dist/envelope.js
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
|
-
import { gcm } from "@noble/ciphers/aes.js";
|
|
2
1
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
3
2
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
4
3
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
5
4
|
import { ALGORITHM, CRYPTO_FORMAT_VERSION, DEFAULT_KDF_PARAMS, KDF, SEAL, } from "./types.js";
|
|
6
|
-
import { base64ToBytes, base64UrlToBytes, bytesToBase64, bytesToBase64Url,
|
|
7
|
-
import { assertKdfParams
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { base64ToBytes, base64UrlToBytes, bytesToBase64, bytesToBase64Url, utf8Decode, utf8Encode, } from "./bytes.js";
|
|
6
|
+
import { assertKdfParams } from "./kdf.js";
|
|
7
|
+
import { aesGcmDecrypt, aesGcmEncrypt, assertKey, KEY_LENGTH, unwrapKeyWith, wipe, wrapKeyWith, } from "./wrap.js";
|
|
8
|
+
export { wipe };
|
|
10
9
|
const AAD_USER_KEY = "getmyenv:v2:userkey";
|
|
11
10
|
const HKDF_INFO_SEAL = "getmyenv:v2:seal";
|
|
12
|
-
/** Overwrite key material in place. */
|
|
13
|
-
export function wipe(...buffers) {
|
|
14
|
-
for (const b of buffers)
|
|
15
|
-
b?.fill(0);
|
|
16
|
-
}
|
|
17
|
-
function aesGcmEncrypt(key, plaintext, aad) {
|
|
18
|
-
const nonce = getRandomBytes(NONCE_LENGTH);
|
|
19
|
-
const ciphertext = gcm(key, nonce, utf8Encode(aad)).encrypt(plaintext);
|
|
20
|
-
return { nonce, ciphertext };
|
|
21
|
-
}
|
|
22
|
-
function aesGcmDecrypt(key, nonce, ciphertext, aad) {
|
|
23
|
-
return gcm(key, nonce, utf8Encode(aad)).decrypt(ciphertext);
|
|
24
|
-
}
|
|
25
|
-
function assertKey(key) {
|
|
26
|
-
if (key.length !== KEY_LENGTH)
|
|
27
|
-
throw new Error("Invalid key length");
|
|
28
|
-
}
|
|
29
11
|
/** A fresh X25519 keypair. Used for server tokens and guest projects. */
|
|
30
12
|
export function generateKeypair() {
|
|
31
13
|
const privateKey = x25519.utils.randomSecretKey();
|
|
@@ -47,39 +29,11 @@ export function publicKeyFingerprint(publicKey) {
|
|
|
47
29
|
}
|
|
48
30
|
/** Wrap an X25519 private key with a key derived from a password. */
|
|
49
31
|
export async function wrapPrivateKey(password, privateKey) {
|
|
50
|
-
|
|
51
|
-
const salt = generateSalt();
|
|
52
|
-
const kek = await deriveKek(password, salt);
|
|
53
|
-
try {
|
|
54
|
-
const { nonce, ciphertext } = aesGcmEncrypt(kek, privateKey, AAD_USER_KEY);
|
|
55
|
-
return {
|
|
56
|
-
version: CRYPTO_FORMAT_VERSION,
|
|
57
|
-
kdf: KDF,
|
|
58
|
-
kdfParams: { ...DEFAULT_KDF_PARAMS },
|
|
59
|
-
salt: bytesToBase64(salt),
|
|
60
|
-
nonce: bytesToBase64(nonce),
|
|
61
|
-
ciphertext: bytesToBase64(ciphertext),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
wipe(kek);
|
|
66
|
-
}
|
|
32
|
+
return wrapKeyWith(password, privateKey, AAD_USER_KEY, DEFAULT_KDF_PARAMS);
|
|
67
33
|
}
|
|
68
34
|
/** Throws when the password is wrong or the blob was changed. */
|
|
69
35
|
export async function unwrapPrivateKey(password, wrapped) {
|
|
70
|
-
|
|
71
|
-
throw new Error("Unsupported wrapped key");
|
|
72
|
-
}
|
|
73
|
-
assertKdfParams(wrapped.kdfParams);
|
|
74
|
-
const kek = await deriveKek(password, base64ToBytes(wrapped.salt), wrapped.kdfParams);
|
|
75
|
-
try {
|
|
76
|
-
const key = aesGcmDecrypt(kek, base64ToBytes(wrapped.nonce), base64ToBytes(wrapped.ciphertext), AAD_USER_KEY);
|
|
77
|
-
assertKey(key);
|
|
78
|
-
return key;
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
wipe(kek);
|
|
82
|
-
}
|
|
36
|
+
return unwrapKeyWith(password, wrapped, AAD_USER_KEY);
|
|
83
37
|
}
|
|
84
38
|
/** New user keypair, wrapped with the Vault password. */
|
|
85
39
|
export async function createUserKeys(password) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change detection for a push: HMAC-SHA256 over the sorted name/value pairs,
|
|
3
|
+
* keyed from the context key. Not a protection for the values. The key only
|
|
4
|
+
* stops the server, which stores the result, from guessing short values.
|
|
5
|
+
*/
|
|
6
|
+
export declare function pushFingerprint(contextKey: Uint8Array, values: Record<string, string>): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
2
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
4
|
+
import { bytesToBase64Url, utf8Encode } from "./bytes.js";
|
|
5
|
+
import { wipe } from "./wrap.js";
|
|
6
|
+
const HKDF_INFO_PUSH = "getmyenv push fingerprint";
|
|
7
|
+
/**
|
|
8
|
+
* Change detection for a push: HMAC-SHA256 over the sorted name/value pairs,
|
|
9
|
+
* keyed from the context key. Not a protection for the values. The key only
|
|
10
|
+
* stops the server, which stores the result, from guessing short values.
|
|
11
|
+
*/
|
|
12
|
+
export function pushFingerprint(contextKey, values) {
|
|
13
|
+
const key = hkdf(sha256, contextKey, new Uint8Array(0), utf8Encode(HKDF_INFO_PUSH), 32);
|
|
14
|
+
const lines = Object.keys(values)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((name) => `${name}\0${values[name]}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
const mac = hmac(sha256, key, utf8Encode(lines));
|
|
19
|
+
wipe(key);
|
|
20
|
+
return bytesToBase64Url(mac);
|
|
21
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { KdfParams, WrappedPrivateKey } from "./types.js";
|
|
2
|
+
/** The code carries 128 bits of entropy, so the lowest accepted Argon2id cost is enough. */
|
|
3
|
+
export declare const RECOVERY_KDF_PARAMS: KdfParams;
|
|
4
|
+
/** XXXX-XXXX-... from the canonical form. */
|
|
5
|
+
export declare function formatRecoveryCode(code: string): string;
|
|
6
|
+
/** A new random recovery code, formatted for display. */
|
|
7
|
+
export declare function generateRecoveryCode(): string;
|
|
8
|
+
/** True when the input has the shape of a recovery code, whether or not its checksum is right. */
|
|
9
|
+
export declare function looksLikeRecoveryCode(input: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The canonical form (28 characters, no dashes) used as the KDF input.
|
|
12
|
+
* Formatting never changes the key. Throws on a typo.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseRecoveryCode(input: string): string;
|
|
15
|
+
/** Same code, ignoring case, spaces and dashes. False when either one is not a valid code. */
|
|
16
|
+
export declare function recoveryCodesMatch(a: string, b: string): boolean;
|
|
17
|
+
export declare function wrapWithRecoveryCode(code: string, privateKey: Uint8Array): Promise<WrappedPrivateKey>;
|
|
18
|
+
/** Throws "That recovery code does not open this vault." when the code is wrong. */
|
|
19
|
+
export declare function unwrapWithRecoveryCode(code: string, wrapped: WrappedPrivateKey): Promise<Uint8Array>;
|
|
20
|
+
/** A new recovery code and the private key wrapped with it. */
|
|
21
|
+
export declare function createRecoveryWrap(privateKey: Uint8Array): Promise<{
|
|
22
|
+
code: string;
|
|
23
|
+
wrapped: WrappedPrivateKey;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Check a recovery wrap before upload: it must open with the code and hold the
|
|
27
|
+
* private key of this public key. The server cannot check this.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertRecoveryWrap(code: string, wrapped: WrappedPrivateKey, publicKey: string): Promise<void>;
|
package/dist/recovery.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
|
+
import { getRandomBytes } from "./bytes.js";
|
|
3
|
+
import { publicKeyFromPrivate } from "./envelope.js";
|
|
4
|
+
import { unwrapKeyWith, wipe, wrapKeyWith } from "./wrap.js";
|
|
5
|
+
/**
|
|
6
|
+
* Recovery code: 16 random bytes and 1 checksum byte, in Crockford base32,
|
|
7
|
+
* shown as 7 groups of 4. It wraps the same private key as the Vault password,
|
|
8
|
+
* so either one opens the vault.
|
|
9
|
+
*/
|
|
10
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
11
|
+
const SECRET_BYTES = 16;
|
|
12
|
+
const CODE_CHARS = 28;
|
|
13
|
+
const AAD_RECOVERY_KEY = "getmyenv:v2:recoverykey";
|
|
14
|
+
/** The code carries 128 bits of entropy, so the lowest accepted Argon2id cost is enough. */
|
|
15
|
+
export const RECOVERY_KDF_PARAMS = {
|
|
16
|
+
memory: 19_456,
|
|
17
|
+
iterations: 2,
|
|
18
|
+
parallelism: 1,
|
|
19
|
+
hashLength: 32,
|
|
20
|
+
};
|
|
21
|
+
function checksum(secret) {
|
|
22
|
+
return sha256(secret)[0];
|
|
23
|
+
}
|
|
24
|
+
function encode(bytes) {
|
|
25
|
+
let out = "";
|
|
26
|
+
let buffer = 0;
|
|
27
|
+
let bits = 0;
|
|
28
|
+
for (const b of bytes) {
|
|
29
|
+
buffer = (buffer << 8) | b;
|
|
30
|
+
bits += 8;
|
|
31
|
+
while (bits >= 5) {
|
|
32
|
+
out += ALPHABET[(buffer >>> (bits - 5)) & 31];
|
|
33
|
+
bits -= 5;
|
|
34
|
+
}
|
|
35
|
+
buffer &= (1 << bits) - 1;
|
|
36
|
+
}
|
|
37
|
+
if (bits > 0)
|
|
38
|
+
out += ALPHABET[(buffer << (5 - bits)) & 31];
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function decode(chars) {
|
|
42
|
+
const out = [];
|
|
43
|
+
let buffer = 0;
|
|
44
|
+
let bits = 0;
|
|
45
|
+
for (const c of chars) {
|
|
46
|
+
const v = ALPHABET.indexOf(c);
|
|
47
|
+
if (v < 0)
|
|
48
|
+
return null;
|
|
49
|
+
buffer = (buffer << 5) | v;
|
|
50
|
+
bits += 5;
|
|
51
|
+
if (bits >= 8) {
|
|
52
|
+
out.push((buffer >>> (bits - 8)) & 255);
|
|
53
|
+
bits -= 8;
|
|
54
|
+
}
|
|
55
|
+
buffer &= (1 << bits) - 1;
|
|
56
|
+
}
|
|
57
|
+
if (buffer !== 0)
|
|
58
|
+
return null;
|
|
59
|
+
return new Uint8Array(out);
|
|
60
|
+
}
|
|
61
|
+
function normalize(input) {
|
|
62
|
+
return input
|
|
63
|
+
.toUpperCase()
|
|
64
|
+
.replace(/[\s-]/g, "")
|
|
65
|
+
.replace(/O/g, "0")
|
|
66
|
+
.replace(/[IL]/g, "1");
|
|
67
|
+
}
|
|
68
|
+
/** XXXX-XXXX-... from the canonical form. */
|
|
69
|
+
export function formatRecoveryCode(code) {
|
|
70
|
+
return normalize(code).match(/.{1,4}/g).join("-");
|
|
71
|
+
}
|
|
72
|
+
/** A new random recovery code, formatted for display. */
|
|
73
|
+
export function generateRecoveryCode() {
|
|
74
|
+
const secret = getRandomBytes(SECRET_BYTES);
|
|
75
|
+
const bytes = new Uint8Array(SECRET_BYTES + 1);
|
|
76
|
+
bytes.set(secret, 0);
|
|
77
|
+
bytes[SECRET_BYTES] = checksum(secret);
|
|
78
|
+
wipe(secret);
|
|
79
|
+
const code = formatRecoveryCode(encode(bytes));
|
|
80
|
+
wipe(bytes);
|
|
81
|
+
return code;
|
|
82
|
+
}
|
|
83
|
+
/** True when the input has the shape of a recovery code, whether or not its checksum is right. */
|
|
84
|
+
export function looksLikeRecoveryCode(input) {
|
|
85
|
+
const n = normalize(input);
|
|
86
|
+
return n.length === CODE_CHARS && [...n].every((c) => ALPHABET.includes(c));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The canonical form (28 characters, no dashes) used as the KDF input.
|
|
90
|
+
* Formatting never changes the key. Throws on a typo.
|
|
91
|
+
*/
|
|
92
|
+
export function parseRecoveryCode(input) {
|
|
93
|
+
const n = normalize(input);
|
|
94
|
+
if (n.length !== CODE_CHARS)
|
|
95
|
+
throw new Error(`A recovery code has ${CODE_CHARS} characters.`);
|
|
96
|
+
const bytes = decode(n);
|
|
97
|
+
if (!bytes || bytes.length !== SECRET_BYTES + 1)
|
|
98
|
+
throw new Error("That recovery code has a typo.");
|
|
99
|
+
const ok = checksum(bytes.subarray(0, SECRET_BYTES)) === bytes[SECRET_BYTES];
|
|
100
|
+
wipe(bytes);
|
|
101
|
+
if (!ok)
|
|
102
|
+
throw new Error("That recovery code has a typo.");
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
/** Same code, ignoring case, spaces and dashes. False when either one is not a valid code. */
|
|
106
|
+
export function recoveryCodesMatch(a, b) {
|
|
107
|
+
try {
|
|
108
|
+
return parseRecoveryCode(a) === parseRecoveryCode(b);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function wrapWithRecoveryCode(code, privateKey) {
|
|
115
|
+
return wrapKeyWith(parseRecoveryCode(code), privateKey, AAD_RECOVERY_KEY, RECOVERY_KDF_PARAMS);
|
|
116
|
+
}
|
|
117
|
+
/** Throws "That recovery code does not open this vault." when the code is wrong. */
|
|
118
|
+
export async function unwrapWithRecoveryCode(code, wrapped) {
|
|
119
|
+
const canonical = parseRecoveryCode(code);
|
|
120
|
+
try {
|
|
121
|
+
return await unwrapKeyWith(canonical, wrapped, AAD_RECOVERY_KEY);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error("That recovery code does not open this vault.");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** A new recovery code and the private key wrapped with it. */
|
|
128
|
+
export async function createRecoveryWrap(privateKey) {
|
|
129
|
+
const code = generateRecoveryCode();
|
|
130
|
+
return { code, wrapped: await wrapWithRecoveryCode(code, privateKey) };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Check a recovery wrap before upload: it must open with the code and hold the
|
|
134
|
+
* private key of this public key. The server cannot check this.
|
|
135
|
+
*/
|
|
136
|
+
export async function assertRecoveryWrap(code, wrapped, publicKey) {
|
|
137
|
+
const key = await unwrapWithRecoveryCode(code, wrapped);
|
|
138
|
+
try {
|
|
139
|
+
if (publicKeyFromPrivate(key) !== publicKey) {
|
|
140
|
+
throw new Error("The recovery code does not match this vault key.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
wipe(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -59,7 +59,9 @@ export type EncryptedSecretPayload = {
|
|
|
59
59
|
nonce: string;
|
|
60
60
|
ciphertext: string;
|
|
61
61
|
};
|
|
62
|
-
export declare const BACKUP_FORMAT_VERSION:
|
|
62
|
+
export declare const BACKUP_FORMAT_VERSION: 4;
|
|
63
|
+
/** Versions parseBackup reads. v3 has no recovery wrap and opens with its Vault password only. */
|
|
64
|
+
export declare const READABLE_BACKUP_VERSIONS: readonly [3, 4];
|
|
63
65
|
export type BackupContext = {
|
|
64
66
|
/** Context id when the backup was made. Part of the sealed key's binding. */
|
|
65
67
|
id: number;
|
|
@@ -78,15 +80,18 @@ export type BackupValue = {
|
|
|
78
80
|
};
|
|
79
81
|
/**
|
|
80
82
|
* Portable encrypted backup. Opens with the Vault password that was set when
|
|
81
|
-
* it was made
|
|
83
|
+
* it was made, or with the recovery code of that time: it carries both wraps
|
|
84
|
+
* of the private key and the context keys sealed to it.
|
|
82
85
|
*/
|
|
83
86
|
export type EncryptedBackupEnvelope = {
|
|
84
87
|
format: "getmyenv-backup";
|
|
85
|
-
version: typeof
|
|
88
|
+
version: (typeof READABLE_BACKUP_VERSIONS)[number];
|
|
86
89
|
createdAt: string;
|
|
87
90
|
projectName: string;
|
|
88
91
|
publicKey: string;
|
|
89
92
|
wrappedPrivateKey: WrappedPrivateKey;
|
|
93
|
+
/** The private key wrapped with the recovery code. Absent in v3, null for guests and vaults without a code. */
|
|
94
|
+
recoveryWrappedKey?: WrappedPrivateKey | null;
|
|
90
95
|
contexts: BackupContext[];
|
|
91
96
|
/** Every variable name in the project, including ones with no values. */
|
|
92
97
|
keys: string[];
|
package/dist/types.js
CHANGED
|
@@ -17,4 +17,6 @@ export const KDF_BOUNDS = {
|
|
|
17
17
|
parallelism: { min: 1, max: 4 },
|
|
18
18
|
hashLength: 32,
|
|
19
19
|
};
|
|
20
|
-
export const BACKUP_FORMAT_VERSION =
|
|
20
|
+
export const BACKUP_FORMAT_VERSION = 4;
|
|
21
|
+
/** Versions parseBackup reads. v3 has no recovery wrap and opens with its Vault password only. */
|
|
22
|
+
export const READABLE_BACKUP_VERSIONS = [3, 4];
|
package/dist/wrap.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type KdfParams, type WrappedPrivateKey } from "./types.js";
|
|
2
|
+
/** Internal. Not exported from the package index. */
|
|
3
|
+
export declare const NONCE_LENGTH = 12;
|
|
4
|
+
export declare const KEY_LENGTH = 32;
|
|
5
|
+
/** Overwrite key material in place. */
|
|
6
|
+
export declare function wipe(...buffers: Array<Uint8Array | null | undefined>): void;
|
|
7
|
+
export declare function assertKey(key: Uint8Array): void;
|
|
8
|
+
export declare function aesGcmEncrypt(key: Uint8Array, plaintext: Uint8Array, aad: string): {
|
|
9
|
+
nonce: Uint8Array;
|
|
10
|
+
ciphertext: Uint8Array;
|
|
11
|
+
};
|
|
12
|
+
export declare function aesGcmDecrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad: string): Uint8Array;
|
|
13
|
+
/** Wrap a private key with a key derived from a secret. The AAD says which kind of secret. */
|
|
14
|
+
export declare function wrapKeyWith(secret: string, privateKey: Uint8Array, aad: string, params: KdfParams): Promise<WrappedPrivateKey>;
|
|
15
|
+
/** Throws when the secret is wrong, the AAD differs, or the blob was changed. */
|
|
16
|
+
export declare function unwrapKeyWith(secret: string, wrapped: WrappedPrivateKey, aad: string): Promise<Uint8Array>;
|
package/dist/wrap.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
2
|
+
import { CRYPTO_FORMAT_VERSION, KDF } from "./types.js";
|
|
3
|
+
import { base64ToBytes, bytesToBase64, getRandomBytes, utf8Encode } from "./bytes.js";
|
|
4
|
+
import { assertKdfParams, deriveKek, generateSalt } from "./kdf.js";
|
|
5
|
+
/** Internal. Not exported from the package index. */
|
|
6
|
+
export const NONCE_LENGTH = 12;
|
|
7
|
+
export const KEY_LENGTH = 32;
|
|
8
|
+
/** Overwrite key material in place. */
|
|
9
|
+
export function wipe(...buffers) {
|
|
10
|
+
for (const b of buffers)
|
|
11
|
+
b?.fill(0);
|
|
12
|
+
}
|
|
13
|
+
export function assertKey(key) {
|
|
14
|
+
if (key.length !== KEY_LENGTH)
|
|
15
|
+
throw new Error("Invalid key length");
|
|
16
|
+
}
|
|
17
|
+
export function aesGcmEncrypt(key, plaintext, aad) {
|
|
18
|
+
const nonce = getRandomBytes(NONCE_LENGTH);
|
|
19
|
+
const ciphertext = gcm(key, nonce, utf8Encode(aad)).encrypt(plaintext);
|
|
20
|
+
return { nonce, ciphertext };
|
|
21
|
+
}
|
|
22
|
+
export function aesGcmDecrypt(key, nonce, ciphertext, aad) {
|
|
23
|
+
return gcm(key, nonce, utf8Encode(aad)).decrypt(ciphertext);
|
|
24
|
+
}
|
|
25
|
+
/** Wrap a private key with a key derived from a secret. The AAD says which kind of secret. */
|
|
26
|
+
export async function wrapKeyWith(secret, privateKey, aad, params) {
|
|
27
|
+
assertKey(privateKey);
|
|
28
|
+
const salt = generateSalt();
|
|
29
|
+
const kek = await deriveKek(secret, salt, params);
|
|
30
|
+
try {
|
|
31
|
+
const { nonce, ciphertext } = aesGcmEncrypt(kek, privateKey, aad);
|
|
32
|
+
return {
|
|
33
|
+
version: CRYPTO_FORMAT_VERSION,
|
|
34
|
+
kdf: KDF,
|
|
35
|
+
kdfParams: { ...params },
|
|
36
|
+
salt: bytesToBase64(salt),
|
|
37
|
+
nonce: bytesToBase64(nonce),
|
|
38
|
+
ciphertext: bytesToBase64(ciphertext),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
wipe(kek);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Throws when the secret is wrong, the AAD differs, or the blob was changed. */
|
|
46
|
+
export async function unwrapKeyWith(secret, wrapped, aad) {
|
|
47
|
+
if (wrapped.version !== CRYPTO_FORMAT_VERSION || wrapped.kdf !== KDF) {
|
|
48
|
+
throw new Error("Unsupported wrapped key");
|
|
49
|
+
}
|
|
50
|
+
assertKdfParams(wrapped.kdfParams);
|
|
51
|
+
const kek = await deriveKek(secret, base64ToBytes(wrapped.salt), wrapped.kdfParams);
|
|
52
|
+
try {
|
|
53
|
+
const key = aesGcmDecrypt(kek, base64ToBytes(wrapped.nonce), base64ToBytes(wrapped.ciphertext), aad);
|
|
54
|
+
assertKey(key);
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
wipe(kek);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED