win-cliauth 1.0.4 → 1.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.
package/README.md CHANGED
@@ -17,16 +17,25 @@ win-cliauth provides a command-line interface for managing OTP (One-Time Passwor
17
17
  - `add <name> --replace`: Adds an authenticator, replacing an existing one with the same name.
18
18
  - `add <name> --steam`: Adds a Steam Guard authenticator (see [Steam Guard](#steam-guard)).
19
19
  - `add <name> --mafile <path>`: Imports a Steam Guard authenticator from a `.maFile`.
20
+ - `add <name> --hotp [--counter <n>]`: Adds a counter-based (HOTP) authenticator from a base32 secret; `otpauth://hotp/` links are detected automatically.
20
21
  - `remove <name>`: Removes the authenticator with the specified name.
21
- - `get <name>`: Retrieves the token from the authenticator with the specified name.
22
- - `get <name> --clipboard`: Retrieves the token and adds it to the clipboard.
22
+ - `get <name>`: Retrieves the token and shows how many seconds it stays valid. Any unique start of the name works, in any case (`get micro` for `microsoft`). If the code is about to expire, it waits for the next one.
23
+ - `get <name> --clipboard`: Retrieves the token and adds it to the clipboard, clearing it again after 30 seconds (`--clear <seconds>` to change, `--clear 0` to keep it).
23
24
  - `get <name> --sync`: Forces a fresh time sync before generating the token.
25
+ - `get <name> --watch`: Keeps the code on screen with a countdown bar, switching to the next code as it changes, until Ctrl+C.
24
26
  - `list`: Lists all registered authenticators.
25
27
  - `list --showsecret`: Lists all registered authenticators, including the secret keys.
28
+ - `rename <old-name> <new-name>`: Renames an authenticator.
29
+ - `export <file>`: Saves all authenticators to a password-protected backup file (see [Backups](#backups)).
30
+ - `import <file>`: Adds the authenticators from a backup file; existing names are kept unless `--replace` is given.
26
31
 
27
32
  The secret key can be a base32 secret or an `otpauth://totp/...` URI (its digits, period and algorithm are respected).
28
33
 
29
- Commands exit with a non-zero code when they fail (e.g. authenticator not found), so they can be used in scripts.
34
+ Counter-based (HOTP) authenticators are also supported, though rare: each `get` shows the next code and saves the new counter, so only ask for a code when you are going to use it.
35
+
36
+ Commands exit with a non-zero code when they fail (e.g. authenticator not found), so they can be used in scripts. When the output is not a terminal, `get` prints only the code.
37
+
38
+ win-cliauth checks npm at most once a day and tells you when a newer version is available. Set `WIN_CLIAUTH_NO_UPDATE_CHECK=1` to turn this off.
30
39
 
31
40
  To use win-cliauth, open a terminal or command prompt and run one of the commands listed above.
32
41
 
@@ -55,7 +64,13 @@ Anyone with your `shared_secret` can generate your Steam Guard codes, so keep `.
55
64
 
56
65
  ## Backups
57
66
 
58
- The vault is tied to your Windows user account: reinstalling Windows or switching accounts makes it unreadable, even if you copied the `keys` file. Before that happens, run `win-cliauth list --showsecret` and store the secrets somewhere safe (e.g. a password manager), along with each service's recovery codes, such as the Steam revocation code from your `.maFile`.
67
+ The vault is tied to your Windows user account: reinstalling Windows or switching accounts makes it unreadable, even if you copied the `keys` file. Before that happens, make a backup:
68
+
69
+ ```
70
+ win-cliauth export D:\backup\win-cliauth.json
71
+ ```
72
+
73
+ The file is encrypted with the password you choose (scrypt + AES-256-GCM), so it can be kept on a USB drive or in cloud storage, and restored on any PC or account with `win-cliauth import`. Without the password it cannot be restored, so keep the password safe too, along with each service's recovery codes, such as the Steam revocation code from your `.maFile`.
59
74
 
60
75
  win-cliauth is provided without warranty; see the [license](LICENSE).
61
76
 
@@ -63,7 +78,6 @@ win-cliauth is provided without warranty; see the [license](LICENSE).
63
78
 
64
79
  This project uses the following open-source libraries:
65
80
 
66
- - [otpauth](https://github.com/hectorm/otpauth) - One Time Password (HOTP/TOTP) library for Node.js, Deno, Bun and browsers.
67
81
  - [yargs](https://github.com/yargs/yargs) - A command-line argument parsing library for Node.js.
68
82
 
69
83
  Please refer to the respective library documentation for detailed information on their usage, licensing, and contributions.
package/dist/main.js CHANGED
@@ -9,7 +9,10 @@ LICENSE file in the root directory of this source tree.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  const commands_1 = require("./src/commands");
12
- (0, commands_1.handleCommands)().catch((error) => {
12
+ const update_1 = require("./src/update");
13
+ (0, commands_1.handleCommands)()
14
+ .catch((error) => {
13
15
  console.error(`Error: ${error instanceof Error ? error.message : error}`);
14
16
  process.exitCode = 1;
15
- });
17
+ })
18
+ .then(update_1.notifyUpdate);
package/dist/src/auth.js CHANGED
@@ -7,18 +7,26 @@ This source code is licensed under the BSD-style license found in the
7
7
  LICENSE file in the root directory of this source tree.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.countdown = countdown;
10
11
  exports.getAuth = getAuth;
11
12
  exports.addAuth = addAuth;
12
13
  exports.removeAuth = removeAuth;
13
14
  exports.listAuth = listAuth;
15
+ exports.renameAuth = renameAuth;
16
+ exports.exportAuth = exportAuth;
17
+ exports.importAuth = importAuth;
14
18
  const child_process_1 = require("child_process");
15
19
  const promises_1 = require("fs/promises");
20
+ const backup_1 = require("./backup");
21
+ const names_1 = require("./names");
16
22
  const otp_1 = require("./otp");
17
23
  const prompt_1 = require("./prompt");
18
24
  const pshell_1 = require("./pshell");
19
25
  const time_1 = require("./time");
20
26
  function parseEntry(entry) {
21
- return typeof entry === 'string' ? { secret: entry, steam: false } : { secret: entry.secret, steam: !!entry.steam };
27
+ return typeof entry === 'string'
28
+ ? { secret: entry, steam: false }
29
+ : { secret: entry.secret, steam: !!entry.steam, counter: entry.counter };
22
30
  }
23
31
  function notFound(name) {
24
32
  console.error(`${name} not found.`);
@@ -32,25 +40,103 @@ function copyToClipboard(text) {
32
40
  clip.stdin.end(text);
33
41
  });
34
42
  }
43
+ // Clears the clipboard after a delay, in a detached process so the command returns
44
+ // right away; it leaves the clipboard alone if something else was copied meanwhile.
45
+ function clearClipboardLater(token, seconds) {
46
+ const script = `
47
+ Start-Sleep -Seconds $env:WIN_CLIAUTH_CLEAR_AFTER
48
+ if ("$(Get-Clipboard -Raw)".Trim() -eq $env:WIN_CLIAUTH_TOKEN) {
49
+ Add-Type -AssemblyName System.Windows.Forms
50
+ [System.Windows.Forms.Clipboard]::Clear()
51
+ }`;
52
+ (0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
53
+ detached: true,
54
+ stdio: 'ignore',
55
+ windowsHide: true,
56
+ env: { ...(0, pshell_1.powershellEnv)(), WIN_CLIAUTH_TOKEN: token, WIN_CLIAUTH_CLEAR_AFTER: String(seconds) },
57
+ }).unref();
58
+ }
59
+ function sleep(ms) {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
35
62
  async function readMaFile(path) {
36
63
  const maFile = JSON.parse(await (0, promises_1.readFile)(path, 'utf8'));
37
64
  if (!maFile?.shared_secret)
38
65
  throw new Error('No shared_secret in maFile (if it is encrypted, decrypt it first).');
39
66
  return maFile.shared_secret;
40
67
  }
41
- async function getAuth({ name, steam, clipboard, sync }) {
68
+ function countdown(code, msLeft, period) {
69
+ const width = 20;
70
+ const filled = Math.round((msLeft / (period * 1000)) * width);
71
+ return `${code} ${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${String(Math.ceil(msLeft / 1000)).padStart(2)}s`;
72
+ }
73
+ async function getAuth({ name, steam, clipboard, clear = 30, sync, watch }) {
74
+ if (watch && !process.stdout.isTTY)
75
+ throw new Error('--watch needs a terminal.');
42
76
  const objAuth = await (0, pshell_1.decrypt)();
43
- if (!objAuth[name])
44
- return notFound(name);
45
- const entry = parseEntry(objAuth[name]);
46
- const token = steam || entry.steam
47
- ? (0, otp_1.steamCode)(entry.secret, await (0, time_1.syncedNow)('steam', sync))
48
- : (0, otp_1.totpCode)(entry.secret, await (0, time_1.syncedNow)('totp', sync));
49
- if (clipboard)
77
+ const found = (0, names_1.findName)(Object.keys(objAuth), name);
78
+ if ('error' in found) {
79
+ console.error(found.error);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ const entry = parseEntry(objAuth[found.name]);
84
+ const copy = async (token) => {
50
85
  await copyToClipboard(token);
51
- console.info(token);
86
+ if (clear > 0)
87
+ clearClipboardLater(token, clear);
88
+ };
89
+ if (entry.counter !== undefined) {
90
+ // HOTP: each code is used once; the next counter is saved before the code is shown
91
+ if (watch)
92
+ throw new Error('--watch only works with time-based codes.');
93
+ const token = (0, otp_1.hotpCode)(entry.secret, entry.counter);
94
+ objAuth[found.name] = { secret: entry.secret, counter: entry.counter + 1 };
95
+ await (0, pshell_1.encrypt)(objAuth);
96
+ if (clipboard)
97
+ await copy(token);
98
+ console.info(token);
99
+ return;
100
+ }
101
+ const isSteam = !!(steam || entry.steam);
102
+ const period = (0, otp_1.codePeriod)(entry.secret, isSteam);
103
+ const generate = (ts) => (isSteam ? (0, otp_1.steamCode)(entry.secret, ts) : (0, otp_1.totpCode)(entry.secret, ts));
104
+ let timestamp = await (0, time_1.syncedNow)(isSteam ? 'steam' : 'totp', sync);
105
+ const msLeft = (ts) => period * 1000 - (ts % (period * 1000));
106
+ if (watch) {
107
+ // keeps the code on screen, switching to the next one as it changes, until Ctrl+C
108
+ const offset = timestamp - Date.now();
109
+ process.on('SIGINT', () => {
110
+ process.stdout.write('\n');
111
+ process.exit(0);
112
+ });
113
+ console.info(`${found.name} (Ctrl+C to stop)`);
114
+ let last = '';
115
+ for (;;) {
116
+ const now = Date.now() + offset;
117
+ const token = generate(now);
118
+ if (token !== last && clipboard)
119
+ await copy(token);
120
+ last = token;
121
+ process.stdout.write(`\r${countdown(token, msLeft(now), period)} `);
122
+ await sleep(1000 - (now % 1000) + 10);
123
+ }
124
+ }
125
+ // a code about to expire is of little use: wait for the next one
126
+ if (msLeft(timestamp) <= 2000) {
127
+ const wait = msLeft(timestamp);
128
+ if (process.stderr.isTTY)
129
+ console.error('Code about to expire, waiting for the next one...');
130
+ await sleep(wait);
131
+ timestamp += wait;
132
+ }
133
+ const token = generate(timestamp);
134
+ if (clipboard)
135
+ await copy(token);
136
+ // scripts get only the code; people also see how long it stays valid
137
+ console.info(process.stdout.isTTY ? `${token} (${Math.ceil(msLeft(timestamp) / 1000)}s left)` : token);
52
138
  }
53
- async function addAuth({ name, secretKey, steam, mafile, replace }) {
139
+ async function addAuth({ name, secretKey, steam, mafile, replace, hotp, counter }) {
54
140
  let secret;
55
141
  if (mafile) {
56
142
  secret = await readMaFile(mafile);
@@ -67,9 +153,17 @@ async function addAuth({ name, secretKey, steam, mafile, replace }) {
67
153
  throw new Error('No secret key given.');
68
154
  if (/^steam:\/\//i.test(secret))
69
155
  steam = true;
156
+ if ((0, otp_1.isHotpUri)(secret) || counter !== undefined)
157
+ hotp = true;
158
+ if (steam && hotp)
159
+ throw new Error('Steam Guard codes are time-based; --hotp and --counter do not apply.');
160
+ if (counter !== undefined && (!Number.isSafeInteger(counter) || counter < 0))
161
+ throw new Error('--counter must be a whole number, 0 or more.');
70
162
  // validates the secret before storing it
71
163
  if (steam)
72
164
  (0, otp_1.steamSecret)(secret);
165
+ else if (hotp)
166
+ (0, otp_1.hotpCode)(secret, 0);
73
167
  else
74
168
  (0, otp_1.totpCode)(secret, Date.now());
75
169
  const objAuth = await (0, pshell_1.decrypt)();
@@ -78,7 +172,10 @@ async function addAuth({ name, secretKey, steam, mafile, replace }) {
78
172
  process.exitCode = 1;
79
173
  return;
80
174
  }
81
- objAuth[name] = steam ? { secret, steam: true } : secret;
175
+ if (hotp)
176
+ objAuth[name] = { secret, counter: counter ?? (0, otp_1.initialCounter)(secret) };
177
+ else
178
+ objAuth[name] = steam ? { secret, steam: true } : secret;
82
179
  await (0, pshell_1.encrypt)(objAuth);
83
180
  console.info(`${name} added!`);
84
181
  }
@@ -97,3 +194,54 @@ async function listAuth({ showsecret }) {
97
194
  console.warn('Warning: secret keys are shown in plain text.');
98
195
  console.table(Object.fromEntries(Object.entries(objAuth).map(([name, entry]) => [name, parseEntry(entry)])));
99
196
  }
197
+ async function renameAuth({ oldName, newName }) {
198
+ const objAuth = await (0, pshell_1.decrypt)();
199
+ if (!objAuth[oldName])
200
+ return notFound(oldName);
201
+ if (objAuth[newName]) {
202
+ console.error(`${newName} already exists.`);
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ objAuth[newName] = objAuth[oldName];
207
+ delete objAuth[oldName];
208
+ await (0, pshell_1.encrypt)(objAuth);
209
+ console.info(`${oldName} renamed to ${newName}.`);
210
+ }
211
+ async function exportAuth({ file, force }) {
212
+ const objAuth = await (0, pshell_1.decrypt)();
213
+ const password = await (0, prompt_1.readSecret)('Backup password: ');
214
+ if (password.length < 8)
215
+ throw new Error('Use a password of at least 8 characters.');
216
+ if (process.stdin.isTTY && (await (0, prompt_1.readSecret)('Repeat password: ')) !== password)
217
+ throw new Error('Passwords do not match.');
218
+ try {
219
+ await (0, promises_1.writeFile)(file, (0, backup_1.encryptBackup)(objAuth, password), { flag: force ? 'w' : 'wx' });
220
+ }
221
+ catch (error) {
222
+ if (error.code === 'EEXIST')
223
+ throw new Error(`${file} already exists, add --force to overwrite it.`, { cause: error });
224
+ throw error;
225
+ }
226
+ console.info(`${Object.keys(objAuth).length} authenticator(s) exported to ${file}.`);
227
+ }
228
+ async function importAuth({ file, replace }) {
229
+ const text = await (0, promises_1.readFile)(file, 'utf8');
230
+ const backup = (0, backup_1.decryptBackup)(text, await (0, prompt_1.readSecret)('Backup password: '));
231
+ const objAuth = await (0, pshell_1.decrypt)();
232
+ const added = [];
233
+ const skipped = [];
234
+ for (const [name, entry] of Object.entries(backup)) {
235
+ if (objAuth[name] && !replace)
236
+ skipped.push(name);
237
+ else {
238
+ objAuth[name] = entry;
239
+ added.push(name);
240
+ }
241
+ }
242
+ if (added.length)
243
+ await (0, pshell_1.encrypt)(objAuth);
244
+ console.info(`${added.length} authenticator(s) imported.`);
245
+ if (skipped.length)
246
+ console.warn(`Skipped (already exist, add --replace to overwrite): ${skipped.join(', ')}`);
247
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /*
3
+ Copyright (c) <2022>, <Pedro Lucas Magalhães de Oliveira>
4
+ All rights reserved.
5
+
6
+ This source code is licensed under the BSD-style license found in the
7
+ LICENSE file in the root directory of this source tree.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.encryptBackup = encryptBackup;
11
+ exports.decryptBackup = decryptBackup;
12
+ const crypto_1 = require("crypto");
13
+ // Password-protected export of the vault: scrypt-derived key, AES-256-GCM.
14
+ const FORMAT = 'win-cliauth-backup';
15
+ const SCRYPT = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
16
+ function deriveKey(password, salt, { N, r, p }) {
17
+ return (0, crypto_1.scryptSync)(password.normalize('NFC'), salt, 32, { N, r, p, maxmem: SCRYPT.maxmem });
18
+ }
19
+ function encryptBackup(vault, password) {
20
+ const salt = (0, crypto_1.randomBytes)(16);
21
+ const iv = (0, crypto_1.randomBytes)(12);
22
+ const cipher = (0, crypto_1.createCipheriv)('aes-256-gcm', deriveKey(password, salt, SCRYPT), iv);
23
+ const data = Buffer.concat([cipher.update(JSON.stringify(vault), 'utf8'), cipher.final()]);
24
+ const backup = {
25
+ format: FORMAT,
26
+ version: 1,
27
+ kdf: { name: 'scrypt', N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, salt: salt.toString('base64') },
28
+ iv: iv.toString('base64'),
29
+ tag: cipher.getAuthTag().toString('base64'),
30
+ data: data.toString('base64'),
31
+ };
32
+ return JSON.stringify(backup, null, 2);
33
+ }
34
+ function decryptBackup(text, password) {
35
+ let backup;
36
+ try {
37
+ backup = JSON.parse(text);
38
+ }
39
+ catch {
40
+ throw new Error('Not a win-cliauth backup file.');
41
+ }
42
+ if (backup?.format !== FORMAT || backup.version !== 1 || backup.kdf?.name !== 'scrypt')
43
+ throw new Error('Not a win-cliauth backup file (or made by a newer version).');
44
+ const key = deriveKey(password, Buffer.from(backup.kdf.salt, 'base64'), backup.kdf);
45
+ const decipher = (0, crypto_1.createDecipheriv)('aes-256-gcm', key, Buffer.from(backup.iv, 'base64'));
46
+ decipher.setAuthTag(Buffer.from(backup.tag, 'base64'));
47
+ let json;
48
+ try {
49
+ json = Buffer.concat([decipher.update(Buffer.from(backup.data, 'base64')), decipher.final()]).toString('utf8');
50
+ }
51
+ catch {
52
+ throw new Error('Wrong password, or the backup file is damaged.');
53
+ }
54
+ return Object.assign(Object.create(null), JSON.parse(json));
55
+ }
@@ -19,23 +19,40 @@ async function handleCommands() {
19
19
  .scriptName("win-cliauth")
20
20
  .command('add <name> [secret-key]', 'adds authenticator with given secret key (prompts for it when omitted).', (y) => y
21
21
  .positional('name', { type: 'string', demandOption: true })
22
- .positional('secret-key', { type: 'string', description: 'base32 secret, otpauth:// URI, or Steam shared_secret' })
22
+ .positional('secret-key', { type: 'string', description: 'base32 secret, otpauth://totp/ or otpauth://hotp/ URI, or Steam shared_secret' })
23
23
  .options({
24
24
  'replace': { type: 'boolean', description: "forces replacement of existing service." },
25
25
  'steam': { type: 'boolean', description: "stores it as a Steam Guard authenticator." },
26
26
  'mafile': { type: 'string', description: "imports the Steam shared_secret from a .maFile." },
27
+ 'hotp': { type: 'boolean', description: "stores it as a counter-based (HOTP) authenticator (otpauth://hotp/ links are detected)." },
28
+ 'counter': { type: 'number', description: "HOTP counter to start from (default: the link's counter, or 0)." },
27
29
  }), (argv) => (0, auth_1.addAuth)(argv))
28
30
  .command('remove <name>', 'removes the given <name> authenticator.', (y) => y.positional('name', { type: 'string', demandOption: true }), (argv) => (0, auth_1.removeAuth)(argv))
29
- .command('get <name>', 'gets the token from service <name>', (y) => y
31
+ .command('get <name>', 'gets the token from service <name> (a unique prefix of the name is enough)', (y) => y
30
32
  .positional('name', { type: 'string', demandOption: true })
31
33
  .options({
32
34
  'steam': { type: 'boolean', description: "gets token from steam authenticator." },
33
35
  'clipboard': { type: 'boolean', description: 'adds authenticator code to clipboard' },
36
+ 'clear': { type: 'number', default: 30, description: 'seconds before the copied code is cleared from the clipboard (0 keeps it)' },
34
37
  'sync': { type: 'boolean', description: 'forces a time re-sync instead of using the cached offset' },
38
+ 'watch': { type: 'boolean', description: 'keeps the code on screen with a countdown, updating it until Ctrl+C' },
35
39
  }), (argv) => (0, auth_1.getAuth)(argv))
36
40
  .command('list', 'Prints out the list of authenticators.', (y) => y.options({
37
41
  'showsecret': { type: 'boolean', description: "Prints out the list with secret keys" },
38
42
  }), (argv) => (0, auth_1.listAuth)(argv))
43
+ .command('rename <old-name> <new-name>', 'renames an authenticator.', (y) => y
44
+ .positional('old-name', { type: 'string', demandOption: true })
45
+ .positional('new-name', { type: 'string', demandOption: true }), (argv) => (0, auth_1.renameAuth)(argv))
46
+ .command('export <file>', 'saves all authenticators to a password-protected backup file.', (y) => y
47
+ .positional('file', { type: 'string', demandOption: true })
48
+ .options({
49
+ 'force': { type: 'boolean', description: 'overwrites the file if it exists.' },
50
+ }), (argv) => (0, auth_1.exportAuth)(argv))
51
+ .command('import <file>', 'adds the authenticators from a backup file made with export.', (y) => y
52
+ .positional('file', { type: 'string', demandOption: true })
53
+ .options({
54
+ 'replace': { type: 'boolean', description: 'overwrites authenticators that already exist.' },
55
+ }), (argv) => (0, auth_1.importAuth)(argv))
39
56
  .wrap(null)
40
57
  .demandCommand()
41
58
  .recommendCommands()
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /*
3
+ Copyright (c) <2022>, <Pedro Lucas Magalhães de Oliveira>
4
+ All rights reserved.
5
+
6
+ This source code is licensed under the BSD-style license found in the
7
+ LICENSE file in the root directory of this source tree.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.findName = findName;
11
+ /**
12
+ * Resolves what the user typed to a stored name: exact match first, then a
13
+ * case-insensitive match, then a unique case-insensitive prefix.
14
+ */
15
+ function findName(names, query) {
16
+ if (names.includes(query))
17
+ return { name: query };
18
+ const q = query.toLowerCase();
19
+ const exact = names.filter((n) => n.toLowerCase() === q);
20
+ if (exact.length === 1)
21
+ return { name: exact[0] };
22
+ const matches = exact.length ? exact : names.filter((n) => n.toLowerCase().startsWith(q));
23
+ if (matches.length === 1)
24
+ return { name: matches[0] };
25
+ if (matches.length > 1)
26
+ return { error: `${query} matches several authenticators: ${matches.join(', ')}` };
27
+ return { error: `${query} not found.` };
28
+ }
package/dist/src/otp.js CHANGED
@@ -7,20 +7,90 @@ This source code is licensed under the BSD-style license found in the
7
7
  LICENSE file in the root directory of this source tree.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.isHotpUri = isHotpUri;
11
+ exports.initialCounter = initialCounter;
10
12
  exports.steamSecret = steamSecret;
11
13
  exports.steamCode = steamCode;
12
14
  exports.totpCode = totpCode;
15
+ exports.hotpCode = hotpCode;
16
+ exports.codePeriod = codePeriod;
13
17
  const crypto_1 = require("crypto");
14
- const otpauth_1 = require("otpauth");
15
18
  const STEAM_CHARS = '23456789BCDFGHJKMNPQRTVWXY';
19
+ const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
20
+ const ALGORITHMS = { SHA1: 'sha1', SHA256: 'sha256', SHA512: 'sha512' };
21
+ /** RFC 4648 base32; spaces, dashes, padding and lower case are accepted. */
22
+ function fromBase32(str) {
23
+ const clean = str.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase();
24
+ if (!clean)
25
+ throw new Error('Invalid secret: it is empty.');
26
+ const bytes = [];
27
+ let bits = 0;
28
+ let value = 0;
29
+ for (const char of clean) {
30
+ const index = BASE32_CHARS.indexOf(char);
31
+ if (index < 0)
32
+ throw new Error(`Invalid secret: "${char}" is not a base32 character.`);
33
+ value = (value << 5) | index;
34
+ bits += 5;
35
+ if (bits >= 8) {
36
+ bits -= 8;
37
+ bytes.push((value >>> bits) & 0xff);
38
+ }
39
+ }
40
+ return Buffer.from(bytes);
41
+ }
42
+ /** Reads an otpauth://totp/... or otpauth://hotp/... URI (Google Authenticator key URI format). */
16
43
  function parseUri(uri) {
17
- const otp = otpauth_1.URI.parse(uri);
18
- if (!(otp instanceof otpauth_1.TOTP))
19
- throw new Error('Only TOTP (time-based) otpauth:// URIs are supported.');
44
+ const url = new URL(uri);
45
+ const type = url.host.toLowerCase();
46
+ if (type !== 'totp' && type !== 'hotp')
47
+ throw new Error('Unsupported otpauth:// URI: expected otpauth://totp/ or otpauth://hotp/.');
48
+ const params = url.searchParams;
49
+ const secret = params.get('secret');
50
+ if (!secret)
51
+ throw new Error('The otpauth:// URI has no secret.');
52
+ const algorithm = ALGORITHMS[(params.get('algorithm') ?? 'SHA1').toUpperCase().replace('-', '')];
53
+ if (!algorithm)
54
+ throw new Error(`Unsupported algorithm ${params.get('algorithm')} (use SHA1, SHA256 or SHA512).`);
55
+ const digits = Number(params.get('digits') ?? 6);
56
+ if (!Number.isInteger(digits) || digits < 1 || digits > 10)
57
+ throw new Error(`Invalid digits ${params.get('digits')} in otpauth:// URI.`);
58
+ const period = Number(params.get('period') ?? 30);
59
+ if (!Number.isInteger(period) || period < 1)
60
+ throw new Error(`Invalid period ${params.get('period')} in otpauth:// URI.`);
61
+ const counter = Number(params.get('counter') ?? 0);
62
+ if (!Number.isSafeInteger(counter) || counter < 0)
63
+ throw new Error(`Invalid counter ${params.get('counter')} in otpauth:// URI.`);
64
+ return { type, key: fromBase32(secret), algorithm, digits, period, counter };
65
+ }
66
+ function otpOf(secret) {
67
+ const s = secret.trim();
68
+ return /^otpauth:\/\//i.test(s)
69
+ ? parseUri(s)
70
+ : { type: 'totp', key: fromBase32(s), algorithm: 'sha1', digits: 6, period: 30, counter: 0 };
71
+ }
72
+ function totpOf(secret) {
73
+ const otp = otpOf(secret);
74
+ if (otp.type === 'hotp')
75
+ throw new Error('This is a counter-based (HOTP) secret; it has no time-based code.');
20
76
  return otp;
21
77
  }
22
- function fromBase32(str) {
23
- return Buffer.from(otpauth_1.Secret.fromBase32(str.replace(/[\s-]/g, '').toUpperCase()).bytes);
78
+ /** True for otpauth://hotp/ URIs. */
79
+ function isHotpUri(secret) {
80
+ return /^otpauth:\/\/hotp\//i.test(secret.trim());
81
+ }
82
+ /** The counter an otpauth://hotp/ URI starts at (0 for anything else). */
83
+ function initialCounter(secret) {
84
+ return otpOf(secret).counter;
85
+ }
86
+ /** RFC 4226 dynamic truncation of the HMAC of a counter (the time step for TOTP). */
87
+ function truncatedHmac(key, algorithm, counter) {
88
+ const message = Buffer.alloc(8);
89
+ message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
90
+ message.writeUInt32BE(counter >>> 0, 4);
91
+ const hmac = (0, crypto_1.createHmac)(algorithm, key).update(message).digest();
92
+ const offset = hmac[hmac.length - 1] & 0x0f;
93
+ return hmac.readUInt32BE(offset) & 0x7fffffff;
24
94
  }
25
95
  /**
26
96
  * Decodes a Steam Guard secret in any of the formats tools export it in:
@@ -32,7 +102,7 @@ function steamSecret(secret) {
32
102
  const s = secret.trim();
33
103
  let key;
34
104
  if (/^otpauth:\/\//i.test(s))
35
- key = Buffer.from(parseUri(s).secret.bytes);
105
+ key = parseUri(s).key;
36
106
  else if (/^steam:\/\//i.test(s))
37
107
  key = fromBase32(s.slice('steam://'.length));
38
108
  else if (/^[0-9a-f]{40}$/i.test(s))
@@ -48,11 +118,7 @@ function steamSecret(secret) {
48
118
  return key;
49
119
  }
50
120
  function steamCode(secret, timestamp) {
51
- const counter = Buffer.alloc(8);
52
- counter.writeUInt32BE(Math.floor(timestamp / 1000 / 30), 4);
53
- const hmac = (0, crypto_1.createHmac)('sha1', steamSecret(secret)).update(counter).digest();
54
- const offset = hmac[19] & 0x0f;
55
- let fullCode = hmac.readUInt32BE(offset) & 0x7fffffff;
121
+ let fullCode = truncatedHmac(steamSecret(secret), 'sha1', Math.floor(timestamp / 1000 / 30));
56
122
  let code = '';
57
123
  for (let i = 0; i < 5; i++) {
58
124
  code += STEAM_CHARS[fullCode % STEAM_CHARS.length];
@@ -60,10 +126,19 @@ function steamCode(secret, timestamp) {
60
126
  }
61
127
  return code;
62
128
  }
129
+ function formatCode(value, digits) {
130
+ return String(value % 10 ** digits).padStart(digits, '0');
131
+ }
63
132
  function totpCode(secret, timestamp) {
64
- const s = secret.trim();
65
- const totp = /^otpauth:\/\//i.test(s)
66
- ? parseUri(s)
67
- : new otpauth_1.TOTP({ secret: otpauth_1.Secret.fromBase32(s.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase()), digits: 6, period: 30 });
68
- return totp.generate({ timestamp });
133
+ const { key, algorithm, digits, period } = totpOf(secret);
134
+ return formatCode(truncatedHmac(key, algorithm, Math.floor(timestamp / 1000 / period)), digits);
135
+ }
136
+ /** RFC 4226 counter-based code; secret is an otpauth://hotp/ URI or a plain base32 secret. */
137
+ function hotpCode(secret, counter) {
138
+ const { key, algorithm, digits } = otpOf(secret);
139
+ return formatCode(truncatedHmac(key, algorithm, counter), digits);
140
+ }
141
+ /** Seconds each code is valid for (Steam Guard and plain secrets use 30). */
142
+ function codePeriod(secret, steam) {
143
+ return steam ? 30 : totpOf(secret).period;
69
144
  }
@@ -7,15 +7,33 @@ This source code is licensed under the BSD-style license found in the
7
7
  LICENSE file in the root directory of this source tree.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.powershellEnv = powershellEnv;
11
+ exports.readableError = readableError;
10
12
  exports.encrypt = encrypt;
11
13
  exports.decrypt = decrypt;
12
14
  const child_process_1 = require("child_process");
15
+ // Windows PowerShell must not inherit PowerShell 7's module path (set when win-cliauth runs from pwsh),
16
+ // or it fails to load its own modules such as Microsoft.PowerShell.Security.
17
+ function powershellEnv(env = process.env) {
18
+ return Object.fromEntries(Object.entries(env).filter(([key]) => key.toUpperCase() !== 'PSMODULEPATH'));
19
+ }
20
+ // Turns PowerShell's serialized "#< CLIXML" error output into plain text.
21
+ function readableError(stderr) {
22
+ if (!stderr.startsWith('#< CLIXML'))
23
+ return stderr;
24
+ const errors = [...stderr.matchAll(/<S S="Error">([\s\S]*?)<\/S>/g)].map(([, text]) => text);
25
+ return errors
26
+ .join('')
27
+ .replace(/_x000D__x000A_/g, '\n')
28
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&')
29
+ .trim() || stderr;
30
+ }
13
31
  // Secrets never appear in the script text (which is visible in process listings):
14
32
  // they travel through stdin/stdout as base64-encoded UTF-8.
15
33
  function pshell({ script, input = '' }) {
16
- const encoded = Buffer.from(`$ErrorActionPreference = 'Stop'\n${script}`, 'utf16le').toString('base64');
34
+ const encoded = Buffer.from(`$ErrorActionPreference = 'Stop'\n$ProgressPreference = 'SilentlyContinue'\n${script}`, 'utf16le').toString('base64');
17
35
  return new Promise((resolve, reject) => {
18
- const ps = (0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { stdio: 'pipe', windowsHide: true });
36
+ const ps = (0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', encoded], { stdio: 'pipe', windowsHide: true, env: powershellEnv() });
19
37
  let stdout = '';
20
38
  let stderr = '';
21
39
  ps.stdout.on('data', (chunk) => (stdout += chunk));
@@ -24,7 +42,7 @@ function pshell({ script, input = '' }) {
24
42
  ps.on('close', (code) => {
25
43
  if (code === 0)
26
44
  return resolve(stdout.trim());
27
- reject(new Error(stderr.trim() || `PowerShell exited with code ${code}`));
45
+ reject(new Error(readableError(stderr.trim()) || `PowerShell exited with code ${code}`));
28
46
  });
29
47
  ps.stdin.end(input);
30
48
  });
@@ -60,10 +78,12 @@ async function decrypt() {
60
78
  }
61
79
  `,
62
80
  });
81
+ // null prototype: names like "toString" or "__proto__" are plain keys, not inherited properties
82
+ const vault = Object.create(null);
63
83
  if (!output)
64
- return {};
84
+ return vault;
65
85
  try {
66
- return JSON.parse(Buffer.from(output, 'base64').toString('utf8'));
86
+ return Object.assign(vault, JSON.parse(Buffer.from(output, 'base64').toString('utf8')));
67
87
  }
68
88
  catch {
69
89
  throw new Error('The key file is corrupted (a backup may exist at %AppData%\\win-cliauth\\keys.bak).');
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ /*
3
+ Copyright (c) <2022>, <Pedro Lucas Magalhães de Oliveira>
4
+ All rights reserved.
5
+
6
+ This source code is licensed under the BSD-style license found in the
7
+ LICENSE file in the root directory of this source tree.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.currentVersion = currentVersion;
11
+ exports.isNewer = isNewer;
12
+ exports.notifyUpdate = notifyUpdate;
13
+ const fs_1 = require("fs");
14
+ const promises_1 = require("fs/promises");
15
+ const os_1 = require("os");
16
+ const path_1 = require("path");
17
+ const CACHE_PATH = (0, path_1.join)(process.env.APPDATA ?? (0, os_1.homedir)(), 'win-cliauth', 'update.json');
18
+ const CHECK_EVERY = 24 * 60 * 60 * 1000;
19
+ const TIMEOUT = 1500;
20
+ function currentVersion() {
21
+ return JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', '..', 'package.json'), 'utf8')).version;
22
+ }
23
+ /** True when version a is newer than b (x.y.z, pre-release tags ignored). */
24
+ function isNewer(a, b) {
25
+ const parse = (v) => v.split('-')[0].split('.').map(Number);
26
+ const [x, y] = [parse(a), parse(b)];
27
+ for (let i = 0; i < 3; i++)
28
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
29
+ return (x[i] ?? 0) > (y[i] ?? 0);
30
+ return false;
31
+ }
32
+ /**
33
+ * Prints a one-line notice on stderr when a newer version is on npm. The
34
+ * registry is asked at most once a day, with a short timeout; failures are
35
+ * ignored. Disabled for scripts (stderr not a terminal), in CI, and with
36
+ * WIN_CLIAUTH_NO_UPDATE_CHECK=1.
37
+ */
38
+ async function notifyUpdate() {
39
+ if (!process.stderr.isTTY || process.env.CI || process.env.WIN_CLIAUTH_NO_UPDATE_CHECK)
40
+ return;
41
+ try {
42
+ let cache = {};
43
+ try {
44
+ cache = JSON.parse(await (0, promises_1.readFile)(CACHE_PATH, 'utf8'));
45
+ }
46
+ catch {
47
+ // first run or unreadable cache
48
+ }
49
+ if (!cache.checkedAt || Date.now() - cache.checkedAt > CHECK_EVERY) {
50
+ const response = await fetch('https://registry.npmjs.org/win-cliauth/latest', { signal: AbortSignal.timeout(TIMEOUT) });
51
+ const { version } = (await response.json());
52
+ cache = { checkedAt: Date.now(), latest: version };
53
+ await (0, promises_1.mkdir)((0, path_1.dirname)(CACHE_PATH), { recursive: true });
54
+ await (0, promises_1.writeFile)(CACHE_PATH, JSON.stringify(cache));
55
+ }
56
+ const current = currentVersion();
57
+ if (cache.latest && isNewer(cache.latest, current))
58
+ console.error(`\nUpdate available: ${current} → ${cache.latest}. Run: npm install -g win-cliauth`);
59
+ }
60
+ catch {
61
+ // never let the update check break a command
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "win-cliauth",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "win-cliauth is a CLI-based OTP (One-Time Password) authenticator management tool for Windows.",
5
5
  "author": "Pedro Lucas Magalhães <pedrolucasmag@gmail.com>",
6
6
  "license": "BSD-3-Clause",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
- "lint": "eslint . --ext .ts",
9
+ "lint": "eslint .",
10
10
  "test": "tsc && node --test \"dist/test/**/*.test.js\"",
11
+ "prepare": "husky",
11
12
  "prepack": "tsc"
12
13
  },
13
14
  "bin": {
@@ -23,15 +24,16 @@
23
24
  "!dist/test"
24
25
  ],
25
26
  "devDependencies": {
26
- "@commitlint/cli": "^17.7.1",
27
- "@commitlint/config-conventional": "^17.7.0",
28
- "@types/node": "^18.11.10",
27
+ "@commitlint/cli": "^21.2.3",
28
+ "@commitlint/config-conventional": "^21.2.3",
29
+ "@eslint/js": "^10.0.1",
30
+ "@types/node": "^20.19.0",
29
31
  "@types/yargs": "^17.0.10",
30
- "@typescript-eslint/eslint-plugin": "^8.0.0",
31
- "@typescript-eslint/parser": "^8.0.0",
32
- "eslint": "^8.57.0",
33
- "husky": "^8.0.3",
34
- "typescript": "^5.4.0"
32
+ "eslint": "^10.12.0",
33
+ "globals": "^17.13.0",
34
+ "husky": "^9.1.7",
35
+ "typescript": "^5.4.0",
36
+ "typescript-eslint": "^8.71.1"
35
37
  },
36
38
  "os": [
37
39
  "win32"
@@ -47,10 +49,9 @@
47
49
  "steam"
48
50
  ],
49
51
  "dependencies": {
50
- "otpauth": "^9.1.4",
51
52
  "yargs": "^17.5.1"
52
53
  },
53
54
  "engines": {
54
- "node": ">=18"
55
+ "node": ">=20"
55
56
  }
56
57
  }