Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ network request.
| Package | npm | Source | Description |
|---------|-----|--------|-------------|
| **@decryption/core** | [![npm](https://img.shields.io/npm/v/@decryption/core.svg)](https://www.npmjs.com/package/@decryption/core) | [GitHub](./packages/core) | Versioned envelope format — Argon2id + XChaCha20-Poly1305 |
| **@decryption/legacy** | [![npm](https://img.shields.io/npm/v/@decryption/legacy.svg)](https://www.npmjs.com/package/@decryption/legacy) | [GitHub](./packages/legacy) | Byte-compatible reader for the old CryptoJS/`@cosmology/core` scheme |
| **@decryption/cosmology-compat** | [![npm](https://img.shields.io/npm/v/@decryption/cosmology-compat.svg)](https://www.npmjs.com/package/@decryption/cosmology-compat) | [GitHub](./packages/cosmology-compat) | Byte-compatible reader for the cosmology CLI encryption scheme |
| **@decryption/shamir** | [![npm](https://img.shields.io/npm/v/@decryption/shamir.svg)](https://www.npmjs.com/package/@decryption/shamir) | [GitHub](./packages/shamir) | Shamir secret sharing with authenticated, versioned shares |
| **@decryption/wallet** | [![npm](https://img.shields.io/npm/v/@decryption/wallet.svg)](https://www.npmjs.com/package/@decryption/wallet) | [GitHub](./packages/wallet) | BIP39/BIP32 wallets and offline address derivation |
| **@decryption/keys** | [![npm](https://img.shields.io/npm/v/@decryption/keys.svg)](https://www.npmjs.com/package/@decryption/keys) | [GitHub](./packages/keys) | X25519 identities, recipient strings, on-disk keyring |
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@decryption/core": "workspace:*",
"@decryption/hashes": "workspace:*",
"@decryption/keys": "workspace:*",
"@decryption/legacy": "workspace:*",
"@decryption/cosmology-compat": "workspace:*",
"@decryption/shamir": "workspace:*",
"@decryption/vault": "workspace:*",
"@decryption/wallet": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { decryptFromString, encryptToString } from '@decryption/core';
import { decrypt as legacyDecrypt } from '@decryption/legacy';
import { decrypt as legacyDecrypt } from '@decryption/cosmology-compat';
import { combineToString, splitToStrings } from '@decryption/shamir';
import { createWallet, deriveAccounts, WordCount } from '@decryption/wallet';
import { ipcMain } from 'electron';
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/src/screens/ToolsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import { toast } from 'sonner';
import type { WalletAccountInfo } from '../../../shared/api';
import { copyWithTimeout, dcrypt } from '../lib/ipc';

const NETWORKS = ['cosmoshub', 'osmosis', 'ethereum', 'bitcoin'];
const NETWORKS = ['bitcoin', 'ethereum', 'cosmoshub', 'osmosis', 'juno', 'stargaze', 'akash', 'celestia', 'secret', 'terra'];

const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err));

const WalletTool = () => {
const [network, setNetwork] = useState('cosmoshub');
const [network, setNetwork] = useState('bitcoin');
const [words, setWords] = useState('24');
const [mnemonic, setMnemonic] = useState('');
const [accounts, setAccounts] = useState<WalletAccountInfo[]>([]);
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dcrypt --help
| `dcrypt keychain set\|get\|del\|list` | Named local secrets, always encrypted |
| `dcrypt shamir split\|combine` | Authenticated Shamir shares |
| `dcrypt salt generate\|encrypt\|decrypt` | Two-layer encryption (data under a salt, salt under your passphrase) |
| `dcrypt legacy decrypt\|upgrade` | Read and migrate old CryptoJS blobs |
| `dcrypt cosmology decrypt\|upgrade` | Read and migrate data from the cosmology CLI (`legacy` still works as an alias) |

Every command supports `--help`.

Expand Down Expand Up @@ -63,6 +63,27 @@ Every prompt has a flag, so the CLI works in CI:
dcrypt decrypt --in secret.dcrypt --passphrase-file /run/secrets/passphrase --out plain.txt
```

### Environment variables

Every variable the cosmology CLI read still works, plus `DCRYPT_`-prefixed aliases
(`DCRYPT_MNEMONIC`, `DCRYPT_SALT`, ...) for environments where the bare names would collide:

| Variable | Used by |
|----------|---------|
| `MNEMONIC` | `wallet address` / `wallet validate` |
| `SALT` | `cosmology decrypt` / `cosmology upgrade` |
| `ENCRYPTED_SALT` | the two-layer cosmology scheme |
| `KEYCHAIN_ACCOUNT` | keychain namespace (default `dcrypt`) |
| `DCRYPT_PASSPHRASE` | any command that asks for a passphrase |

Resolution order everywhere: explicit flag, then environment, then stdin, then the
interactive prompt.

```bash
SALT=my-salt dcrypt cosmology decrypt --in old.txt
MNEMONIC="..." dcrypt wallet address --network ethereum
```

Add `--json` for machine-readable output, and check exit codes:

| Code | Meaning |
Expand Down
76 changes: 74 additions & 2 deletions packages/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,15 @@ describe('salt', () => {
});
});

describe('legacy', () => {
describe('cosmology', () => {
// Produced by @cosmology/core: crypt('my-salt', 'legacy plaintext').
const legacyBlob =
'U2FsdGVkX1+e1rSKneHnXFk1ufVWBgAjucz2FBFi5Qxzkuhk6KCkZF+naLOt+APM';

it('decrypts an old cryptojs blob and upgrades it', async () => {
const blob = file('old.txt', legacyBlob);
const salt = file('salt.txt', 'my-salt');
expect(await run(`legacy decrypt --in ${blob} --salt-file ${salt}`)).toBe(0);
expect(await run(`cosmology decrypt --in ${blob} --salt-file ${salt}`)).toBe(0);
expect(stdout()).toBe('legacy plaintext');

out = [];
Expand All @@ -288,6 +288,78 @@ describe('legacy', () => {
'legacy plaintext'
);
});

it('still answers to the cosmology CLI name for it, "legacy"', async () => {
const blob = file('old.txt', legacyBlob);
const salt = file('salt.txt', 'my-salt');
expect(await run(`legacy decrypt --in ${blob} --salt-file ${salt}`)).toBe(0);
expect(stdout()).toBe('legacy plaintext');
});

it('reads the salt from SALT, like the cosmology CLI', async () => {
const blob = file('old.txt', legacyBlob);
process.env.SALT = 'my-salt';
try {
expect(await run(`cosmology decrypt --in ${blob}`)).toBe(0);
expect(stdout()).toBe('legacy plaintext');
} finally {
delete process.env.SALT;
}
});
});

describe('environment variables', () => {
it('reads the passphrase from DCRYPT_PASSPHRASE', async () => {
const plain = file('plain.txt', 'env secret');
const encrypted = join(work, 'secret.dcrypt');
process.env.DCRYPT_PASSPHRASE = 'from-the-environment';
try {
expect(await run(`encrypt --in ${plain} --out ${encrypted} --kdf ${FAST_KDF}`)).toBe(0);
expect(await run(`decrypt --in ${encrypted}`)).toBe(0);
expect(stdout()).toBe('env secret');
} finally {
delete process.env.DCRYPT_PASSPHRASE;
}
});

it('reads the mnemonic from MNEMONIC, like the cosmology CLI', async () => {
process.env.MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
try {
expect(await run('wallet validate --json')).toBe(0);
expect(JSON.parse(stdout()).valid).toBe(true);
} finally {
delete process.env.MNEMONIC;
}
});

it('namespaces the keychain with KEYCHAIN_ACCOUNT, like the cosmology CLI', async () => {
const pass = file('pass.txt', 'kc-pass');
const value = file('value.txt', 'namespaced');
process.env.KEYCHAIN_ACCOUNT = 'work';
try {
expect(
await run(`keychain set token --in ${value} --kdf ${FAST_KDF} --passphrase-file ${pass}`)
).toBe(0);
expect(existsSync(join(home, '.dcrypt', 'data', 'keychain-work.json'))).toBe(true);
} finally {
delete process.env.KEYCHAIN_ACCOUNT;
}
out = [];
// Without the namespace, the entry is invisible.
expect(await run('keychain list')).toBe(0);
expect(stdout()).toBe('');
});
});

describe('wallet defaults', () => {
it('derives bitcoin when no network is named', async () => {
expect(await run('wallet create --words 12 --json')).toBe(0);
const { accounts } = JSON.parse(stdout());
expect(accounts).toHaveLength(1);
expect(accounts[0].network).toBe('bitcoin');
expect(accounts[0].address.startsWith('bc1')).toBe(true);
});
});

describe('secrets', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@decryption/core": "workspace:*",
"@decryption/hashes": "workspace:*",
"@decryption/keys": "workspace:*",
"@decryption/legacy": "workspace:*",
"@decryption/cosmology-compat": "workspace:*",
"@decryption/secrets": "workspace:*",
"@decryption/shamir": "workspace:*",
"@decryption/wallet": "workspace:*",
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { CommandHandler, getPackageJson, Inquirerer } from 'inquirerer';
import { ParsedArgs } from 'minimist';

import { cosmologyCommand } from './commands/cosmology';
import { decryptCommand, encryptCommand } from './commands/encrypt';
import { keychainCommand } from './commands/keychain';
import { keysCommand } from './commands/keys';
import { legacyCommand } from './commands/legacy';
import { saltCommand } from './commands/salt';
import { secretsCommand } from './commands/secrets';
import { shamirCommand } from './commands/shamir';
import { vaultCommand } from './commands/vault';
import { walletCommand } from './commands/wallet';
import { Handler, takeFirst } from './utils/dispatch';
import { envUsage } from './utils/env';
import { exitCodeFor, messageFor } from './utils/errors';

export const usageText = `
Expand All @@ -29,7 +30,7 @@ Commands:
keychain Store named secrets locally, always encrypted
shamir Split and recombine a secret into authenticated shares
salt Two-layer encryption: data under a salt, salt under your passphrase
legacy Read and upgrade data from the old CryptoJS scheme
cosmology Read and upgrade data written by the cosmology CLI

Global options:
--json Machine-readable output where supported
Expand All @@ -40,7 +41,7 @@ Global options:
--help, -h Show this help; "dcrypt <command> --help" for a command

Nothing in dcrypt makes a network request, and passphrases are never accepted in argv.

${envUsage}
Exit codes:
1 usage 2 wrong passphrase 3 corrupt input 4 not found 5 not a recipient
`;
Expand All @@ -55,7 +56,9 @@ export const createCommandMap = (): Record<string, Handler> => ({
keychain: keychainCommand,
shamir: shamirCommand,
salt: saltCommand,
legacy: legacyCommand,
cosmology: cosmologyCommand,
// The cosmology CLI's own name for it; kept so existing scripts keep working.
legacy: cosmologyCommand,
});

/** Runs one command and maps thrown errors onto exit codes; used by the bin and by tests. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { armor, encrypt } from '@decryption/core';
import { decrypt as legacyDecrypt, decryptWithEncryptedSalt } from '@decryption/legacy';
import { decrypt as compatDecrypt, decryptWithEncryptedSalt } from '@decryption/cosmology-compat';
import { Inquirerer } from 'inquirerer';
import { ParsedArgs } from 'minimist';

import { runSubcommand, takeFirst } from '../utils/dispatch';
import { fromEnv } from '../utils/env';
import { CliError } from '../utils/errors';
import { readInput, writeOutput } from '../utils/io';
import { resolvePassphrase } from '../utils/passphrase';
import { resolveKdf } from './encrypt';

export const legacyUsage = `
Legacy Command:
export const cosmologyUsage = `
Cosmology Command:

dcrypt legacy <subcommand> [OPTIONS]
dcrypt cosmology <subcommand> [OPTIONS]

Read data written by the old encryption demo and @cosmology/core (CryptoJS AES).
That format is unauthenticated and derives its key with a single round of MD5 —
use "upgrade" to move it onto the modern format as soon as you can.
Read data written by the cosmology CLI (CryptoJS AES). That format is
unauthenticated and derives its key with a single round of MD5 — use "upgrade"
to move it onto the modern format as soon as you can.

"dcrypt legacy" is an alias, for scripts that already use that name.

Subcommands:
decrypt Decrypt an old blob and print the plaintext
Expand All @@ -31,9 +34,13 @@ Options:
--passphrase-stdin Read the new passphrase from stdin
--help, -h Show this help message

Environment:
SALT, ENCRYPTED_SALT The cosmology CLI's own variables, still honoured

Examples:
dcrypt legacy decrypt --in old.txt --salt-file salt.txt
dcrypt legacy upgrade --in old.txt --salt-file salt.txt --out new.dcrypt
dcrypt cosmology decrypt --in old.txt --salt-file salt.txt
SALT=... dcrypt cosmology decrypt --in old.txt
dcrypt cosmology upgrade --in old.txt --salt-file salt.txt --out new.dcrypt
`;

const readSalt = async (argv: ParsedArgs, prompter: Inquirerer): Promise<string> => {
Expand All @@ -43,9 +50,11 @@ const readSalt = async (argv: ParsedArgs, prompter: Inquirerer): Promise<string>
}
if (argv.salt !== undefined) {
throw new CliError(
'refusing to read the old salt from argv (it is visible in `ps`); use --salt-file <path>'
'refusing to read the old salt from argv (it is visible in `ps`); use --salt-file <path> or SALT'
);
}
const envSalt = fromEnv('salt');
if (envSalt !== undefined) return envSalt.trim();
const { salt } = await prompter.prompt<{ salt: string }>({} as { salt: string }, [
{ type: 'password', name: 'salt', message: 'Legacy salt', required: true },
]);
Expand All @@ -56,10 +65,11 @@ const readLegacyPlaintext = async (argv: ParsedArgs, prompter: Inquirerer): Prom
const { first, newArgv } = takeFirst(argv);
const ciphertext = readInput(newArgv, first).trim();
const salt = await readSalt(newArgv, prompter);
const encryptedSalt = newArgv['encrypted-salt'] ?? newArgv.encryptedSalt;
const encryptedSalt =
newArgv['encrypted-salt'] ?? newArgv.encryptedSalt ?? fromEnv('encryptedSalt');
return typeof encryptedSalt === 'string' && encryptedSalt.length
? decryptWithEncryptedSalt(salt, encryptedSalt, ciphertext)
: legacyDecrypt(salt, ciphertext);
: compatDecrypt(salt, ciphertext);
};

const decryptCmd = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> => {
Expand All @@ -75,10 +85,10 @@ const upgrade = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> =>
writeOutput(argv, armor(encrypt(plaintext, passphrase, { kdf: resolveKdf(argv.kdf) })));
};

export const legacyCommand = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> => {
export const cosmologyCommand = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> => {
await runSubcommand(argv, prompter, {
name: 'legacy',
usage: legacyUsage,
name: 'cosmology',
usage: cosmologyUsage,
handlers: { decrypt: decryptCmd, upgrade },
});
};
4 changes: 4 additions & 0 deletions packages/cli/src/commands/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Options:
--passphrase-stdin Read the passphrase from stdin
--help, -h Show this help message

Environment:
KEYCHAIN_ACCOUNT Keychain namespace (default: dcrypt)
DCRYPT_PASSPHRASE The passphrase, for unattended runs

Examples:
dcrypt keychain set github-token --in token.txt
dcrypt keychain get github-token --out token.txt
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { armor, encrypt } from '@decryption/core';
import {
assertValidMnemonic,
createMnemonic,
DEFAULT_NETWORK,
deriveAccount,
isValidMnemonic,
NETWORKS,
Expand All @@ -12,6 +13,7 @@ import { Inquirerer } from 'inquirerer';
import { ParsedArgs } from 'minimist';

import { runSubcommand, takeFirst, wantsHelp } from '../utils/dispatch';
import { fromEnv } from '../utils/env';
import { CliError } from '../utils/errors';
import { emit, readInput, writeOutput } from '../utils/io';
import { resolvePassphrase } from '../utils/passphrase';
Expand All @@ -32,7 +34,7 @@ Subcommands:

Options:
--words <n> 12, 15, 18, 21 or 24 (default: 24)
--network <id> Network id, repeatable (default: cosmoshub)
--network <id> Network id, repeatable (default: bitcoin)
--account <n> BIP44 account index (default: 0)
--index <n> BIP44 address index (default: 0)
--path <path> Explicit derivation path, overrides --account/--index
Expand All @@ -43,18 +45,22 @@ Options:
--json Machine-readable output
--help, -h Show this help message

Environment:
MNEMONIC The mnemonic, for "address" and "validate"

Networks:
${Object.keys(NETWORKS).join(', ')}

Examples:
dcrypt wallet create --words 12 --network osmosis --network ethereum
dcrypt wallet create --words 12 --network bitcoin --network ethereum
dcrypt wallet create --encrypt --out wallet.dcrypt
dcrypt wallet address --in mnemonic.txt --network cosmoshub --index 3
dcrypt wallet address --in mnemonic.txt --network bitcoin --index 3
MNEMONIC="..." dcrypt wallet address --network ethereum
echo "$MNEMONIC" | dcrypt wallet validate --in -
`;

const networksOf = (argv: ParsedArgs): string[] => {
const value = argv.network ?? argv.networks ?? 'cosmoshub';
const value = argv.network ?? argv.networks ?? DEFAULT_NETWORK;
return (Array.isArray(value) ? value : [value]).map(String);
};

Expand Down Expand Up @@ -124,7 +130,10 @@ const validate = async (argv: ParsedArgs, prompter: Inquirerer): Promise<void> =

const readMnemonic = async (argv: ParsedArgs, prompter: Inquirerer): Promise<string> => {
const { first, newArgv } = takeFirst(argv);
if (first || newArgv.in || !process.stdin.isTTY) return readInput(newArgv, first).trim();
if (first || newArgv.in) return readInput(newArgv, first).trim();
const envMnemonic = fromEnv('mnemonic');
if (envMnemonic !== undefined) return envMnemonic.trim();
if (!process.stdin.isTTY) return readInput(newArgv, first).trim();
const { mnemonic } = await prompter.prompt<{ mnemonic: string }>({} as { mnemonic: string }, [
{ type: 'password', name: 'mnemonic', message: 'Mnemonic', required: true },
]);
Expand Down
Loading
Loading