latchkey 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.
Files changed (176) hide show
  1. package/.nvmrc +1 -0
  2. package/.pre-commit-config.yaml +22 -0
  3. package/.prettierignore +4 -0
  4. package/.prettierrc +7 -0
  5. package/CLAUDE.md +13 -0
  6. package/LICENSE +7 -0
  7. package/README.md +167 -0
  8. package/dist/scripts/cryptFile.d.ts +21 -0
  9. package/dist/scripts/cryptFile.d.ts.map +1 -0
  10. package/dist/scripts/cryptFile.js +106 -0
  11. package/dist/scripts/cryptFile.js.map +1 -0
  12. package/dist/scripts/encryptFile.d.ts +21 -0
  13. package/dist/scripts/encryptFile.d.ts.map +1 -0
  14. package/dist/scripts/encryptFile.js +101 -0
  15. package/dist/scripts/encryptFile.js.map +1 -0
  16. package/dist/scripts/recordBrowserSession.d.ts +18 -0
  17. package/dist/scripts/recordBrowserSession.d.ts.map +1 -0
  18. package/dist/scripts/recordBrowserSession.js +213 -0
  19. package/dist/scripts/recordBrowserSession.js.map +1 -0
  20. package/dist/src/apiCredentialStore.d.ts +19 -0
  21. package/dist/src/apiCredentialStore.d.ts.map +1 -0
  22. package/dist/src/apiCredentialStore.js +65 -0
  23. package/dist/src/apiCredentialStore.js.map +1 -0
  24. package/dist/src/apiCredentials.d.ts +134 -0
  25. package/dist/src/apiCredentials.d.ts.map +1 -0
  26. package/dist/src/apiCredentials.js +139 -0
  27. package/dist/src/apiCredentials.js.map +1 -0
  28. package/dist/src/browserConfig.d.ts +90 -0
  29. package/dist/src/browserConfig.d.ts.map +1 -0
  30. package/dist/src/browserConfig.js +259 -0
  31. package/dist/src/browserConfig.js.map +1 -0
  32. package/dist/src/browserState.d.ts +8 -0
  33. package/dist/src/browserState.d.ts.map +1 -0
  34. package/dist/src/browserState.js +21 -0
  35. package/dist/src/browserState.js.map +1 -0
  36. package/dist/src/cli.d.ts +6 -0
  37. package/dist/src/cli.d.ts.map +1 -0
  38. package/dist/src/cli.js +25 -0
  39. package/dist/src/cli.js.map +1 -0
  40. package/dist/src/cliCommands.d.ts +29 -0
  41. package/dist/src/cliCommands.d.ts.map +1 -0
  42. package/dist/src/cliCommands.js +264 -0
  43. package/dist/src/cliCommands.js.map +1 -0
  44. package/dist/src/config.d.ts +35 -0
  45. package/dist/src/config.d.ts.map +1 -0
  46. package/dist/src/config.js +96 -0
  47. package/dist/src/config.js.map +1 -0
  48. package/dist/src/curl.d.ts +29 -0
  49. package/dist/src/curl.d.ts.map +1 -0
  50. package/dist/src/curl.js +53 -0
  51. package/dist/src/curl.js.map +1 -0
  52. package/dist/src/encryptedStorage.d.ts +39 -0
  53. package/dist/src/encryptedStorage.d.ts.map +1 -0
  54. package/dist/src/encryptedStorage.js +128 -0
  55. package/dist/src/encryptedStorage.js.map +1 -0
  56. package/dist/src/encryption.d.ts +28 -0
  57. package/dist/src/encryption.d.ts.map +1 -0
  58. package/dist/src/encryption.js +86 -0
  59. package/dist/src/encryption.js.map +1 -0
  60. package/dist/src/index.d.ts +14 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +17 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/keychain.d.ts +33 -0
  65. package/dist/src/keychain.d.ts.map +1 -0
  66. package/dist/src/keychain.js +94 -0
  67. package/dist/src/keychain.js.map +1 -0
  68. package/dist/src/playwrightUtils.d.ts +27 -0
  69. package/dist/src/playwrightUtils.d.ts.map +1 -0
  70. package/dist/src/playwrightUtils.js +122 -0
  71. package/dist/src/playwrightUtils.js.map +1 -0
  72. package/dist/src/registry.d.ts +12 -0
  73. package/dist/src/registry.d.ts.map +1 -0
  74. package/dist/src/registry.js +30 -0
  75. package/dist/src/registry.js.map +1 -0
  76. package/dist/src/services/base.d.ts +98 -0
  77. package/dist/src/services/base.d.ts.map +1 -0
  78. package/dist/src/services/base.js +137 -0
  79. package/dist/src/services/base.js.map +1 -0
  80. package/dist/src/services/discord.d.ts +20 -0
  81. package/dist/src/services/discord.d.ts.map +1 -0
  82. package/dist/src/services/discord.js +55 -0
  83. package/dist/src/services/discord.js.map +1 -0
  84. package/dist/src/services/dropbox.d.ts +23 -0
  85. package/dist/src/services/dropbox.d.ts.map +1 -0
  86. package/dist/src/services/dropbox.js +136 -0
  87. package/dist/src/services/dropbox.js.map +1 -0
  88. package/dist/src/services/github.d.ts +23 -0
  89. package/dist/src/services/github.d.ts.map +1 -0
  90. package/dist/src/services/github.js +110 -0
  91. package/dist/src/services/github.js.map +1 -0
  92. package/dist/src/services/index.d.ts +12 -0
  93. package/dist/src/services/index.d.ts.map +1 -0
  94. package/dist/src/services/index.js +11 -0
  95. package/dist/src/services/index.js.map +1 -0
  96. package/dist/src/services/linear.d.ts +23 -0
  97. package/dist/src/services/linear.d.ts.map +1 -0
  98. package/dist/src/services/linear.js +110 -0
  99. package/dist/src/services/linear.js.map +1 -0
  100. package/dist/src/services/slack.d.ts +21 -0
  101. package/dist/src/services/slack.d.ts.map +1 -0
  102. package/dist/src/services/slack.js +67 -0
  103. package/dist/src/services/slack.js.map +1 -0
  104. package/dist/tests/apiCredentialStore.test.d.ts +2 -0
  105. package/dist/tests/apiCredentialStore.test.d.ts.map +1 -0
  106. package/dist/tests/apiCredentialStore.test.js +130 -0
  107. package/dist/tests/apiCredentialStore.test.js.map +1 -0
  108. package/dist/tests/apiCredentials.test.d.ts +2 -0
  109. package/dist/tests/apiCredentials.test.d.ts.map +1 -0
  110. package/dist/tests/apiCredentials.test.js +169 -0
  111. package/dist/tests/apiCredentials.test.js.map +1 -0
  112. package/dist/tests/cli.test.d.ts +2 -0
  113. package/dist/tests/cli.test.d.ts.map +1 -0
  114. package/dist/tests/cli.test.js +584 -0
  115. package/dist/tests/cli.test.js.map +1 -0
  116. package/dist/tests/encryptedStorage.test.d.ts +2 -0
  117. package/dist/tests/encryptedStorage.test.d.ts.map +1 -0
  118. package/dist/tests/encryptedStorage.test.js +126 -0
  119. package/dist/tests/encryptedStorage.test.js.map +1 -0
  120. package/dist/tests/encryption.test.d.ts +2 -0
  121. package/dist/tests/encryption.test.d.ts.map +1 -0
  122. package/dist/tests/encryption.test.js +121 -0
  123. package/dist/tests/encryption.test.js.map +1 -0
  124. package/dist/tests/lint.test.d.ts +2 -0
  125. package/dist/tests/lint.test.d.ts.map +1 -0
  126. package/dist/tests/lint.test.js +18 -0
  127. package/dist/tests/lint.test.js.map +1 -0
  128. package/dist/tests/registry.test.d.ts +2 -0
  129. package/dist/tests/registry.test.d.ts.map +1 -0
  130. package/dist/tests/registry.test.js +85 -0
  131. package/dist/tests/registry.test.js.map +1 -0
  132. package/dist/tests/servicesAgainstRecordings.test.d.ts +20 -0
  133. package/dist/tests/servicesAgainstRecordings.test.d.ts.map +1 -0
  134. package/dist/tests/servicesAgainstRecordings.test.js +157 -0
  135. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -0
  136. package/dist/tests/typecheck.test.d.ts +2 -0
  137. package/dist/tests/typecheck.test.d.ts.map +1 -0
  138. package/dist/tests/typecheck.test.js +18 -0
  139. package/dist/tests/typecheck.test.js.map +1 -0
  140. package/docs/development.md +94 -0
  141. package/eslint.config.js +30 -0
  142. package/integrations/SKILL.md +62 -0
  143. package/package.json +68 -0
  144. package/scripts/cryptFile.ts +123 -0
  145. package/scripts/recordBrowserSession.ts +280 -0
  146. package/scripts/tsconfig.json +10 -0
  147. package/src/apiCredentialStore.ts +87 -0
  148. package/src/apiCredentials.ts +180 -0
  149. package/src/cli.ts +32 -0
  150. package/src/cliCommands.ts +321 -0
  151. package/src/config.ts +115 -0
  152. package/src/curl.ts +78 -0
  153. package/src/encryptedStorage.ts +161 -0
  154. package/src/encryption.ts +106 -0
  155. package/src/index.ts +65 -0
  156. package/src/keychain.ts +105 -0
  157. package/src/playwrightUtils.ts +143 -0
  158. package/src/registry.ts +35 -0
  159. package/src/services/base.ts +234 -0
  160. package/src/services/discord.ts +73 -0
  161. package/src/services/dropbox.ts +173 -0
  162. package/src/services/github.ts +139 -0
  163. package/src/services/index.ts +13 -0
  164. package/src/services/linear.ts +134 -0
  165. package/src/services/slack.ts +85 -0
  166. package/tests/apiCredentialStore.test.ts +162 -0
  167. package/tests/apiCredentials.test.ts +195 -0
  168. package/tests/cli.test.ts +798 -0
  169. package/tests/encryptedStorage.test.ts +173 -0
  170. package/tests/encryption.test.ts +169 -0
  171. package/tests/lint.test.ts +19 -0
  172. package/tests/registry.test.ts +103 -0
  173. package/tests/servicesAgainstRecordings.test.ts +230 -0
  174. package/tests/typecheck.test.ts +19 -0
  175. package/tsconfig.json +24 -0
  176. package/vitest.config.ts +13 -0
package/src/config.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Configuration management for Latchkey.
3
+ */
4
+
5
+ import { existsSync, statSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { isKeychainAvailable } from './keychain.js';
9
+
10
+ export class InsecureFilePermissionsError extends Error {
11
+ constructor(filePath: string, permissions: number) {
12
+ const permissionsOctal = permissions.toString(8).padStart(3, '0');
13
+ super(
14
+ `File ${filePath} has insecure permissions (${permissionsOctal}). Run: chmod 600 ${filePath}`
15
+ );
16
+ this.name = 'InsecureFilePermissionsError';
17
+ }
18
+ }
19
+
20
+ const LATCHKEY_STORE_ENV_VAR = 'LATCHKEY_STORE';
21
+ const LATCHKEY_BROWSER_STATE_ENV_VAR = 'LATCHKEY_BROWSER_STATE';
22
+ const LATCHKEY_CURL_PATH_ENV_VAR = 'LATCHKEY_CURL_PATH';
23
+ const LATCHKEY_ENCRYPTION_KEY_ENV_VAR = 'LATCHKEY_ENCRYPTION_KEY';
24
+ const LATCHKEY_KEYRING_SERVICE_NAME_ENV_VAR = 'LATCHKEY_KEYRING_SERVICE_NAME';
25
+ const LATCHKEY_KEYRING_ACCOUNT_NAME_ENV_VAR = 'LATCHKEY_KEYRING_ACCOUNT_NAME';
26
+
27
+ export const DEFAULT_KEYRING_SERVICE_NAME = 'latchkey';
28
+ export const DEFAULT_KEYRING_ACCOUNT_NAME = 'encryption-key';
29
+
30
+ function getDefaultCredentialStorePath(encryptionEnabled: boolean): string {
31
+ const filename = encryptionEnabled ? 'credentials.json.enc' : 'credentials.json';
32
+ return join(homedir(), '.latchkey', filename);
33
+ }
34
+
35
+ function getDefaultBrowserStatePath(encryptionEnabled: boolean): string {
36
+ const filename = encryptionEnabled ? 'browser_state.json.enc' : 'browser_state.json';
37
+ return join(homedir(), '.latchkey', filename);
38
+ }
39
+
40
+ function resolvePathWithTildeExpansion(path: string): string {
41
+ if (path.startsWith('~')) {
42
+ return resolve(homedir(), path.slice(2));
43
+ }
44
+ return resolve(path);
45
+ }
46
+
47
+ /**
48
+ * Configuration for Latchkey, sourced from environment variables with sensible defaults.
49
+ */
50
+ export class Config {
51
+ readonly credentialStorePath: string;
52
+ readonly browserStatePath: string;
53
+ readonly curlCommand: string;
54
+ /**
55
+ * Encryption key override from environment variable.
56
+ * If set, this key will be used instead of the system keychain.
57
+ * The key should be a base64-encoded 256-bit (32-byte) value.
58
+ */
59
+ readonly encryptionKeyOverride: string | null;
60
+ readonly serviceName: string;
61
+ readonly accountName: string;
62
+
63
+ constructor(getEnv: (name: string) => string | undefined = (name) => process.env[name]) {
64
+ this.curlCommand = getEnv(LATCHKEY_CURL_PATH_ENV_VAR) ?? 'curl';
65
+ this.encryptionKeyOverride = getEnv(LATCHKEY_ENCRYPTION_KEY_ENV_VAR) ?? null;
66
+ this.serviceName =
67
+ getEnv(LATCHKEY_KEYRING_SERVICE_NAME_ENV_VAR) ?? DEFAULT_KEYRING_SERVICE_NAME;
68
+ this.accountName =
69
+ getEnv(LATCHKEY_KEYRING_ACCOUNT_NAME_ENV_VAR) ?? DEFAULT_KEYRING_ACCOUNT_NAME;
70
+
71
+ // Determine if encryption will be enabled (either via key override or keychain)
72
+ const encryptionEnabled =
73
+ this.encryptionKeyOverride !== null ||
74
+ isKeychainAvailable(this.serviceName, this.accountName);
75
+
76
+ const credentialStoreEnv = getEnv(LATCHKEY_STORE_ENV_VAR);
77
+ this.credentialStorePath = credentialStoreEnv
78
+ ? resolvePathWithTildeExpansion(credentialStoreEnv)
79
+ : getDefaultCredentialStorePath(encryptionEnabled);
80
+
81
+ const browserStateEnv = getEnv(LATCHKEY_BROWSER_STATE_ENV_VAR);
82
+ this.browserStatePath = browserStateEnv
83
+ ? resolvePathWithTildeExpansion(browserStateEnv)
84
+ : getDefaultBrowserStatePath(encryptionEnabled);
85
+ }
86
+
87
+ /**
88
+ * Check that sensitive files have secure permissions.
89
+ * Throws InsecureFilePermissionsError if any file is readable by group or others.
90
+ */
91
+ checkSensitiveFilePermissions(): void {
92
+ const filesToCheck = [this.credentialStorePath, this.browserStatePath];
93
+
94
+ for (const filePath of filesToCheck) {
95
+ if (!existsSync(filePath)) {
96
+ continue;
97
+ }
98
+
99
+ const stats = statSync(filePath);
100
+ if (stats.isDirectory()) {
101
+ continue;
102
+ }
103
+
104
+ const permissions = stats.mode & 0o777;
105
+ if ((permissions & 0o077) !== 0) {
106
+ throw new InsecureFilePermissionsError(filePath, permissions);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Global configuration singleton, initialized from process.env at import time.
114
+ */
115
+ export const CONFIG = new Config();
package/src/curl.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Curl subprocess utilities.
3
+ */
4
+
5
+ import { spawnSync, SpawnSyncReturns } from 'node:child_process';
6
+ import { CONFIG } from './config.js';
7
+
8
+ export interface CurlResult {
9
+ readonly returncode: number;
10
+ readonly stdout: string;
11
+ readonly stderr: string;
12
+ }
13
+
14
+ /**
15
+ * Type for the subprocess runner function (no output capture, for interactive use).
16
+ */
17
+ export type SubprocessRunner = (args: readonly string[]) => CurlResult;
18
+
19
+ /**
20
+ * Type for the capturing subprocess runner function (captures output).
21
+ */
22
+ export type CapturingSubprocessRunner = (args: readonly string[], timeout: number) => CurlResult;
23
+
24
+ function defaultSubprocessRunner(args: readonly string[]): CurlResult {
25
+ const result: SpawnSyncReturns<Buffer> = spawnSync(CONFIG.curlCommand, args as string[], {
26
+ stdio: ['inherit', 'inherit', 'inherit'],
27
+ });
28
+ return {
29
+ returncode: result.status ?? 1,
30
+ stdout: '',
31
+ stderr: '',
32
+ };
33
+ }
34
+
35
+ function defaultCapturingSubprocessRunner(args: readonly string[], timeout: number): CurlResult {
36
+ const result: SpawnSyncReturns<string> = spawnSync(CONFIG.curlCommand, args as string[], {
37
+ encoding: 'utf-8',
38
+ timeout: timeout * 1000,
39
+ });
40
+ return {
41
+ returncode: result.status ?? 1,
42
+ stdout: result.stdout,
43
+ stderr: result.stderr,
44
+ };
45
+ }
46
+
47
+ let subprocessRunner: SubprocessRunner = defaultSubprocessRunner;
48
+ let capturingSubprocessRunner: CapturingSubprocessRunner = defaultCapturingSubprocessRunner;
49
+
50
+ export function setSubprocessRunner(runner: SubprocessRunner): void {
51
+ subprocessRunner = runner;
52
+ }
53
+
54
+ export function resetSubprocessRunner(): void {
55
+ subprocessRunner = defaultSubprocessRunner;
56
+ }
57
+
58
+ export function setCapturingSubprocessRunner(runner: CapturingSubprocessRunner): void {
59
+ capturingSubprocessRunner = runner;
60
+ }
61
+
62
+ export function resetCapturingSubprocessRunner(): void {
63
+ capturingSubprocessRunner = defaultCapturingSubprocessRunner;
64
+ }
65
+
66
+ /**
67
+ * Run curl without capturing output (for interactive CLI use).
68
+ */
69
+ export function run(args: readonly string[]): CurlResult {
70
+ return subprocessRunner(args);
71
+ }
72
+
73
+ /**
74
+ * Run curl with output capture (for credential checking).
75
+ */
76
+ export function runCaptured(args: readonly string[], timeout = 10): CurlResult {
77
+ return capturingSubprocessRunner(args, timeout);
78
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Encrypted file storage with automatic key management.
3
+ * Encryption keys are retrieved from:
4
+ * 1. Provided encryptionKeyOverride option
5
+ * 2. System keychain
6
+ * 3. Generated and stored in keychain (first run)
7
+ *
8
+ * Falls back to unencrypted storage with chmod 600 if keychain is unavailable.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { DEFAULT_KEYRING_SERVICE_NAME, DEFAULT_KEYRING_ACCOUNT_NAME } from './config.js';
14
+ import { encrypt, decrypt, generateKey, DecryptionError } from './encryption.js';
15
+ import {
16
+ isKeychainAvailable,
17
+ retrieveFromKeychain,
18
+ storeInKeychain,
19
+ KeychainNotAvailableError,
20
+ } from './keychain.js';
21
+
22
+ const ENCRYPTED_FILE_PREFIX = 'LATCHKEY_ENCRYPTED:';
23
+
24
+ export class EncryptedStorageError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = 'EncryptedStorageError';
28
+ }
29
+ }
30
+
31
+ export class PathIsDirectoryError extends Error {
32
+ constructor(filePath: string) {
33
+ super(`Path is a directory, not a file: ${filePath}`);
34
+ this.name = 'PathIsDirectoryError';
35
+ }
36
+ }
37
+
38
+ export interface EncryptedStorageOptions {
39
+ encryptionKeyOverride?: string | null;
40
+ serviceName?: string;
41
+ accountName?: string;
42
+ }
43
+
44
+ /**
45
+ * Manages encrypted file storage with automatic key handling.
46
+ */
47
+ export class EncryptedStorage {
48
+ private readonly key: string | null;
49
+
50
+ constructor(options: EncryptedStorageOptions = {}) {
51
+ this.key = EncryptedStorage.initializeKey(options);
52
+ }
53
+
54
+ private static initializeKey(options: EncryptedStorageOptions): string | null {
55
+ // If key was provided via override, use it
56
+ if (options.encryptionKeyOverride !== undefined && options.encryptionKeyOverride !== null) {
57
+ return options.encryptionKeyOverride;
58
+ }
59
+
60
+ const serviceName = options.serviceName ?? DEFAULT_KEYRING_SERVICE_NAME;
61
+ const accountName = options.accountName ?? DEFAULT_KEYRING_ACCOUNT_NAME;
62
+
63
+ // Check if keychain is available
64
+ if (!isKeychainAvailable(serviceName, accountName)) {
65
+ return null;
66
+ }
67
+
68
+ // Try to retrieve from keychain
69
+ try {
70
+ const keychainKey = retrieveFromKeychain(serviceName, accountName);
71
+ if (keychainKey) {
72
+ return keychainKey;
73
+ }
74
+
75
+ // Generate new key and store in keychain
76
+ const newKey = generateKey();
77
+ storeInKeychain(serviceName, accountName, newKey);
78
+ return newKey;
79
+ } catch (error) {
80
+ if (error instanceof KeychainNotAvailableError) {
81
+ // Fall back to unencrypted storage
82
+ return null;
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ private isEncryptionEnabled(): boolean {
89
+ return this.key !== null;
90
+ }
91
+
92
+ /**
93
+ * Read and decrypt a file.
94
+ */
95
+ readFile(filePath: string): string | null {
96
+ if (!existsSync(filePath)) {
97
+ return null;
98
+ }
99
+
100
+ const stats = statSync(filePath);
101
+ if (stats.isDirectory()) {
102
+ throw new PathIsDirectoryError(filePath);
103
+ }
104
+
105
+ const content = readFileSync(filePath, 'utf-8');
106
+
107
+ // Check if the file is encrypted
108
+ if (content.startsWith(ENCRYPTED_FILE_PREFIX)) {
109
+ if (this.key === null) {
110
+ throw new EncryptedStorageError(
111
+ 'File is encrypted but a key is not available. ' +
112
+ 'Set LATCHKEY_ENCRYPTION_KEY or ensure system keychain is accessible.'
113
+ );
114
+ }
115
+
116
+ const encryptedData = content.slice(ENCRYPTED_FILE_PREFIX.length);
117
+ try {
118
+ return decrypt(encryptedData, this.key);
119
+ } catch (error) {
120
+ if (error instanceof DecryptionError) {
121
+ throw new EncryptedStorageError(
122
+ `Failed to decrypt file: ${error.message}. ` + 'The encryption key may have changed.'
123
+ );
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ // File is not encrypted (fallback mode when keychain unavailable)
130
+ return content;
131
+ }
132
+
133
+ /**
134
+ * Encrypt and write data to a file.
135
+ * Creates parent directories. New files are created with chmod 600.
136
+ */
137
+ writeFile(filePath: string, content: string): void {
138
+ const dir = dirname(filePath);
139
+ if (!existsSync(dir)) {
140
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
141
+ }
142
+
143
+ if (existsSync(filePath)) {
144
+ const stats = statSync(filePath);
145
+ if (stats.isDirectory()) {
146
+ throw new PathIsDirectoryError(filePath);
147
+ }
148
+ }
149
+
150
+ let dataToWrite: string;
151
+ if (this.key !== null) {
152
+ const encryptedData = encrypt(content, this.key);
153
+ dataToWrite = ENCRYPTED_FILE_PREFIX + encryptedData;
154
+ } else {
155
+ // Fallback to unencrypted storage
156
+ dataToWrite = content;
157
+ }
158
+
159
+ writeFileSync(filePath, dataToWrite, { encoding: 'utf-8', mode: 0o600 });
160
+ }
161
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Encryption utilities for secure credential storage.
3
+ * Uses AES-256-GCM for authenticated encryption.
4
+ */
5
+
6
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
7
+
8
+ const ALGORITHM = 'aes-256-gcm';
9
+ const KEY_LENGTH = 32; // 256 bits
10
+ const IV_LENGTH = 12; // 96 bits for GCM
11
+ const AUTH_TAG_LENGTH = 16; // 128 bits
12
+
13
+ export class EncryptionError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'EncryptionError';
17
+ }
18
+ }
19
+
20
+ export class DecryptionError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'DecryptionError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Encrypt data using AES-256-GCM.
29
+ * The key should be a base64-encoded 256-bit key.
30
+ * Returns a base64-encoded string containing: iv + authTag + ciphertext
31
+ */
32
+ export function encrypt(plaintext: string, keyBase64: string): string {
33
+ try {
34
+ const key = Buffer.from(keyBase64, 'base64');
35
+ if (key.length !== KEY_LENGTH) {
36
+ throw new EncryptionError(
37
+ `Invalid key length: expected ${String(KEY_LENGTH)} bytes, got ${String(key.length)}`
38
+ );
39
+ }
40
+
41
+ const iv = randomBytes(IV_LENGTH);
42
+
43
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
44
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
45
+ const authTag = cipher.getAuthTag();
46
+
47
+ // Combine: iv (12) + authTag (16) + ciphertext
48
+ const combined = Buffer.concat([iv, authTag, ciphertext]);
49
+ return combined.toString('base64');
50
+ } catch (error) {
51
+ if (error instanceof EncryptionError) {
52
+ throw error;
53
+ }
54
+ throw new EncryptionError(
55
+ `Failed to encrypt data: ${error instanceof Error ? error.message : String(error)}`
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Decrypt data that was encrypted with the encrypt function.
62
+ * The key should be a base64-encoded 256-bit key.
63
+ * Input should be a base64-encoded string containing: iv + authTag + ciphertext
64
+ */
65
+ export function decrypt(encryptedData: string, keyBase64: string): string {
66
+ try {
67
+ const key = Buffer.from(keyBase64, 'base64');
68
+ if (key.length !== KEY_LENGTH) {
69
+ throw new DecryptionError(
70
+ `Invalid key length: expected ${String(KEY_LENGTH)} bytes, got ${String(key.length)}`
71
+ );
72
+ }
73
+
74
+ const combined = Buffer.from(encryptedData, 'base64');
75
+
76
+ // Minimum length is iv + authTag (ciphertext can be empty for empty string)
77
+ if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) {
78
+ throw new DecryptionError('Invalid encrypted data: too short');
79
+ }
80
+
81
+ const iv = combined.subarray(0, IV_LENGTH);
82
+ const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
83
+ const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
84
+
85
+ const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
86
+ decipher.setAuthTag(authTag);
87
+
88
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
89
+ return decrypted.toString('utf8');
90
+ } catch (error) {
91
+ if (error instanceof DecryptionError) {
92
+ throw error;
93
+ }
94
+ throw new DecryptionError(
95
+ `Failed to decrypt data: ${error instanceof Error ? error.message : String(error)}`
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Generate a cryptographically secure random 256-bit key.
102
+ * Returns the key as a base64-encoded string.
103
+ */
104
+ export function generateKey(): string {
105
+ return randomBytes(KEY_LENGTH).toString('base64');
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Latchkey - A CLI tool that injects API credentials into curl requests.
3
+ */
4
+
5
+ // Core types and utilities
6
+ export {
7
+ ApiCredentials,
8
+ ApiCredentialStatus,
9
+ AuthorizationBearer,
10
+ AuthorizationBare,
11
+ SlackApiCredentials,
12
+ deserializeCredentials,
13
+ serializeCredentials,
14
+ } from './apiCredentials.js';
15
+
16
+ export { ApiCredentialStore, ApiCredentialStoreError } from './apiCredentialStore.js';
17
+
18
+ export { Config, CONFIG, InsecureFilePermissionsError } from './config.js';
19
+
20
+ export { encrypt, decrypt, generateKey, EncryptionError, DecryptionError } from './encryption.js';
21
+
22
+ export { EncryptedStorage, EncryptedStorageError } from './encryptedStorage.js';
23
+
24
+ export {
25
+ storeInKeychain,
26
+ retrieveFromKeychain,
27
+ deleteFromKeychain,
28
+ isKeychainAvailable,
29
+ KeychainError,
30
+ KeychainNotAvailableError,
31
+ } from './keychain.js';
32
+
33
+ export {
34
+ run as runCurl,
35
+ runCaptured as runCurlCaptured,
36
+ setSubprocessRunner,
37
+ resetSubprocessRunner,
38
+ setCapturingSubprocessRunner,
39
+ resetCapturingSubprocessRunner,
40
+ } from './curl.js';
41
+
42
+ export { typeLikeHuman } from './playwrightUtils.js';
43
+
44
+ // Services
45
+ export {
46
+ Service,
47
+ ServiceSession,
48
+ SimpleServiceSession,
49
+ BrowserFollowupServiceSession,
50
+ LoginCancelledError,
51
+ LoginFailedError,
52
+ Slack,
53
+ SLACK,
54
+ Discord,
55
+ DISCORD,
56
+ Github,
57
+ GITHUB,
58
+ Dropbox,
59
+ DROPBOX,
60
+ Linear,
61
+ LINEAR,
62
+ } from './services/index.js';
63
+
64
+ // Registry
65
+ export { Registry, REGISTRY } from './registry.js';
@@ -0,0 +1,105 @@
1
+ /**
2
+ * System keychain integration for secure password storage.
3
+ * Uses @napi-rs/keyring for cross-platform support (macOS Keychain, Windows Credential Manager,
4
+ * Linux Secret Service via keyutils/kernel keyring).
5
+ */
6
+
7
+ import { Entry } from '@napi-rs/keyring';
8
+
9
+ export class KeychainError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = 'KeychainError';
13
+ }
14
+ }
15
+
16
+ export class KeychainNotAvailableError extends KeychainError {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = 'KeychainNotAvailableError';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Get a keyring entry.
25
+ */
26
+ function getEntry(serviceName: string, accountName: string): Entry {
27
+ return new Entry(serviceName, accountName);
28
+ }
29
+
30
+ /**
31
+ * Store a password in the system keychain.
32
+ * Throws KeychainNotAvailableError if the keychain is not accessible.
33
+ */
34
+ export function storeInKeychain(serviceName: string, accountName: string, password: string): void {
35
+ try {
36
+ const entry = getEntry(serviceName, accountName);
37
+ entry.setPassword(password);
38
+ } catch (error) {
39
+ throw new KeychainNotAvailableError(
40
+ `Failed to store password in keychain: ${error instanceof Error ? error.message : String(error)}`
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Retrieve a password from the system keychain.
47
+ * Returns null if the password is not found.
48
+ * Throws KeychainNotAvailableError if the keychain is not accessible.
49
+ */
50
+ export function retrieveFromKeychain(serviceName: string, accountName: string): string | null {
51
+ try {
52
+ const entry = getEntry(serviceName, accountName);
53
+ const password = entry.getPassword();
54
+ return password ?? null;
55
+ } catch (error) {
56
+ const errorMessage = error instanceof Error ? error.message : String(error);
57
+ // Check if it's a "not found" error
58
+ if (
59
+ errorMessage.includes('not found') ||
60
+ errorMessage.includes('No password') ||
61
+ errorMessage.includes('ItemNotFound')
62
+ ) {
63
+ return null;
64
+ }
65
+ throw new KeychainNotAvailableError(
66
+ `Failed to retrieve password from keychain: ${errorMessage}`
67
+ );
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Delete a password from the system keychain.
73
+ * Returns true if deleted, false if not found.
74
+ * Throws KeychainNotAvailableError if the keychain is not accessible.
75
+ */
76
+ export function deleteFromKeychain(serviceName: string, accountName: string): boolean {
77
+ try {
78
+ const entry = getEntry(serviceName, accountName);
79
+ entry.deletePassword();
80
+ return true;
81
+ } catch (error) {
82
+ const errorMessage = error instanceof Error ? error.message : String(error);
83
+ if (
84
+ errorMessage.includes('not found') ||
85
+ errorMessage.includes('No password') ||
86
+ errorMessage.includes('ItemNotFound')
87
+ ) {
88
+ return false;
89
+ }
90
+ throw new KeychainNotAvailableError(`Failed to delete password from keychain: ${errorMessage}`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Check if the system keychain is available.
96
+ */
97
+ export function isKeychainAvailable(serviceName: string, accountName: string): boolean {
98
+ try {
99
+ // Try to create an entry - this should work on all supported platforms
100
+ getEntry(serviceName, accountName);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }