Skip to content

Commit 3b1b649

Browse files
authored
Merge pull request #86 from imbue-ai/hynek/re-encrypt
Version 2.16.0
2 parents 2b99ed1 + 64e7f4b commit 3b1b649

12 files changed

Lines changed: 308 additions & 18 deletions

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,14 @@ latchkey auth clear
275275
```
276276

277277

278+
### Re-encrypting credentials
279+
280+
If you want to export your stored credentials encrypted with
281+
a different key or containing only some of the credentials (for
282+
example to move them to another machine) , use the `auth re-encrypt`
283+
subcommand.
284+
285+
278286
### Permissions
279287

280288
Optionally, you can specify rules for approving / rejecting
@@ -381,7 +389,7 @@ defaults:
381389
- `LATCHKEY_PERMISSIONS_CONFIG`: override the `permissions.json` location.
382390
- `LATCHKEY_PERMISSIONS_DO_NOT_USE_BUILTIN_SCHEMAS`: do not use the built-in permission definitions.
383391
- `LATCHKEY_PASSTHROUGH_UNKNOWN`: if set, Latchkey will forward requests (via `latchkey curl` or gateway) even if no credentials are injected.
384-
- `LATCHKEY_GATEWAY`: when set to a base URL (e.g. `http://localhost:1989`), the CLI delegates commands to a remote Latchkey gateway instead of running them locally. Commands that change local state (`auth set`, `auth clear`, `services register`, `ensure-browser`, `gateway`) cannot run in this mode.
392+
- `LATCHKEY_GATEWAY`: when set to a base URL (e.g. `http://localhost:1989`), the CLI delegates commands to a remote Latchkey gateway instead of running them locally. Commands that change local state (`auth set`, `auth clear`, `auth re-encrypt`, `services register`, `ensure-browser`, `gateway`) cannot run in this mode.
385393
- `LATCHKEY_GATEWAY_LISTEN_HOST`, `LATCHKEY_GATEWAY_LISTEN_PORT`: default address and port the local `latchkey gateway` command binds to when `--host` / `--port` are not supplied (defaults: `localhost`, `1989`). Distinct from `LATCHKEY_GATEWAY`, which configures a *remote* gateway URL.
386394
- `LATCHKEY_GATEWAY_PASSWORD`: optional shared secret used by the client side. When set together with `LATCHKEY_GATEWAY`, the CLI sends the value in the `X-Latchkey-Gateway-Password` header on every outgoing gateway request.
387395
- `LATCHKEY_GATEWAY_LISTEN_PASSWORD`: optional shared secret used by the server side. When set, `latchkey gateway` rejects (with `401`) any request that does not present the same value in the `X-Latchkey-Gateway-Password` header. The header is stripped before requests are forwarded upstream.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "latchkey",
3-
"version": "2.15.4",
3+
"version": "2.16.0",
44
"description": "A CLI tool that injects API credentials into curl requests to third-party services",
55
"author": "Imbue <hynek@imbue.com>",
66
"repository": {

src/apiCredentials/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,17 @@ export async function getCredentialStatus(
3535
service: Service,
3636
credentials: ApiCredentials | null,
3737
apiCredentialStore: ApiCredentialStore,
38-
disableRefresh = false
38+
disableRefresh = false,
39+
offline = false
3940
): Promise<ApiCredentialStatus> {
4041
if (credentials === null) {
4142
return ApiCredentialStatus.Missing;
4243
}
44+
// In offline mode we never send a validation request, so we can only report
45+
// that credentials exist without knowing whether they are actually valid.
46+
if (offline) {
47+
return ApiCredentialStatus.Unknown;
48+
}
4349
const refreshed = await maybeRefreshCredentials(
4450
service,
4551
credentials,

src/cliCommands.ts

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
*/
44

55
import type { Command } from 'commander';
6-
import { existsSync, unlinkSync } from 'node:fs';
6+
import { existsSync, statSync, unlinkSync } from 'node:fs';
7+
import { basename, join } from 'node:path';
78
import { createInterface } from 'node:readline';
8-
import { ApiCredentialStore } from './apiCredentials/store.js';
9+
import { ApiCredentialStore, ApiCredentialStoreError } from './apiCredentials/store.js';
910
import { ApiCredentials, RawCurlCredentials } from './apiCredentials/base.js';
1011
import {
1112
CredentialsExpiredError,
@@ -31,8 +32,8 @@ import {
3132
} from './playwrightUtils.js';
3233
import { BrowserFeaturesUnavailableError, loadPlaywright } from './playwrightLoader.js';
3334
import type { CurlResult } from './curl.js';
34-
import { EncryptedStorage } from './encryptedStorage.js';
35-
import { resolveEncryptionKey } from './encryption.js';
35+
import { EncryptedStorage, EncryptedStorageError } from './encryptedStorage.js';
36+
import { encrypt, EncryptionError, resolveEncryptionKey } from './encryption.js';
3637
import {
3738
DuplicateServiceNameError,
3839
InvalidServiceNameError,
@@ -103,6 +104,7 @@ export interface CliDependencies {
103104
doNotUseBuiltinSchemas: boolean
104105
) => Promise<boolean>;
105106
readonly confirm: (message: string) => Promise<boolean>;
107+
readonly readStdin: () => Promise<string>;
106108
readonly exit: (code: number) => never;
107109
readonly log: (message: string) => void;
108110
readonly errorLog: (message: string) => void;
@@ -120,6 +122,7 @@ export function createDefaultDependencies(): CliDependencies {
120122
runCurlAsync: curlRunAsync,
121123
checkPermission: checkPermission,
122124
confirm: defaultConfirm,
125+
readStdin: defaultReadStdin,
123126
exit: (code: number) => process.exit(code),
124127
log: (message: string) => {
125128
console.log(message);
@@ -168,6 +171,14 @@ function refuseInGatewayMode(deps: CliDependencies, commandName: string): void {
168171
}
169172
}
170173

174+
async function defaultReadStdin(): Promise<string> {
175+
const chunks: Buffer[] = [];
176+
for await (const chunk of process.stdin) {
177+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
178+
}
179+
return Buffer.concat(chunks).toString('utf-8');
180+
}
181+
171182
async function defaultConfirm(message: string): Promise<boolean> {
172183
const readline = createInterface({
173184
input: process.stdin,
@@ -298,11 +309,15 @@ export function registerCommands(program: Command, deps: CliDependencies): void
298309
.command('info')
299310
.description('Show information about a service.')
300311
.argument('<service_name>', 'Name of the service to get info for')
301-
.action(async (serviceName: string) => {
312+
.option(
313+
'--offline',
314+
'Do not send a request to validate credentials; report them as only "missing" or "unknown".'
315+
)
316+
.action(async (serviceName: string, options: { offline?: boolean }) => {
302317
if (deps.config.gatewayUrl !== null) {
303318
const info = await forwardToGateway(deps, {
304319
command: 'services info',
305-
params: { serviceName },
320+
params: { serviceName, offline: options.offline },
306321
});
307322
deps.log(JSON.stringify(info, null, 2));
308323
return;
@@ -317,7 +332,8 @@ export function registerCommands(program: Command, deps: CliDependencies): void
317332
deps.registry,
318333
apiCredentialStore,
319334
deps.config,
320-
serviceName
335+
serviceName,
336+
options.offline ?? false
321337
);
322338
deps.log(JSON.stringify(info, null, 2));
323339
} catch (error) {
@@ -929,6 +945,103 @@ export function registerCommands(program: Command, deps: CliDependencies): void
929945
}
930946
);
931947

948+
authCommand
949+
.command('re-encrypt')
950+
.description(
951+
'Re-encrypt stored credentials with a new key and write them into a ' +
952+
'destination directory, using the same filename Latchkey itself expects. ' +
953+
'The new key is read from stdin so that it does not appear in the process ' +
954+
'arguments or shell history. When stdin is empty, the existing encryption ' +
955+
'key is reused.'
956+
)
957+
.argument(
958+
'<destination_directory>',
959+
'Directory to write the re-encrypted credential store into'
960+
)
961+
.option(
962+
'--services <services...>',
963+
'Only include these services in the new encrypted store (default: all stored services)'
964+
)
965+
.addHelpText(
966+
'after',
967+
`\nExamples:\n $ openssl rand -base64 32 | latchkey auth re-encrypt ~/latchkey-export` +
968+
`\n $ echo "" | latchkey auth re-encrypt ~/latchkey-export --services gitlab slack`
969+
)
970+
.action(async (destinationDirectory: string, options: { services?: string[] }) => {
971+
refuseInGatewayMode(deps, 'auth re-encrypt');
972+
973+
if (existsSync(destinationDirectory) && !statSync(destinationDirectory).isDirectory()) {
974+
deps.errorLog(`Error: Destination is not a directory: ${destinationDirectory}`);
975+
deps.exit(1);
976+
}
977+
978+
const destination = join(destinationDirectory, basename(deps.config.credentialStorePath));
979+
if (existsSync(destination)) {
980+
deps.errorLog(`Error: Destination file already exists: ${destination}`);
981+
deps.errorLog('Remove it first or choose a different destination directory.');
982+
deps.exit(1);
983+
}
984+
985+
const sourceKey = await resolveEncryptionKeyFromConfig(deps.config);
986+
987+
// An empty stdin means "reuse the existing encryption key".
988+
const stdinKey = (await deps.readStdin()).trim();
989+
const destinationKey = stdinKey === '' ? sourceKey : stdinKey;
990+
// Validate the key up front using the encryption routine itself, rather
991+
// than duplicating its key-format checks.
992+
try {
993+
encrypt('', destinationKey);
994+
} catch (error) {
995+
if (error instanceof EncryptionError) {
996+
deps.errorLog(`Error: ${error.message}. Generate a key with: openssl rand -base64 32`);
997+
deps.exit(1);
998+
}
999+
throw error;
1000+
}
1001+
1002+
const sourceStorage = new EncryptedStorage(sourceKey);
1003+
const sourceStore = new ApiCredentialStore(deps.config.credentialStorePath, sourceStorage);
1004+
1005+
let allCredentials: ReadonlyMap<string, ApiCredentials>;
1006+
try {
1007+
allCredentials = sourceStore.getAll();
1008+
} catch (error) {
1009+
if (error instanceof ApiCredentialStoreError || error instanceof EncryptedStorageError) {
1010+
deps.errorLog(`Error: ${error.message}`);
1011+
deps.exit(1);
1012+
}
1013+
throw error;
1014+
}
1015+
1016+
let selectedServiceNames: string[];
1017+
if (options.services !== undefined) {
1018+
const missing = options.services.filter((name) => !allCredentials.has(name));
1019+
if (missing.length > 0) {
1020+
deps.errorLog(`Error: No stored credentials for: ${missing.join(', ')}`);
1021+
deps.exit(1);
1022+
}
1023+
selectedServiceNames = options.services;
1024+
} else {
1025+
selectedServiceNames = [...allCredentials.keys()];
1026+
}
1027+
1028+
if (selectedServiceNames.length === 0) {
1029+
deps.errorLog('Error: No stored credentials found to re-encrypt.');
1030+
deps.exit(1);
1031+
}
1032+
1033+
const destinationStorage = new EncryptedStorage(destinationKey);
1034+
const destinationStore = new ApiCredentialStore(destination, destinationStorage);
1035+
for (const serviceName of selectedServiceNames) {
1036+
const credentials = allCredentials.get(serviceName);
1037+
if (credentials !== undefined) {
1038+
destinationStore.save(serviceName, credentials);
1039+
}
1040+
}
1041+
1042+
deps.log(`Re-encrypted ${String(selectedServiceNames.length)} service(s) to ${destination}.`);
1043+
});
1044+
9321045
program
9331046
.command('skill-md')
9341047
.description('Print the SKILL.md file for AI agent integration.')

src/gateway/latchkeyEndpoint.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ const ServicesListRequestSchema = z.object({
4848

4949
const ServicesInfoRequestSchema = z.object({
5050
command: z.literal('services info'),
51-
params: serviceNameParams,
51+
params: serviceNameParams.extend({
52+
offline: z.boolean().optional(),
53+
}),
5254
});
5355

5456
const AuthListRequestSchema = z.object({
@@ -131,7 +133,8 @@ async function dispatch(
131133
deps.registry,
132134
apiCredentialStore,
133135
deps.config,
134-
parsed.params.serviceName
136+
parsed.params.serviceName,
137+
parsed.params.offline ?? false
135138
);
136139

137140
case 'auth list':

src/sharedOperations.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export async function servicesInfo(
123123
registry: ServiceRegistry,
124124
apiCredentialStore: ApiCredentialStore,
125125
config: Config,
126-
serviceName: string
126+
serviceName: string,
127+
offline = false
127128
): Promise<ServicesInfoResult> {
128129
const service = lookupService(registry, serviceName);
129130

@@ -135,7 +136,8 @@ export async function servicesInfo(
135136
service,
136137
apiCredentials,
137138
apiCredentialStore,
138-
config.credentialsRefreshDisabled
139+
config.credentialsRefreshDisabled,
140+
offline
139141
);
140142

141143
const serviceType = service instanceof RegisteredService ? 'user-registered' : 'built-in';

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// Auto-generated from package.json by scripts/generateVersion.js.
22
// Do not edit by hand; run `node scripts/generateVersion.js` to refresh.
3-
export const VERSION = "2.15.4";
3+
export const VERSION = "2.16.0";

0 commit comments

Comments
 (0)