@did-btcr2/cli 0.10.3 → 0.11.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/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +889 -43
- package/dist/esm/src/cli.js +30 -12
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/completion.js +36 -0
- package/dist/esm/src/commands/completion.js.map +1 -0
- package/dist/esm/src/commands/config.js +69 -0
- package/dist/esm/src/commands/config.js.map +1 -0
- package/dist/esm/src/commands/deactivate.js +21 -8
- package/dist/esm/src/commands/deactivate.js.map +1 -1
- package/dist/esm/src/commands/index.js +4 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/key.js +175 -0
- package/dist/esm/src/commands/key.js.map +1 -0
- package/dist/esm/src/commands/profile.js +63 -0
- package/dist/esm/src/commands/profile.js.map +1 -0
- package/dist/esm/src/commands/update.js +19 -9
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config.js +99 -12
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/keystore/atomic.js +64 -0
- package/dist/esm/src/keystore/atomic.js.map +1 -0
- package/dist/esm/src/keystore/envelope.js +123 -0
- package/dist/esm/src/keystore/envelope.js.map +1 -0
- package/dist/esm/src/keystore/error.js +16 -0
- package/dist/esm/src/keystore/error.js.map +1 -0
- package/dist/esm/src/keystore/file-backed-key-manager.js +78 -0
- package/dist/esm/src/keystore/file-backed-key-manager.js.map +1 -0
- package/dist/esm/src/keystore/file-key-store.js +184 -0
- package/dist/esm/src/keystore/file-key-store.js.map +1 -0
- package/dist/esm/src/keystore/passphrase.js +87 -0
- package/dist/esm/src/keystore/passphrase.js.map +1 -0
- package/dist/esm/src/keystore/paths.js +20 -0
- package/dist/esm/src/keystore/paths.js.map +1 -0
- package/dist/esm/src/keystore/resolve-key-ref.js +47 -0
- package/dist/esm/src/keystore/resolve-key-ref.js.map +1 -0
- package/dist/types/src/cli.d.ts +6 -2
- package/dist/types/src/cli.d.ts.map +1 -1
- package/dist/types/src/commands/completion.d.ts +5 -0
- package/dist/types/src/commands/completion.d.ts.map +1 -0
- package/dist/types/src/commands/config.d.ts +5 -0
- package/dist/types/src/commands/config.d.ts.map +1 -0
- package/dist/types/src/commands/deactivate.d.ts.map +1 -1
- package/dist/types/src/commands/index.d.ts +4 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/key.d.ts +10 -0
- package/dist/types/src/commands/key.d.ts.map +1 -0
- package/dist/types/src/commands/profile.d.ts +5 -0
- package/dist/types/src/commands/profile.d.ts.map +1 -0
- package/dist/types/src/commands/update.d.ts.map +1 -1
- package/dist/types/src/config.d.ts +48 -5
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/keystore/atomic.d.ts +19 -0
- package/dist/types/src/keystore/atomic.d.ts.map +1 -0
- package/dist/types/src/keystore/envelope.d.ts +64 -0
- package/dist/types/src/keystore/envelope.d.ts.map +1 -0
- package/dist/types/src/keystore/error.d.ts +14 -0
- package/dist/types/src/keystore/error.d.ts.map +1 -0
- package/dist/types/src/keystore/file-backed-key-manager.d.ts +41 -0
- package/dist/types/src/keystore/file-backed-key-manager.d.ts.map +1 -0
- package/dist/types/src/keystore/file-key-store.d.ts +52 -0
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -0
- package/dist/types/src/keystore/passphrase.d.ts +20 -0
- package/dist/types/src/keystore/passphrase.d.ts.map +1 -0
- package/dist/types/src/keystore/paths.d.ts +13 -0
- package/dist/types/src/keystore/paths.d.ts.map +1 -0
- package/dist/types/src/keystore/resolve-key-ref.d.ts +19 -0
- package/dist/types/src/keystore/resolve-key-ref.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +91 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/src/cli.ts +36 -11
- package/src/commands/completion.ts +40 -0
- package/src/commands/config.ts +84 -0
- package/src/commands/deactivate.ts +25 -12
- package/src/commands/index.ts +4 -0
- package/src/commands/key.ts +193 -0
- package/src/commands/profile.ts +65 -0
- package/src/commands/update.ts +23 -13
- package/src/config.ts +142 -20
- package/src/keystore/atomic.ts +73 -0
- package/src/keystore/envelope.ts +172 -0
- package/src/keystore/error.ts +16 -0
- package/src/keystore/file-backed-key-manager.ts +99 -0
- package/src/keystore/file-key-store.ts +242 -0
- package/src/keystore/passphrase.ts +99 -0
- package/src/keystore/paths.ts +20 -0
- package/src/keystore/resolve-key-ref.ts +62 -0
- package/src/types.ts +30 -11
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
2
|
+
import { argon2id } from '@noble/hashes/argon2.js';
|
|
3
|
+
import { randomBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
import { base64urlnopad } from '@scure/base';
|
|
5
|
+
import { KeyStoreError } from './error.js';
|
|
6
|
+
|
|
7
|
+
/** Current keystore secret-envelope format version. */
|
|
8
|
+
export const ENVELOPE_VERSION = 1 as const;
|
|
9
|
+
|
|
10
|
+
/** Random salt length in bytes for argon2id. */
|
|
11
|
+
const SALT_BYTES = 16;
|
|
12
|
+
/** XChaCha20-Poly1305 extended nonce length in bytes (safe with random nonces). */
|
|
13
|
+
const NONCE_BYTES = 24;
|
|
14
|
+
/** Derived symmetric key length in bytes (the XChaCha20-Poly1305 key size). */
|
|
15
|
+
const KEY_BYTES = 32;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* argon2id cost parameters. Field names follow RFC 9106: `t` time cost
|
|
19
|
+
* (passes), `m` memory cost in KiB, `p` parallelism (lanes), `dkLen` derived
|
|
20
|
+
* key length in bytes.
|
|
21
|
+
*/
|
|
22
|
+
export type ArgonParams = {
|
|
23
|
+
t : number;
|
|
24
|
+
m : number;
|
|
25
|
+
p : number;
|
|
26
|
+
dkLen : number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Production argon2id parameters: 3 passes over 64 MiB across 4 lanes, deriving
|
|
31
|
+
* a 32-byte key. Recorded in every envelope so the cost can be raised later
|
|
32
|
+
* without making previously sealed envelopes undecryptable.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_ARGON_PARAMS: ArgonParams = { t: 3, m: 65536, p: 4, dkLen: KEY_BYTES };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A self-describing, versioned envelope sealing one secret at rest. The header
|
|
38
|
+
* (version, key-derivation parameters, cipher) is bound as the AEAD additional
|
|
39
|
+
* data, so a tampered header fails authentication. All byte fields are
|
|
40
|
+
* base64url with no padding.
|
|
41
|
+
*/
|
|
42
|
+
export type SecretEnvelope = {
|
|
43
|
+
v : typeof ENVELOPE_VERSION;
|
|
44
|
+
kdf : {
|
|
45
|
+
alg : 'argon2id';
|
|
46
|
+
salt : string;
|
|
47
|
+
t : number;
|
|
48
|
+
m : number;
|
|
49
|
+
p : number;
|
|
50
|
+
dkLen : number;
|
|
51
|
+
};
|
|
52
|
+
cipher : 'xchacha20poly1305';
|
|
53
|
+
nonce : string;
|
|
54
|
+
ciphertext : string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The header bound as AEAD additional data (everything except nonce and ciphertext). */
|
|
58
|
+
type EnvelopeHeader = Pick<SecretEnvelope, 'v' | 'kdf' | 'cipher'>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds the header with a fixed key order so the additional-data bytes are
|
|
62
|
+
* byte-identical on the encrypt and decrypt paths.
|
|
63
|
+
*/
|
|
64
|
+
function buildHeader(saltB64: string, params: ArgonParams): EnvelopeHeader {
|
|
65
|
+
return {
|
|
66
|
+
v : ENVELOPE_VERSION,
|
|
67
|
+
kdf : {
|
|
68
|
+
alg : 'argon2id',
|
|
69
|
+
salt : saltB64,
|
|
70
|
+
t : params.t,
|
|
71
|
+
m : params.m,
|
|
72
|
+
p : params.p,
|
|
73
|
+
dkLen : params.dkLen,
|
|
74
|
+
},
|
|
75
|
+
cipher : 'xchacha20poly1305',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Serializes the header into the AEAD additional-data byte string. */
|
|
80
|
+
function headerAad(header: EnvelopeHeader): Uint8Array {
|
|
81
|
+
return utf8ToBytes(JSON.stringify(header));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stretches a passphrase into the symmetric key. The transient UTF-8 copy of
|
|
86
|
+
* the passphrase is zeroized here; the caller is responsible for zeroizing the
|
|
87
|
+
* returned key after use.
|
|
88
|
+
*/
|
|
89
|
+
function deriveKey(passphrase: string, salt: Uint8Array, params: ArgonParams): Uint8Array {
|
|
90
|
+
const password = utf8ToBytes(passphrase);
|
|
91
|
+
try {
|
|
92
|
+
return argon2id(password, salt, { t: params.t, m: params.m, p: params.p, dkLen: params.dkLen });
|
|
93
|
+
} finally {
|
|
94
|
+
password.fill(0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Seals a secret under a passphrase into a {@link SecretEnvelope}. A fresh
|
|
100
|
+
* random salt and nonce are generated per call, so encrypting the same secret
|
|
101
|
+
* twice yields different envelopes.
|
|
102
|
+
*
|
|
103
|
+
* @param secret - The secret bytes to encrypt. Must be non-empty.
|
|
104
|
+
* @param passphrase - The passphrase the encryption key is derived from.
|
|
105
|
+
* @param params - argon2id cost parameters. Defaults to {@link DEFAULT_ARGON_PARAMS}.
|
|
106
|
+
* @returns The versioned, authenticated envelope.
|
|
107
|
+
* @throws {KeyStoreError} `ENVELOPE_ENCRYPT_ERROR` when `secret` is empty.
|
|
108
|
+
*/
|
|
109
|
+
export function encryptSecret(
|
|
110
|
+
secret : Uint8Array,
|
|
111
|
+
passphrase : string,
|
|
112
|
+
params : ArgonParams = DEFAULT_ARGON_PARAMS,
|
|
113
|
+
): SecretEnvelope {
|
|
114
|
+
if (secret.length === 0) {
|
|
115
|
+
throw new KeyStoreError('Cannot encrypt an empty secret.', 'ENVELOPE_ENCRYPT_ERROR');
|
|
116
|
+
}
|
|
117
|
+
const salt = randomBytes(SALT_BYTES);
|
|
118
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
119
|
+
const header = buildHeader(base64urlnopad.encode(salt), params);
|
|
120
|
+
const key = deriveKey(passphrase, salt, params);
|
|
121
|
+
try {
|
|
122
|
+
const ciphertext = xchacha20poly1305(key, nonce, headerAad(header)).encrypt(secret);
|
|
123
|
+
return {
|
|
124
|
+
...header,
|
|
125
|
+
nonce : base64urlnopad.encode(nonce),
|
|
126
|
+
ciphertext : base64urlnopad.encode(ciphertext),
|
|
127
|
+
};
|
|
128
|
+
} finally {
|
|
129
|
+
key.fill(0);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Opens a {@link SecretEnvelope} sealed by {@link encryptSecret} and returns the
|
|
135
|
+
* plaintext secret. A wrong passphrase, corrupted ciphertext, or a tampered
|
|
136
|
+
* header all fail authentication and raise `DECRYPT_ERROR`.
|
|
137
|
+
*
|
|
138
|
+
* @param env - The envelope to open.
|
|
139
|
+
* @param passphrase - The passphrase the envelope was sealed with.
|
|
140
|
+
* @returns The decrypted secret bytes.
|
|
141
|
+
* @throws {KeyStoreError} `ENVELOPE_VERSION_ERROR` for an unknown version or
|
|
142
|
+
* algorithm; `DECRYPT_ERROR` for failed authentication.
|
|
143
|
+
*/
|
|
144
|
+
export function decryptSecret(env: SecretEnvelope, passphrase: string): Uint8Array {
|
|
145
|
+
if (env.v !== ENVELOPE_VERSION) {
|
|
146
|
+
throw new KeyStoreError(
|
|
147
|
+
`Unsupported keystore envelope version: ${String(env.v)}.`,
|
|
148
|
+
'ENVELOPE_VERSION_ERROR',
|
|
149
|
+
{ version: env.v },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (env.kdf?.alg !== 'argon2id' || env.cipher !== 'xchacha20poly1305') {
|
|
153
|
+
throw new KeyStoreError('Unsupported keystore envelope algorithm.', 'ENVELOPE_VERSION_ERROR');
|
|
154
|
+
}
|
|
155
|
+
const params: ArgonParams = { t: env.kdf.t, m: env.kdf.m, p: env.kdf.p, dkLen: env.kdf.dkLen };
|
|
156
|
+
const salt = base64urlnopad.decode(env.kdf.salt);
|
|
157
|
+
const nonce = base64urlnopad.decode(env.nonce);
|
|
158
|
+
const ciphertext = base64urlnopad.decode(env.ciphertext);
|
|
159
|
+
const header = buildHeader(env.kdf.salt, params);
|
|
160
|
+
const key = deriveKey(passphrase, salt, params);
|
|
161
|
+
try {
|
|
162
|
+
return xchacha20poly1305(key, nonce, headerAad(header)).decrypt(ciphertext);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof KeyStoreError) throw error;
|
|
165
|
+
throw new KeyStoreError(
|
|
166
|
+
'Keystore decryption failed: wrong passphrase or corrupted keystore.',
|
|
167
|
+
'DECRYPT_ERROR',
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
key.fill(0);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DidMethodError } from '@did-btcr2/common';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error raised by the CLI keystore layer: secret-envelope encryption and
|
|
5
|
+
* decryption, on-disk file permission enforcement, and passphrase acquisition.
|
|
6
|
+
*
|
|
7
|
+
* Unlike {@link CLIError} (whose `name` is fixed to `'CLIError'`), this follows
|
|
8
|
+
* the {@link DidMethodError} sibling convention where `name` mirrors the `type`
|
|
9
|
+
* code, so a thrown error's `name` reflects the specific failure category
|
|
10
|
+
* (for example `DECRYPT_ERROR` or `KEYSTORE_PERMISSION_ERROR`).
|
|
11
|
+
*/
|
|
12
|
+
export class KeyStoreError extends DidMethodError {
|
|
13
|
+
constructor(message: string, type: string = 'KeyStoreError', data?: Record<string, any>) {
|
|
14
|
+
super(message, { type, name: type, data });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Bytes, HashBytes, KeyBytes, SignatureBytes } from '@did-btcr2/common';
|
|
2
|
+
import {
|
|
3
|
+
LocalKeyManager,
|
|
4
|
+
type GenerateKeyOptions,
|
|
5
|
+
type ImportKeyOptions,
|
|
6
|
+
type KeyIdentifier,
|
|
7
|
+
type KeyManager,
|
|
8
|
+
type SignOptions,
|
|
9
|
+
type VerifyOptions,
|
|
10
|
+
} from '@did-btcr2/key-manager';
|
|
11
|
+
import type { SchnorrKeyPair } from '@did-btcr2/keypair';
|
|
12
|
+
import { FileKeyStore, type FileKeyStoreOptions } from './file-key-store.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A {@link KeyManager} backed by the encrypted on-disk {@link FileKeyStore}.
|
|
16
|
+
*
|
|
17
|
+
* It composes a {@link LocalKeyManager} over a {@link FileKeyStore} and adds the
|
|
18
|
+
* one thing the store interface cannot express: persisting the active-key
|
|
19
|
+
* pointer. `LocalKeyManager` tracks the active key only in process memory, so
|
|
20
|
+
* this wrapper mirrors every active-key change to the keystore file and
|
|
21
|
+
* re-applies the persisted pointer at construction. Read and signing
|
|
22
|
+
* operations delegate straight through.
|
|
23
|
+
*
|
|
24
|
+
* Injected as the api's KeyManager so every command reaches it uniformly via
|
|
25
|
+
* `api.kms`, and "the active key" survives across CLI invocations.
|
|
26
|
+
*/
|
|
27
|
+
export class FileBackedKeyManager implements KeyManager {
|
|
28
|
+
/** Capability probe: the local store supports exporting secret material. */
|
|
29
|
+
readonly canExport = true;
|
|
30
|
+
|
|
31
|
+
readonly #store: FileKeyStore;
|
|
32
|
+
readonly #inner: LocalKeyManager;
|
|
33
|
+
|
|
34
|
+
constructor(options: FileKeyStoreOptions) {
|
|
35
|
+
this.#store = new FileKeyStore(options);
|
|
36
|
+
this.#inner = new LocalKeyManager(this.#store);
|
|
37
|
+
// Apply the persisted active pointer only if the key still exists. A
|
|
38
|
+
// dangling pointer (from out-of-band file editing or a partial write) is
|
|
39
|
+
// ignored rather than thrown, so recovery commands stay usable; the next
|
|
40
|
+
// setActiveKey overwrites it. has() is a non-decrypting cache lookup.
|
|
41
|
+
const active = this.#store.getActive();
|
|
42
|
+
if (active && this.#store.has(active)) this.#inner.setActiveKey(active);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get activeKeyId(): KeyIdentifier | undefined {
|
|
46
|
+
return this.#inner.activeKeyId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setActiveKey(id: KeyIdentifier): void {
|
|
50
|
+
this.#inner.setActiveKey(id);
|
|
51
|
+
this.#store.setActive(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
importKey(keyPair: SchnorrKeyPair, options?: ImportKeyOptions): KeyIdentifier {
|
|
55
|
+
const id = this.#inner.importKey(keyPair, options);
|
|
56
|
+
if (options?.setActive) this.#store.setActive(id);
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
generateKey(options?: GenerateKeyOptions): KeyIdentifier {
|
|
61
|
+
const id = this.#inner.generateKey(options);
|
|
62
|
+
if (options?.setActive) this.#store.setActive(id);
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
removeKey(id: KeyIdentifier, options?: { force?: boolean }): void {
|
|
67
|
+
// LocalKeyManager.removeKey calls FileKeyStore.delete, which already clears
|
|
68
|
+
// the persisted active pointer when the removed key was the active one.
|
|
69
|
+
this.#inner.removeKey(id, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
listKeys(): KeyIdentifier[] {
|
|
73
|
+
return this.#inner.listKeys();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getPublicKey(id?: KeyIdentifier): KeyBytes {
|
|
77
|
+
return this.#inner.getPublicKey(id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getEntry(id?: KeyIdentifier): { publicKey: KeyBytes; tags?: Record<string, string> } {
|
|
81
|
+
return this.#inner.getEntry(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sign(data: Bytes, id?: KeyIdentifier, options?: SignOptions): SignatureBytes {
|
|
85
|
+
return this.#inner.sign(data, id, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
verify(signature: SignatureBytes, data: Bytes, id?: KeyIdentifier, options?: VerifyOptions): boolean {
|
|
89
|
+
return this.#inner.verify(signature, data, id, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
digest(data: Uint8Array): HashBytes {
|
|
93
|
+
return this.#inner.digest(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
exportKey(id: KeyIdentifier): SchnorrKeyPair {
|
|
97
|
+
return this.#inner.exportKey(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { KeyEntry, KeyIdentifier, KeyValueStore } from '@did-btcr2/key-manager';
|
|
4
|
+
import { base64urlnopad } from '@scure/base';
|
|
5
|
+
import { assertSecurePerms, ensureDir, writeFileAtomic } from './atomic.js';
|
|
6
|
+
import { DEFAULT_ARGON_PARAMS, decryptSecret, encryptSecret } from './envelope.js';
|
|
7
|
+
import type { ArgonParams, SecretEnvelope } from './envelope.js';
|
|
8
|
+
import { KeyStoreError } from './error.js';
|
|
9
|
+
import { defaultKeystorePath } from './paths.js';
|
|
10
|
+
|
|
11
|
+
/** Current on-disk keystore file format version. */
|
|
12
|
+
export const KEYSTORE_VERSION = 1 as const;
|
|
13
|
+
|
|
14
|
+
/** One key as stored on disk: public material in clear, secret sealed (or absent for watch-only). */
|
|
15
|
+
type StoredKey = {
|
|
16
|
+
publicKey : string;
|
|
17
|
+
tags? : Record<string, string>;
|
|
18
|
+
secret? : SecretEnvelope;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The whole keystore file. */
|
|
22
|
+
type KeystoreFile = {
|
|
23
|
+
v : typeof KEYSTORE_VERSION;
|
|
24
|
+
active? : string;
|
|
25
|
+
keys : Record<string, StoredKey>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One key in the in-memory cache; the materialized secret is retained per session once decrypted. */
|
|
29
|
+
type CacheEntry = {
|
|
30
|
+
publicKey : Uint8Array;
|
|
31
|
+
tags? : Record<string, string>;
|
|
32
|
+
secret? : SecretEnvelope;
|
|
33
|
+
decrypted? : Uint8Array;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Options for constructing a {@link FileKeyStore}. */
|
|
37
|
+
export type FileKeyStoreOptions = {
|
|
38
|
+
/** Keystore file path. Defaults to {@link defaultKeystorePath}. */
|
|
39
|
+
path?: string;
|
|
40
|
+
/** Supplies the passphrase lazily, called only when a secret must be sealed or opened. */
|
|
41
|
+
getPassphrase: () => string;
|
|
42
|
+
/** argon2id cost parameters used when sealing new secrets. Defaults to {@link DEFAULT_ARGON_PARAMS}. */
|
|
43
|
+
argonParams?: ArgonParams;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A Node-only, file-backed {@link KeyValueStore} that encrypts secret keys at
|
|
48
|
+
* rest. It satisfies the synchronous store contract by caching the parsed file
|
|
49
|
+
* in memory at construction and flushing the whole file atomically on every
|
|
50
|
+
* mutation.
|
|
51
|
+
*
|
|
52
|
+
* Secrets are materialized only through {@link FileKeyStore.get}. The
|
|
53
|
+
* {@link FileKeyStore.list} and {@link FileKeyStore.entries} projections omit
|
|
54
|
+
* secret keys and never decrypt, so enumerating the store never triggers a
|
|
55
|
+
* passphrase prompt.
|
|
56
|
+
*/
|
|
57
|
+
export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
|
|
58
|
+
readonly #path: string;
|
|
59
|
+
readonly #getPassphrase: () => string;
|
|
60
|
+
readonly #argonParams: ArgonParams;
|
|
61
|
+
readonly #cache: Map<KeyIdentifier, CacheEntry> = new Map();
|
|
62
|
+
#active: string | undefined;
|
|
63
|
+
|
|
64
|
+
constructor(options: FileKeyStoreOptions) {
|
|
65
|
+
this.#path = options.path ?? defaultKeystorePath();
|
|
66
|
+
this.#getPassphrase = options.getPassphrase;
|
|
67
|
+
this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
|
|
68
|
+
ensureDir(dirname(this.#path), 0o700);
|
|
69
|
+
this.#load();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#load(): void {
|
|
73
|
+
if (!existsSync(this.#path)) return;
|
|
74
|
+
assertSecurePerms(this.#path);
|
|
75
|
+
let parsed: KeystoreFile;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(readFileSync(this.#path, 'utf-8')) as KeystoreFile;
|
|
78
|
+
} catch {
|
|
79
|
+
throw new KeyStoreError(
|
|
80
|
+
`Keystore at ${this.#path} is corrupt or unreadable.`,
|
|
81
|
+
'KEYSTORE_CORRUPT_ERROR',
|
|
82
|
+
{ path: this.#path },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (parsed.v !== KEYSTORE_VERSION) {
|
|
86
|
+
throw new KeyStoreError(
|
|
87
|
+
`Unsupported keystore version: ${String(parsed.v)}.`,
|
|
88
|
+
'KEYSTORE_VERSION_ERROR',
|
|
89
|
+
{ version: parsed.v },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
this.#active = parsed.active;
|
|
93
|
+
for (const [ id, stored ] of Object.entries(parsed.keys ?? {})) {
|
|
94
|
+
let publicKey: Uint8Array;
|
|
95
|
+
try {
|
|
96
|
+
if (typeof stored.publicKey !== 'string') throw new Error('missing publicKey');
|
|
97
|
+
publicKey = base64urlnopad.decode(stored.publicKey);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new KeyStoreError(
|
|
100
|
+
`Keystore entry ${id} has a malformed public key.`,
|
|
101
|
+
'KEYSTORE_CORRUPT_ERROR',
|
|
102
|
+
{ path: this.#path, keyId: id },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (publicKey.length !== 33) {
|
|
106
|
+
throw new KeyStoreError(
|
|
107
|
+
`Keystore entry ${id} has a ${publicKey.length}-byte public key; expected 33.`,
|
|
108
|
+
'KEYSTORE_CORRUPT_ERROR',
|
|
109
|
+
{ path: this.#path, keyId: id },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
this.#cache.set(id, {
|
|
113
|
+
publicKey,
|
|
114
|
+
...(stored.tags && { tags: stored.tags }),
|
|
115
|
+
...(stored.secret && { secret: stored.secret }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#flush(): void {
|
|
121
|
+
const keys: Record<string, StoredKey> = {};
|
|
122
|
+
for (const [ id, entry ] of this.#cache) {
|
|
123
|
+
keys[id] = {
|
|
124
|
+
publicKey : base64urlnopad.encode(entry.publicKey),
|
|
125
|
+
...(entry.tags && { tags: entry.tags }),
|
|
126
|
+
...(entry.secret && { secret: entry.secret }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const file: KeystoreFile = {
|
|
130
|
+
v : KEYSTORE_VERSION,
|
|
131
|
+
...(this.#active && { active: this.#active }),
|
|
132
|
+
keys,
|
|
133
|
+
};
|
|
134
|
+
writeFileAtomic(this.#path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get(id: KeyIdentifier): KeyEntry | undefined {
|
|
138
|
+
const entry = this.#cache.get(id);
|
|
139
|
+
if (!entry) return undefined;
|
|
140
|
+
const result: KeyEntry = {
|
|
141
|
+
publicKey : entry.publicKey,
|
|
142
|
+
...(entry.tags && { tags: entry.tags }),
|
|
143
|
+
};
|
|
144
|
+
if (entry.secret) {
|
|
145
|
+
// Materialize the secret lazily, only when it is actually accessed, so
|
|
146
|
+
// reads that need just public material (an active-key existence check,
|
|
147
|
+
// getPublicKey, getEntry) never trigger a passphrase prompt. The property
|
|
148
|
+
// is non-enumerable so spreading or serializing the entry cannot silently
|
|
149
|
+
// decrypt the secret.
|
|
150
|
+
const sealed = entry.secret;
|
|
151
|
+
Object.defineProperty(result, 'secretKey', {
|
|
152
|
+
configurable : true,
|
|
153
|
+
enumerable : false,
|
|
154
|
+
get : (): Uint8Array => {
|
|
155
|
+
entry.decrypted ??= decryptSecret(sealed, this.#getPassphrase());
|
|
156
|
+
return entry.decrypted;
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
has(id: KeyIdentifier): boolean {
|
|
164
|
+
return this.#cache.has(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
set(id: KeyIdentifier, value: KeyEntry): void {
|
|
168
|
+
const secret = value.secretKey
|
|
169
|
+
? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams)
|
|
170
|
+
: undefined;
|
|
171
|
+
this.#cache.set(id, {
|
|
172
|
+
publicKey : value.publicKey,
|
|
173
|
+
...(value.tags && { tags: value.tags }),
|
|
174
|
+
...(secret && { secret }),
|
|
175
|
+
...(value.secretKey && { decrypted: value.secretKey }),
|
|
176
|
+
});
|
|
177
|
+
this.#flush();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
delete(id: KeyIdentifier): boolean {
|
|
181
|
+
const existed = this.#cache.delete(id);
|
|
182
|
+
if (existed) {
|
|
183
|
+
if (this.#active === id) this.#active = undefined;
|
|
184
|
+
this.#flush();
|
|
185
|
+
}
|
|
186
|
+
return existed;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
clear(): void {
|
|
190
|
+
this.#cache.clear();
|
|
191
|
+
this.#active = undefined;
|
|
192
|
+
this.#flush();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** All stored values with secret keys omitted. Never decrypts, never prompts. */
|
|
196
|
+
list(): Array<KeyEntry> {
|
|
197
|
+
return this.entries().map(([ , value ]) => value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* All entries as id-value tuples with secret keys omitted. Never decrypts,
|
|
202
|
+
* never prompts: {@link FileKeyStore.get} is the only secret-materializing
|
|
203
|
+
* path, so callers that only need identifiers (such as `listKeys`) do not
|
|
204
|
+
* force a passphrase prompt. This deviates intentionally from the in-memory
|
|
205
|
+
* store, which returns stored values verbatim.
|
|
206
|
+
*/
|
|
207
|
+
entries(): Array<[KeyIdentifier, KeyEntry]> {
|
|
208
|
+
const out: Array<[KeyIdentifier, KeyEntry]> = [];
|
|
209
|
+
for (const [ id, entry ] of this.#cache) {
|
|
210
|
+
out.push([ id, {
|
|
211
|
+
publicKey : entry.publicKey,
|
|
212
|
+
...(entry.tags && { tags: entry.tags }),
|
|
213
|
+
} ]);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
close(): void {
|
|
219
|
+
for (const entry of this.#cache.values()) {
|
|
220
|
+
entry.decrypted?.fill(0);
|
|
221
|
+
entry.decrypted = undefined;
|
|
222
|
+
}
|
|
223
|
+
this.#cache.clear();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The persisted active-key identifier, or undefined if none is set. */
|
|
227
|
+
getActive(): string | undefined {
|
|
228
|
+
return this.#active;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Persists the active-key pointer in the keystore file. Passing undefined
|
|
233
|
+
* clears it. Throws if the identifier is not a known key.
|
|
234
|
+
*/
|
|
235
|
+
setActive(id: KeyIdentifier | undefined): void {
|
|
236
|
+
if (id !== undefined && !this.#cache.has(id)) {
|
|
237
|
+
throw new KeyStoreError(`Cannot set unknown key as active: ${id}.`, 'KEY_NOT_FOUND_ERROR', { keyId: id });
|
|
238
|
+
}
|
|
239
|
+
this.#active = id;
|
|
240
|
+
this.#flush();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync, readSync } from 'node:fs';
|
|
2
|
+
import { KeyStoreError } from './error.js';
|
|
3
|
+
|
|
4
|
+
/** Environment variable that supplies the keystore passphrase for unattended use. */
|
|
5
|
+
export const ENV_KEYSTORE_PASSPHRASE = 'BTCR2_KEYSTORE_PASSPHRASE';
|
|
6
|
+
|
|
7
|
+
/** Options controlling how a passphrase is acquired. */
|
|
8
|
+
export type PassphraseOptions = {
|
|
9
|
+
/** Path to a file whose contents (a trailing newline is trimmed) are the passphrase. */
|
|
10
|
+
passphraseFile?: string;
|
|
11
|
+
/** Prompt label shown on a terminal. */
|
|
12
|
+
prompt?: string;
|
|
13
|
+
/** When true, prompt twice and require the entries to match (for a new keystore). */
|
|
14
|
+
confirm?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Acquires a passphrase without ever reading it from a command-line flag value
|
|
19
|
+
* (which would leak into process listings and shell history). Resolution order:
|
|
20
|
+
* the {@link ENV_KEYSTORE_PASSPHRASE} environment variable, a passphrase file,
|
|
21
|
+
* then a non-echoing terminal prompt. Throws if none is available and standard
|
|
22
|
+
* input is not a terminal.
|
|
23
|
+
*/
|
|
24
|
+
export function acquirePassphrase(options: PassphraseOptions = {}): string {
|
|
25
|
+
// All sources are normalized identically (at most one trailing newline
|
|
26
|
+
// removed) so the KDF input is source-independent.
|
|
27
|
+
const fromEnv = process.env[ENV_KEYSTORE_PASSPHRASE];
|
|
28
|
+
if (fromEnv) return assertNonEmpty(fromEnv.replace(/\r?\n$/, ''));
|
|
29
|
+
|
|
30
|
+
if (options.passphraseFile) {
|
|
31
|
+
return assertNonEmpty(readFileSync(options.passphraseFile, 'utf-8').replace(/\r?\n$/, ''));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!process.stdin.isTTY) {
|
|
35
|
+
throw new KeyStoreError(
|
|
36
|
+
`No passphrase available. Set ${ENV_KEYSTORE_PASSPHRASE}, pass --passphrase-file, or run in a terminal.`,
|
|
37
|
+
'PASSPHRASE_REQUIRED_ERROR',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const passphrase = promptHidden(options.prompt ?? 'Keystore passphrase: ');
|
|
42
|
+
if (options.confirm) {
|
|
43
|
+
const again = promptHidden('Confirm passphrase: ');
|
|
44
|
+
if (passphrase !== again) {
|
|
45
|
+
throw new KeyStoreError('Passphrases did not match.', 'PASSPHRASE_MISMATCH_ERROR');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return assertNonEmpty(passphrase);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Rejects an empty or whitespace-only passphrase, which would seal the keystore with no protection. */
|
|
52
|
+
function assertNonEmpty(passphrase: string): string {
|
|
53
|
+
if (passphrase.trim() === '') {
|
|
54
|
+
throw new KeyStoreError('A non-empty keystore passphrase is required.', 'PASSPHRASE_REQUIRED_ERROR');
|
|
55
|
+
}
|
|
56
|
+
return passphrase;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads a line from the terminal synchronously without echoing keystrokes.
|
|
61
|
+
* Bytes are accumulated and decoded as UTF-8 so multibyte passphrases survive.
|
|
62
|
+
* This path runs only when standard input is a terminal.
|
|
63
|
+
*/
|
|
64
|
+
function promptHidden(label: string): string {
|
|
65
|
+
process.stderr.write(label);
|
|
66
|
+
const stdin = process.stdin;
|
|
67
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
68
|
+
stdin.setRawMode(true);
|
|
69
|
+
const byte = Buffer.alloc(1);
|
|
70
|
+
const bytes: number[] = [];
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
let read = 0;
|
|
74
|
+
try {
|
|
75
|
+
read = readSync(stdin.fd, byte, 0, 1, null);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const code = (error as { code?: string }).code;
|
|
78
|
+
if (code === 'EAGAIN') continue; // no byte ready yet on a non-blocking TTY
|
|
79
|
+
if (code === 'EOF') break;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (read === 0) break;
|
|
83
|
+
const ch = byte[0];
|
|
84
|
+
if (ch === 0x0a || ch === 0x0d) break; // LF or CR ends the line
|
|
85
|
+
if (ch === 0x03) { // Ctrl-C aborts
|
|
86
|
+
throw new KeyStoreError('Passphrase entry aborted.', 'PASSPHRASE_REQUIRED_ERROR');
|
|
87
|
+
}
|
|
88
|
+
if (ch === 0x7f || ch === 0x08) { // DEL or backspace
|
|
89
|
+
bytes.pop();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
bytes.push(ch);
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
stdin.setRawMode(wasRaw);
|
|
96
|
+
process.stderr.write('\n');
|
|
97
|
+
}
|
|
98
|
+
return Buffer.from(bytes).toString('utf-8');
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default keystore file path, following the XDG Base Directory Specification's
|
|
6
|
+
* data directory. Secret key material is data a user accumulates, so it lives
|
|
7
|
+
* under the data directory, kept separate from the configuration directory used
|
|
8
|
+
* for portable settings.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order:
|
|
11
|
+
* 1. `$XDG_DATA_HOME/btcr2/keystore.json`
|
|
12
|
+
* 2. `%LOCALAPPDATA%/btcr2/keystore.json` (Windows)
|
|
13
|
+
* 3. `~/.local/share/btcr2/keystore.json` (fallback)
|
|
14
|
+
*/
|
|
15
|
+
export function defaultKeystorePath(): string {
|
|
16
|
+
const base = process.env.XDG_DATA_HOME
|
|
17
|
+
?? process.env.LOCALAPPDATA
|
|
18
|
+
?? join(homedir(), '.local', 'share');
|
|
19
|
+
return join(base, 'btcr2', 'keystore.json');
|
|
20
|
+
}
|