dsh-vault 0.1.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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Cryptographically strong random password generator with character-class
3
+ * guarantees (at least one of each selected class) and optional separator
4
+ * grouping (e.g. `XKCD`-style word-free random groups like `vK7-mQ2-zt9`).
5
+ *
6
+ * Zero dependencies — randomness comes from `node:crypto`.
7
+ *
8
+ * @module dsh-vault/password
9
+ */
10
+ import { randomInt } from 'node:crypto';
11
+ const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
12
+ const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
13
+ const DIGITS = '0123456789';
14
+ const SYMBOLS = '!@#$%^&*()-_=+[]{};:,.<>?/~';
15
+ function filterPool(options) {
16
+ const pools = [];
17
+ if (options.lowercase)
18
+ pools.push(LOWERCASE);
19
+ if (options.uppercase)
20
+ pools.push(UPPERCASE);
21
+ if (options.digits)
22
+ pools.push(DIGITS);
23
+ if (options.symbols)
24
+ pools.push(SYMBOLS);
25
+ // Filter each class independently so the per-class guarantee below never
26
+ // draws an ambiguous character when excludeAmbiguous is on: the guarantee
27
+ // samples from `classes`, and `merged` only feeds the fill.
28
+ const classes = options.excludeAmbiguous
29
+ ? pools.map(pool => [...pool].filter(char => !AMBIGUOUS_CHARS.has(char)).join(''))
30
+ : pools;
31
+ return { merged: classes.join(''), classes };
32
+ }
33
+ /** Visually ambiguous characters excluded when `excludeAmbiguous` is set. */
34
+ const AMBIGUOUS_CHARS = new Set('0O1lI');
35
+ /**
36
+ * Generate a random password. At least one character from every selected class
37
+ * is guaranteed; the rest are drawn uniformly from the merged pool. Uses
38
+ * rejection sampling against `node:crypto`'s `randomInt` (no modulo bias).
39
+ * @throws when no character class is selected, or when the requested length is
40
+ * shorter than the number of selected classes.
41
+ */
42
+ export function generatePassword(options = {}) {
43
+ const length = options.length ?? 20;
44
+ const config = {
45
+ lowercase: options.lowercase ?? true,
46
+ uppercase: options.uppercase ?? true,
47
+ digits: options.digits ?? true,
48
+ symbols: options.symbols ?? true,
49
+ excludeAmbiguous: options.excludeAmbiguous ?? false,
50
+ };
51
+ const { merged, classes } = filterPool(config);
52
+ const selected = classes.filter(pool => pool.length > 0);
53
+ if (selected.length === 0)
54
+ throw new Error('password generation requires at least one character class');
55
+ if (!Number.isInteger(length) || length < 1)
56
+ throw new Error('password length must be a positive integer');
57
+ if (length < selected.length) {
58
+ throw new Error(`password length ${length} is shorter than the ${selected.length} selected character classes`);
59
+ }
60
+ if (options.group !== undefined && (!Number.isInteger(options.group) || options.group < 2)) {
61
+ throw new Error('password group must be an integer >= 2 when provided');
62
+ }
63
+ // Guarantee one of each class first.
64
+ const chars = selected.map(pool => pool[randomInt(pool.length)]);
65
+ // Fill the rest from the merged pool (or the only class, if one was selected).
66
+ const source = merged.length > 0 ? merged : selected[0];
67
+ while (chars.length < length) {
68
+ chars.push(source[randomInt(source.length)]);
69
+ }
70
+ // Fisher-Yates shuffle so the guaranteed classes land anywhere.
71
+ for (let i = chars.length - 1; i > 0; i--) {
72
+ const j = randomInt(i + 1);
73
+ [chars[i], chars[j]] = [chars[j], chars[i]];
74
+ }
75
+ let password = chars.join('');
76
+ if (options.group !== undefined && options.group > 1 && password.length > options.group) {
77
+ password = password.match(new RegExp(`.{1,${options.group}}`, 'g')).join('-');
78
+ }
79
+ return password;
80
+ }
package/lib/store.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The encrypted vault store: a single JSON document on disk holding the KDF
3
+ * parameters and one authenticated-encryption envelope per entry. The whole
4
+ * document is atomically replaced on every mutation (`writeFileAtomic` from
5
+ * `@deepseek-ai/dsh-atomic-write`) and cross-process writers serialize through
6
+ * its companion file lock.
7
+ *
8
+ * All secrets live inside AES-256-GCM envelopes keyed by scrypt(master
9
+ * password). The on-disk document never contains plaintext secrets; the key
10
+ * is never persisted and must be re-derived from the master password on every
11
+ * load.
12
+ *
13
+ * KDF parameters are fixed for the life of a vault (chosen when the document
14
+ * is first created), so loading and persisting always derive the same key.
15
+ *
16
+ * @module dsh-vault/store
17
+ */
18
+ import { chmod, mkdir, readFile } from 'node:fs/promises';
19
+ import { dirname, join } from 'node:path';
20
+ import { randomUUID } from 'node:crypto';
21
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
22
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
23
+ import { decrypt, deriveKey, encrypt, newKdfParams, safeEqual, VAULT_FORMAT_VERSION, } from "./crypto.js";
24
+ /** Fixed plaintext inside the verification envelope. */
25
+ const VERIFY_PLAINTEXT = 'dsh-vault:password-ok';
26
+ /** Derive the default on-disk path for a named vault. */
27
+ export function defaultVaultPath(vaultName = 'default') {
28
+ return join(dshHomePath('vault'), `${vaultName}.json`);
29
+ }
30
+ /**
31
+ * Open (or lazily create) a vault and return a ready-to-use store. Loading
32
+ * decrypts the whole document up front so search and reads are pure in-memory
33
+ * operations.
34
+ */
35
+ export async function openVault(options) {
36
+ if (options.masterPassword.length === 0) {
37
+ throw new Error('vault master password must not be empty');
38
+ }
39
+ const path = options.path ?? defaultVaultPath(options.name);
40
+ const store = new VaultStore(path, options.masterPassword);
41
+ await store.load();
42
+ return store;
43
+ }
44
+ /**
45
+ * In-memory vault handle. Mutations persist atomically under the file lock;
46
+ * reads never touch the disk again until a reload.
47
+ */
48
+ export class VaultStore {
49
+ entries = new Map();
50
+ path;
51
+ masterPassword;
52
+ /** KDF parameters fixed for this vault's life; set on first load/create. */
53
+ kdf;
54
+ /** Cached derived key; derived once after load and reused for every persist. */
55
+ key;
56
+ /** Serializes in-process persist calls so concurrent mutations never
57
+ * contend for the cross-process file lock and every write sees the latest
58
+ * in-memory state. */
59
+ persistChain = Promise.resolve();
60
+ constructor(path, masterPassword) {
61
+ this.path = path;
62
+ this.masterPassword = masterPassword;
63
+ }
64
+ /**
65
+ * Load the vault document from disk (creating an empty one on first use)
66
+ * and derive the vault key from the document's fixed KDF parameters.
67
+ */
68
+ async load() {
69
+ // Ensure the vault directory exists before any lock-file or document
70
+ // write, so first use never fails with ENOENT on a fresh install.
71
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
72
+ // Tighten a pre-existing directory (mkdir leaves existing dirs untouched)
73
+ // so the vault tree stays owner-only even when a parent created it wider.
74
+ await chmod(dirname(this.path), 0o700).catch(() => { });
75
+ const { file, created } = await this.readDocument();
76
+ this.kdf = file.kdf;
77
+ this.key = await deriveKey(this.masterPassword, file.kdf);
78
+ if (created) {
79
+ // First run: persist the empty document so the file exists with the
80
+ // chosen KDF (and its verify envelope) before any entry is added.
81
+ await this.persist();
82
+ return;
83
+ }
84
+ for (const blob of file.entries) {
85
+ const plaintext = decrypt(blob, this.key);
86
+ const entry = JSON.parse(plaintext.toString('utf8'));
87
+ this.entries.set(entry.id, entry);
88
+ }
89
+ // Verify the password even when the vault has no entries yet.
90
+ const verify = decrypt(file.verify, this.key);
91
+ if (!safeEqual(verify, Buffer.from(VERIFY_PLAINTEXT, 'utf8'))) {
92
+ throw new Error('vault master password is incorrect');
93
+ }
94
+ }
95
+ /** The vault file path (useful for messages and debugging). */
96
+ get filePath() {
97
+ return this.path;
98
+ }
99
+ /** All entries, in insertion order. */
100
+ list() {
101
+ return [...this.entries.values()];
102
+ }
103
+ /** Read one entry by id. */
104
+ get(id) {
105
+ return this.entries.get(id);
106
+ }
107
+ /** Search entries across text fields; returns summaries without secrets. */
108
+ search(query, limit = 20) {
109
+ const needle = query.trim().toLowerCase();
110
+ if (needle.length === 0)
111
+ return [];
112
+ const results = [];
113
+ for (const entry of this.list()) {
114
+ if (results.length >= limit)
115
+ break;
116
+ if (matches(entry, needle))
117
+ results.push(toSummary(entry));
118
+ }
119
+ return results;
120
+ }
121
+ /** Add a new entry; returns the stored entry with its assigned id. Empty
122
+ * strings and empty arrays from the client form are dropped (a blank form
123
+ * field means "not provided", not "store an empty value"). */
124
+ async add(patch) {
125
+ const now = Date.now();
126
+ const entry = {
127
+ id: randomUUID(),
128
+ title: patch.title,
129
+ ...pickDefined(patch, { skipEmpty: true }),
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ };
133
+ this.entries.set(entry.id, entry);
134
+ await this.persist();
135
+ return entry;
136
+ }
137
+ /** Update an existing entry's fields; returns the updated entry or undefined.
138
+ * Every defined field in `patch` replaces the stored value; an empty string
139
+ * clears (removes) that field. `id`/`createdAt` can never change. */
140
+ async update(id, patch) {
141
+ const current = this.entries.get(id);
142
+ if (!current)
143
+ return undefined;
144
+ // A title must never be blanked out: an empty-string title is an error
145
+ // rather than a request to remove the entry's identity.
146
+ if ('title' in patch && (patch.title ?? '').trim().length === 0) {
147
+ throw new Error('vault: title must not be empty');
148
+ }
149
+ const updated = {
150
+ ...current,
151
+ ...pickDefined(patch, { allowTitle: true }),
152
+ updatedAt: Date.now(),
153
+ };
154
+ // An empty string means "clear this field": drop it entirely instead of
155
+ // storing a blank value, so search and summaries never surface it.
156
+ const record = updated;
157
+ for (const key of Object.keys(patch)) {
158
+ if (patch[key] === '') {
159
+ delete record[key];
160
+ }
161
+ }
162
+ this.entries.set(id, updated);
163
+ await this.persist();
164
+ return updated;
165
+ }
166
+ /** Delete an entry; returns true when it existed. */
167
+ async delete(id) {
168
+ const existed = this.entries.delete(id);
169
+ if (existed)
170
+ await this.persist();
171
+ return existed;
172
+ }
173
+ /** Number of entries. */
174
+ get size() {
175
+ return this.entries.size;
176
+ }
177
+ /** Whether the vault is unlocked (loaded) with a usable key. */
178
+ get unlocked() {
179
+ return this.key !== undefined;
180
+ }
181
+ /**
182
+ * Persist the current in-memory state: encrypt every entry under the vault
183
+ * key, then atomically replace the document under the cross-process lock.
184
+ * Calls are serialized through an in-process chain so concurrent mutations
185
+ * never contend for the lock and each write snapshots the entries visible
186
+ * when it runs — the final file always reflects every completed mutation.
187
+ * The derived key is cached after load and reused, so a mutation does not
188
+ * re-run scrypt; only a fresh vault (no cached key yet) derives once.
189
+ */
190
+ persist() {
191
+ const run = async () => {
192
+ const kdf = this.kdf ?? newKdfParams();
193
+ this.kdf = kdf;
194
+ if (this.key === undefined) {
195
+ this.key = await deriveKey(this.masterPassword, kdf);
196
+ }
197
+ const key = this.key;
198
+ // `withFileLock` creates the `<file>.lock` sibling with exclusive create,
199
+ // which fails when the parent directory is absent, so ensure the directory
200
+ // exists before taking the lock.
201
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
202
+ await withFileLock(this.path, async () => {
203
+ const file = {
204
+ version: VAULT_FORMAT_VERSION,
205
+ kdf,
206
+ verify: encrypt(Buffer.from(VERIFY_PLAINTEXT, 'utf8'), key),
207
+ entries: [...this.entries.values()].map(entry => ({
208
+ id: entry.id,
209
+ ...encrypt(Buffer.from(JSON.stringify(entry), 'utf8'), key),
210
+ })),
211
+ };
212
+ await writeFileAtomic(this.path, JSON.stringify(file), {
213
+ mode: 0o600,
214
+ dirMode: 0o700,
215
+ });
216
+ });
217
+ };
218
+ // Append to the chain; a rejected write must not stall later writes, so
219
+ // the chain continues with the next operation regardless of outcome.
220
+ const next = this.persistChain.then(run, run);
221
+ this.persistChain = next.catch(() => { });
222
+ return next;
223
+ }
224
+ /** Read and parse the vault document, tolerating first-run absence. */
225
+ async readDocument() {
226
+ let raw;
227
+ try {
228
+ raw = await readFile(this.path, 'utf8');
229
+ }
230
+ catch (error) {
231
+ if (error.code === 'ENOENT') {
232
+ return { file: { version: VAULT_FORMAT_VERSION, kdf: newKdfParams(), verify: EMPTY_BLOB, entries: [] }, created: true };
233
+ }
234
+ throw error;
235
+ }
236
+ const parsed = JSON.parse(raw);
237
+ if (parsed.version !== VAULT_FORMAT_VERSION) {
238
+ throw new Error(`unsupported vault format version ${parsed.version} (expected ${VAULT_FORMAT_VERSION})`);
239
+ }
240
+ if (!parsed.kdf || parsed.kdf.algo !== 'scrypt' || !parsed.kdf.saltHex) {
241
+ throw new Error('vault document is missing valid KDF parameters');
242
+ }
243
+ if (!parsed.verify) {
244
+ throw new Error('vault document is missing its password-verification envelope');
245
+ }
246
+ return { file: parsed, created: false };
247
+ }
248
+ }
249
+ /** Placeholder envelope for a not-yet-persisted vault; replaced on first persist. */
250
+ const EMPTY_BLOB = { ivHex: '', tagHex: '', dataHex: '' };
251
+ /** Case-insensitive substring match across the entry's searchable fields. */
252
+ function matches(entry, needle) {
253
+ const fieldValues = [];
254
+ collectSearchable(entry.fields ?? {}, fieldValues);
255
+ return [
256
+ entry.title,
257
+ entry.kind,
258
+ entry.username,
259
+ entry.email,
260
+ entry.phone,
261
+ entry.host,
262
+ entry.port,
263
+ entry.url,
264
+ entry.notes,
265
+ ...(entry.tags ?? []),
266
+ ...fieldValues,
267
+ ].some(value => value?.toLowerCase().includes(needle));
268
+ }
269
+ /** Recursively collect every scalar string from a fields value (numbers and
270
+ * booleans stringified so they are searchable too). */
271
+ function collectSearchable(value, out) {
272
+ if (typeof value === 'string') {
273
+ out.push(value);
274
+ }
275
+ else if (typeof value === 'number' || typeof value === 'boolean') {
276
+ out.push(String(value));
277
+ }
278
+ else if (Array.isArray(value)) {
279
+ for (const item of value)
280
+ collectSearchable(item, out);
281
+ }
282
+ else if (value !== null && typeof value === 'object') {
283
+ for (const item of Object.values(value))
284
+ collectSearchable(item, out);
285
+ }
286
+ }
287
+ /** Project an entry to its non-secret summary shape. */
288
+ function toSummary(entry) {
289
+ return {
290
+ id: entry.id,
291
+ title: entry.title,
292
+ ...(entry.kind !== undefined ? { kind: entry.kind } : {}),
293
+ ...(entry.username !== undefined ? { username: entry.username } : {}),
294
+ ...(entry.email !== undefined ? { email: entry.email } : {}),
295
+ ...(entry.phone !== undefined ? { phone: entry.phone } : {}),
296
+ ...(entry.host !== undefined ? { host: entry.host } : {}),
297
+ ...(entry.port !== undefined ? { port: entry.port } : {}),
298
+ ...(entry.url !== undefined ? { url: entry.url } : {}),
299
+ ...(entry.tags !== undefined ? { tags: entry.tags } : {}),
300
+ };
301
+ }
302
+ /** Copy only the defined properties of a patch, excluding identity fields.
303
+ * `title` is excluded by default (add sets it explicitly); pass
304
+ * `{ allowTitle: true }` for updates, which may rename an entry. With
305
+ * `{ skipEmpty: true }` (adds) empty strings and empty arrays are dropped so
306
+ * blank form fields never land in storage. */
307
+ function pickDefined(patch, options = {}) {
308
+ const result = {};
309
+ for (const [key, value] of Object.entries(patch)) {
310
+ const identityField = key === 'id' || key === 'createdAt' || key === 'updatedAt' || (!options.allowTitle && key === 'title');
311
+ if (value === undefined || identityField)
312
+ continue;
313
+ if (options.skipEmpty && (value === '' || (Array.isArray(value) && value.length === 0)))
314
+ continue;
315
+ result[key] = value;
316
+ }
317
+ return result;
318
+ }
package/lib/totp.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * RFC 6238 TOTP (Time-based One-Time Password) with RFC 4648 Base32 secrets,
3
+ * implemented directly on `node:crypto` (HMAC-SHA1) with zero dependencies.
4
+ *
5
+ * Supports both raw Base32 secrets and `otpauth://totp/...?secret=...` URIs
6
+ * so users can paste either the secret their 2FA setup page shows or the full
7
+ * provisioning URI a QR code encodes.
8
+ *
9
+ * @module dsh-vault/totp
10
+ */
11
+ import { createHmac, randomBytes } from 'node:crypto';
12
+ /** Default time step in seconds (RFC 6238 default, used by Google Authenticator etc.). */
13
+ export const DEFAULT_PERIOD_SECONDS = 30;
14
+ /** Default number of digits (RFC 6238 default). */
15
+ export const DEFAULT_DIGITS = 6;
16
+ /** Characters used by RFC 4648 Base32 (no padding in secrets). */
17
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
18
+ /**
19
+ * Decode an RFC 4648 Base32 string (case-insensitive, optional padding and
20
+ * whitespace tolerated) into bytes.
21
+ * @throws on characters outside the Base32 alphabet.
22
+ */
23
+ export function base32Decode(input) {
24
+ const cleaned = input.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase();
25
+ if (!/^[A-Z2-7]+$/.test(cleaned)) {
26
+ throw new Error('invalid Base32 secret: contains characters outside A-Z2-7');
27
+ }
28
+ let bits = 0;
29
+ let value = 0;
30
+ const bytes = [];
31
+ for (const char of cleaned) {
32
+ const index = BASE32_ALPHABET.indexOf(char);
33
+ if (index < 0)
34
+ throw new Error(`invalid Base32 character: ${char}`);
35
+ value = (value << 5) | index;
36
+ bits += 5;
37
+ if (bits >= 8) {
38
+ bytes.push((value >>> (bits - 8)) & 0xff);
39
+ bits -= 8;
40
+ }
41
+ }
42
+ return Buffer.from(bytes);
43
+ }
44
+ /** RFC 3986 query-string parser sufficient for otpauth URIs. */
45
+ function parseQuery(query) {
46
+ const result = {};
47
+ for (const pair of query.split('&')) {
48
+ if (!pair)
49
+ continue;
50
+ const eq = pair.indexOf('=');
51
+ if (eq < 0) {
52
+ result[decodeURIComponent(pair)] = '';
53
+ }
54
+ else {
55
+ result[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+ /** Extract the numeric `digits` query parameter when present and valid. */
61
+ function digitsOf(raw) {
62
+ if (raw === undefined)
63
+ return DEFAULT_DIGITS;
64
+ const parsed = Number.parseInt(raw, 10);
65
+ if (Number.isInteger(parsed) && parsed >= 6 && parsed <= 10)
66
+ return parsed;
67
+ return DEFAULT_DIGITS;
68
+ }
69
+ /** Extract the numeric `period` query parameter when present and valid. */
70
+ function periodOf(raw) {
71
+ if (raw === undefined)
72
+ return DEFAULT_PERIOD_SECONDS;
73
+ const parsed = Number.parseInt(raw, 10);
74
+ if (Number.isInteger(parsed) && parsed >= 5)
75
+ return parsed;
76
+ return DEFAULT_PERIOD_SECONDS;
77
+ }
78
+ /**
79
+ * Parse a TOTP secret from either a bare Base32 string or a full
80
+ * `otpauth://totp/...` provisioning URI. If a bare string has an explicit
81
+ * trailing `?digits=` or `?period=` (unusual), it is ignored; use a URI for
82
+ * non-default parameters.
83
+ */
84
+ export function parseTotpSecret(input) {
85
+ const trimmed = input.trim();
86
+ if (trimmed.startsWith('otpauth://')) {
87
+ const url = new URL(trimmed);
88
+ if (url.protocol !== 'otpauth:' || url.host !== 'totp') {
89
+ throw new Error('unsupported otpauth URI: only otpauth://totp is supported');
90
+ }
91
+ const secret = url.searchParams.get('secret');
92
+ if (!secret)
93
+ throw new Error('otpauth URI is missing the secret parameter');
94
+ // Labels: "issuer:account" for otpauth://totp/Issuer:account
95
+ const label = decodeURIComponent(url.pathname.replace(/^\//, ''));
96
+ const colon = label.indexOf(':');
97
+ const issuer = url.searchParams.get('issuer') ?? (colon >= 0 ? label.slice(0, colon) : undefined);
98
+ const account = colon >= 0 ? label.slice(colon + 1) : label;
99
+ return {
100
+ secret,
101
+ periodSeconds: periodOf(url.searchParams.get('period') ?? undefined),
102
+ digits: digitsOf(url.searchParams.get('digits') ?? undefined),
103
+ ...(issuer !== undefined ? { issuer } : {}),
104
+ ...(account !== undefined && account.length > 0 ? { account } : {}),
105
+ };
106
+ }
107
+ return {
108
+ secret: trimmed,
109
+ periodSeconds: DEFAULT_PERIOD_SECONDS,
110
+ digits: DEFAULT_DIGITS,
111
+ };
112
+ }
113
+ /** HOTP (RFC 4226) — the building block TOTP is defined on. */
114
+ export function hotp(secret, counter, digits) {
115
+ const counterBuffer = Buffer.alloc(8);
116
+ counterBuffer.writeBigUInt64BE(BigInt(Math.floor(counter)));
117
+ const hmac = createHmac('sha1', secret).update(counterBuffer).digest();
118
+ const offset = hmac[hmac.length - 1] & 0x0f;
119
+ const binary = ((hmac[offset] & 0x7f) << 24) |
120
+ ((hmac[offset + 1] & 0xff) << 16) |
121
+ ((hmac[offset + 2] & 0xff) << 8) |
122
+ (hmac[offset + 3] & 0xff);
123
+ return (binary % 10 ** digits).toString().padStart(digits, '0');
124
+ }
125
+ /**
126
+ * Compute the current TOTP code for `input` (bare Base32 secret or otpauth
127
+ * URI). `nowMs` defaults to the current time; pass it explicitly for tests.
128
+ */
129
+ export function totp(input, nowMs = Date.now()) {
130
+ const parsed = parseTotpSecret(input);
131
+ const key = base32Decode(parsed.secret);
132
+ const counter = Math.floor(nowMs / 1000 / parsed.periodSeconds);
133
+ return hotp(key, counter, parsed.digits);
134
+ }
135
+ /**
136
+ * Build a Base32 string from raw bytes (RFC 4648), no padding.
137
+ */
138
+ export function bytesToBase32(bytes) {
139
+ let bits = 0;
140
+ let value = 0;
141
+ let output = '';
142
+ for (const byte of bytes) {
143
+ value = (value << 8) | byte;
144
+ bits += 8;
145
+ while (bits >= 5) {
146
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 0x1f];
147
+ bits -= 5;
148
+ }
149
+ }
150
+ if (bits > 0) {
151
+ output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
152
+ }
153
+ return output;
154
+ }
155
+ /**
156
+ * Generate a fresh random TOTP secret (160-bit Base32, the RFC 4226
157
+ * recommended length), e.g. for enrolling a new 2FA account.
158
+ */
159
+ export function generateTotpSecret() {
160
+ return bytesToBase32(randomBytes(20));
161
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "dsh-vault",
3
+ "version": "0.1.0",
4
+ "description": "Encrypted credential vault for DeepSeek Harness: store and retrieve usernames, emails, phone numbers, passwords, TOTP secrets, SSH/API-key/OAuth developer credentials through model tools and a Settings UI page.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ox0400",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Ox0400/dsh-vault.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Ox0400/dsh-vault/issues"
14
+ },
15
+ "homepage": "https://github.com/Ox0400/dsh-vault#readme",
16
+ "main": "lib/index.js",
17
+ "exports": {
18
+ ".": "./lib/index.js",
19
+ "./client": "./lib/client.js",
20
+ "./cordis.patch.yml": "./cordis.patch.yml",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "dsh": {
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-connection",
30
+ "@deepseek-ai/dsh-client-locale",
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-settings"
33
+ ],
34
+ "platform": "web"
35
+ }
36
+ },
37
+ "files": [
38
+ "lib",
39
+ "cordis.patch.yml",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "dependencies": {},
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": ">=4.0.0",
49
+ "@deepseek-ai/dsh-atomic-write": ">=0.1.0-rc.6",
50
+ "@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.6",
51
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6",
52
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6",
55
+ "@deepseek-ai/schemastery": ">=3.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@deepseek-ai/cordis": "4.0.1",
59
+ "@deepseek-ai/dsh-atomic-write": "0.1.0-rc.6",
60
+ "@deepseek-ai/dsh-home-paths": "0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
62
+ "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
63
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
64
+ "@deepseek-ai/dsh-typert-protocol": "0.1.0-rc.6",
65
+ "@deepseek-ai/schemastery": "3.18.1",
66
+ "@types/node": "^22.0.0",
67
+ "lightningcss": "^1.32.0",
68
+ "react": "^18.3.1",
69
+ "tsdown": "^0.22.0",
70
+ "typescript": "^6.0.0",
71
+ "vitest": "^4.1.0"
72
+ },
73
+ "scripts": {
74
+ "build": "npm run build:host && npm run build:client",
75
+ "build:host": "tsc -p tsconfig.host.json",
76
+ "build:client": "tsdown",
77
+ "prepare": "npm run build",
78
+ "test": "vitest run",
79
+ "typecheck": "tsc -p tsconfig.json --noEmit"
80
+ }
81
+ }