Skip to content

Commit f57bcf0

Browse files
authored
Merge pull request #21 from constructive-io/feat/constructive-accounts
feat(accounts): manage Constructive accounts and API keys from the vault
2 parents 307872d + 5612497 commit f57bcf0

32 files changed

Lines changed: 4638 additions & 49 deletions

File tree

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
</p>
1515

1616
Audited, dependency-light building blocks for mnemonics, envelope encryption, Shamir secret sharing and
17-
team secret management — plus the `dcrypt` CLI. Everything runs locally; nothing in this repo makes a
18-
network request.
17+
team secret management — plus the `dcrypt` CLI. Everything runs locally; the only code here that opens a
18+
socket is `dcrypt account`, which talks to the Constructive endpoint you name.
1919

2020
## Packages
2121

@@ -27,6 +27,7 @@ network request.
2727
| **@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 |
2828
| **@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 |
2929
| **@decryption/secrets** | [![npm](https://img.shields.io/npm/v/@decryption/secrets.svg)](https://www.npmjs.com/package/@decryption/secrets) | [GitHub](./packages/secrets) | Team secrets file format, rekeying and `.env` export |
30+
| **@decryption/accounts** | [![npm](https://img.shields.io/npm/v/@decryption/accounts.svg)](https://www.npmjs.com/package/@decryption/accounts) | [GitHub](./packages/accounts) | Constructive accounts and API keys, held in the local vault |
3031
| **@decryption/cli** | [![npm](https://img.shields.io/npm/v/@decryption/cli.svg)](https://www.npmjs.com/package/@decryption/cli) | [GitHub](./packages/cli) | The `dcrypt` command-line interface |
3132

3233
### Vendored primitives
@@ -53,6 +54,9 @@ dcrypt encrypt --in secret.txt # Argon2id + XChaCha20-Poly1305 envelope
5354
dcrypt shamir split --shares 5 --threshold 3
5455
dcrypt secrets init && dcrypt secrets set DATABASE_URL
5556
dcrypt secrets run -- pnpm dev # inject secrets without writing a .env
57+
58+
dcrypt account signin me@example.com --endpoint https://auth.example.com/graphql
59+
dcrypt account key create ci --expires-days 30 # secret lands in the vault, not a .env
5660
```
5761

5862
## Security model
@@ -62,4 +66,5 @@ dcrypt secrets run -- pnpm dev # inject secrets without writing a .env
6266
returning an empty string.
6367
- Team secrets use X25519 per-recipient wrapping (age/sops-shaped), so adding or removing a teammate never
6468
requires resharing a passphrase. Shamir is reserved for break-glass recovery.
65-
- Nothing here opens a socket. Private keys are never written to disk unencrypted.
69+
- Private keys are never written to disk unencrypted. `dcrypt account` is the one command that uses the
70+
network: session tokens and API-key secrets it receives go straight into the encrypted vault.

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"icons": "node scripts/generate-icons.mjs"
2222
},
2323
"dependencies": {
24+
"@decryption/accounts": "workspace:*",
2425
"@decryption/core": "workspace:*",
2526
"@decryption/cosmology-compat": "workspace:*",
2627
"@decryption/hashes": "workspace:*",

apps/desktop/src/main/ipc.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AccountManager } from '@decryption/accounts';
12
import { decryptFromString, encryptToString } from '@decryption/core';
23
import { decrypt as legacyDecrypt } from '@decryption/cosmology-compat';
34
import { combineToString, splitToStrings } from '@decryption/shamir';
@@ -6,7 +7,13 @@ import { BrowserWindow, ipcMain, shell } from 'electron';
67
import { existsSync } from 'fs';
78
import * as path from 'path';
89

9-
import { CHANNELS, FieldPurpose, ItemKind } from '../shared/api';
10+
import {
11+
CHANNELS,
12+
CreateKeyRequest,
13+
FieldPurpose,
14+
ItemKind,
15+
SignInRequest,
16+
} from '../shared/api';
1017
import { parseOtpauthUri } from '../shared/otpauth';
1118
import { backupVault, restoreVault } from './backup';
1219
import { lookupBrandIcons } from './brand-icons';
@@ -156,6 +163,58 @@ export const registerIpc = (service: VaultService): void => {
156163
// ─── brand icons (bundled, offline) ───
157164
handle(CHANNELS.iconsLookup, (names: string[]) => lookupBrandIcons(assertStringArray(names)));
158165

166+
// ─── constructive accounts ───
167+
const accounts = (): AccountManager => new AccountManager(service.current());
168+
const credentials = (request: SignInRequest): SignInRequest => ({
169+
endpoint: assertString(request?.endpoint),
170+
email: assertString(request?.email),
171+
password: assertString(request?.password),
172+
});
173+
174+
handle(CHANNELS.accountsList, () => accounts().listAccounts());
175+
handle(CHANNELS.accountsSignIn, async (request: SignInRequest) => {
176+
const account = await accounts().signIn(credentials(request));
177+
service.scheduleSave();
178+
return account;
179+
});
180+
handle(CHANNELS.accountsSignUp, async (request: SignInRequest) => {
181+
const account = await accounts().signUp(credentials(request));
182+
service.scheduleSave();
183+
return account;
184+
});
185+
handle(CHANNELS.accountsSignOut, async (itemId: string) => {
186+
await accounts().signOut(assertString(itemId));
187+
service.scheduleSave();
188+
});
189+
handle(CHANNELS.accountsForget, async (itemId: string) => {
190+
await accounts().forget(assertString(itemId));
191+
service.scheduleSave();
192+
});
193+
handle(CHANNELS.accountsKeys, (accountItemId?: string) =>
194+
accounts().listApiKeys(accountItemId === undefined ? undefined : assertString(accountItemId))
195+
);
196+
handle(
197+
CHANNELS.accountsCreateKey,
198+
async (accountItemId: string, request: CreateKeyRequest) => {
199+
const days = request?.expiresDays;
200+
const key = await accounts().createApiKey(assertString(accountItemId), {
201+
name: assertString(request?.name),
202+
expiresIn: days === undefined ? undefined : { days: assertInt(days, 1, 3650) },
203+
accessLevel:
204+
request?.accessLevel === undefined ? undefined : assertString(request.accessLevel),
205+
});
206+
service.scheduleSave();
207+
return key;
208+
}
209+
);
210+
handle(CHANNELS.accountsRevealKey, (itemId: string) =>
211+
accounts().revealApiKey(assertString(itemId))
212+
);
213+
handle(CHANNELS.accountsRevokeKey, async (itemId: string) => {
214+
await accounts().revokeApiKey(assertString(itemId));
215+
service.scheduleSave();
216+
});
217+
159218
// ─── audit ───
160219
handle(CHANNELS.auditLog, (itemId?: string) => service.current().auditLog(itemId));
161220

apps/desktop/src/main/vault-service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import type { RebuildReport, TotpEntry, VaultStatus } from '../shared/api';
77

88
const APP_NAME = 'dcrypt';
99

10+
const appDirs = () => appstash(APP_NAME, { ensure: true });
11+
1012
/** Root of everything dcrypt keeps on this machine: vault, keychain, identity. */
11-
export const appDataPath = (): string => appstash(APP_NAME, { ensure: true });
13+
export const appDataPath = (): string => appDirs().root;
1214

1315
export const vaultFilePath = (): string =>
14-
resolve(appDataPath(), 'data', 'db') + path.sep + 'vault.dcrypt';
16+
resolve(appDirs(), 'data', 'db') + path.sep + 'vault.dcrypt';
1517

1618
/** Locate the dcrypt-vault pgpm module in dev (workspace) and packaged builds. */
1719
export const vaultModulePath = (): string => {

apps/desktop/src/preload/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ const api: DcryptApi & {
5252
urls: (itemId) => invoke(CHANNELS.urlsList, itemId),
5353
addUrl: (itemId, url) => invoke(CHANNELS.urlsAdd, itemId, url),
5454
},
55+
accounts: {
56+
list: () => invoke(CHANNELS.accountsList),
57+
signIn: (request) => invoke(CHANNELS.accountsSignIn, request),
58+
signUp: (request) => invoke(CHANNELS.accountsSignUp, request),
59+
signOut: (itemId) => invoke(CHANNELS.accountsSignOut, itemId),
60+
forget: (itemId) => invoke(CHANNELS.accountsForget, itemId),
61+
keys: (accountItemId) => invoke(CHANNELS.accountsKeys, accountItemId),
62+
createKey: (accountItemId, request) =>
63+
invoke(CHANNELS.accountsCreateKey, accountItemId, request),
64+
revealKey: (itemId) => invoke(CHANNELS.accountsRevealKey, itemId),
65+
revokeKey: (itemId) => invoke(CHANNELS.accountsRevokeKey, itemId),
66+
},
5567
audit: {
5668
log: (itemId) => invoke(CHANNELS.auditLog, itemId),
5769
},

apps/desktop/src/renderer/src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,30 @@ import {
77
Settings,
88
ShieldCheck,
99
Timer,
10+
UserRound,
1011
Wrench,
1112
} from 'lucide-react';
1213
import { useCallback, useEffect, useState } from 'react';
1314

1415
import { DoorState, VaultDoors } from './components/VaultDoors';
1516
import { dcrypt } from './lib/ipc';
1617
import { ThemeProvider, useThemeMode } from './lib/theme-context';
18+
import { AccountsScreen } from './screens/AccountsScreen';
1719
import { SettingsScreen } from './screens/SettingsScreen';
1820
import { ToolsScreen } from './screens/ToolsScreen';
1921
import { TotpScreen } from './screens/TotpScreen';
2022
import { UnlockScreen } from './screens/UnlockScreen';
2123
import { VaultScreen } from './screens/VaultScreen';
2224

23-
type Tab = 'vault' | 'codes' | 'tools' | 'settings';
25+
type Tab = 'vault' | 'codes' | 'accounts' | 'tools' | 'settings';
2426

2527
/** `opening`/`closing` are the door transitions; the vault is mounted for all but `locked`. */
2628
type Phase = 'locked' | 'opening' | 'unlocked' | 'closing';
2729

2830
const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [
2931
{ id: 'vault', label: 'Vault', icon: KeyRound },
3032
{ id: 'codes', label: 'Codes', icon: Timer },
33+
{ id: 'accounts', label: 'Accounts', icon: UserRound },
3134
{ id: 'tools', label: 'Tools', icon: Wrench },
3235
{ id: 'settings', label: 'Settings', icon: Settings },
3336
];
@@ -110,6 +113,7 @@ const AppContent = () => {
110113
<main className="min-w-0 flex-1 overflow-hidden">
111114
{tab === 'vault' && <VaultScreen />}
112115
{tab === 'codes' && <TotpScreen />}
116+
{tab === 'accounts' && <AccountsScreen />}
113117
{tab === 'tools' && <ToolsScreen />}
114118
{tab === 'settings' && (
115119
<SettingsScreen onLocked={() => setPhase('closing')} />

0 commit comments

Comments
 (0)