@push.rocks/smartsecret 1.0.1
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/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/index.d.ts +5 -0
- package/dist_ts/index.js +5 -0
- package/dist_ts/smartsecret.backends.base.d.ts +9 -0
- package/dist_ts/smartsecret.backends.base.js +2 -0
- package/dist_ts/smartsecret.backends.file.d.ts +17 -0
- package/dist_ts/smartsecret.backends.file.js +121 -0
- package/dist_ts/smartsecret.backends.linux.d.ts +11 -0
- package/dist_ts/smartsecret.backends.linux.js +106 -0
- package/dist_ts/smartsecret.backends.macos.d.ts +11 -0
- package/dist_ts/smartsecret.backends.macos.js +104 -0
- package/dist_ts/smartsecret.classes.smartsecret.d.ts +17 -0
- package/dist_ts/smartsecret.classes.smartsecret.js +49 -0
- package/dist_ts/smartsecret.plugins.d.ts +6 -0
- package/dist_ts/smartsecret.plugins.js +8 -0
- package/license +21 -0
- package/npmextra.json +44 -0
- package/package.json +58 -0
- package/readme.hints.md +12 -0
- package/readme.md +193 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/index.ts +5 -0
- package/ts/smartsecret.backends.base.ts +10 -0
- package/ts/smartsecret.backends.file.ts +153 -0
- package/ts/smartsecret.backends.linux.ts +109 -0
- package/ts/smartsecret.backends.macos.ts +107 -0
- package/ts/smartsecret.classes.smartsecret.ts +66 -0
- package/ts/smartsecret.plugins.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@push.rocks/smartsecret",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "OS keychain-based secret storage with encrypted-file fallback for Node.js.",
|
|
6
|
+
"main": "dist_ts/index.js",
|
|
7
|
+
"typings": "dist_ts/index.d.ts",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "(tstest test/ --verbose)",
|
|
11
|
+
"build": "(tsbuild)",
|
|
12
|
+
"buildDocs": "tsdoc"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://code.foss.global/push.rocks/smartsecret.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"secret",
|
|
20
|
+
"keychain",
|
|
21
|
+
"credential",
|
|
22
|
+
"vault",
|
|
23
|
+
"encryption",
|
|
24
|
+
"security",
|
|
25
|
+
"keyring",
|
|
26
|
+
"libsecret",
|
|
27
|
+
"macos-keychain",
|
|
28
|
+
"aes-256-gcm"
|
|
29
|
+
],
|
|
30
|
+
"author": "Task Venture Capital GmbH",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/pushrocks/smartsecret/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://code.foss.global/push.rocks/smartsecret",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@git.zone/tsbuild": "^4.1.2",
|
|
38
|
+
"@git.zone/tsrun": "^2.0.1",
|
|
39
|
+
"@git.zone/tstest": "^3.1.8",
|
|
40
|
+
"@push.rocks/tapbundle": "^6.0.3",
|
|
41
|
+
"@types/node": "^22.15.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"ts/**/*",
|
|
45
|
+
"ts_web/**/*",
|
|
46
|
+
"dist/**/*",
|
|
47
|
+
"dist_*/**/*",
|
|
48
|
+
"dist_ts/**/*",
|
|
49
|
+
"dist_ts_web/**/*",
|
|
50
|
+
"assets/**/*",
|
|
51
|
+
"cli.js",
|
|
52
|
+
"npmextra.json",
|
|
53
|
+
"readme.md"
|
|
54
|
+
],
|
|
55
|
+
"browserslist": [
|
|
56
|
+
"last 1 chrome versions"
|
|
57
|
+
]
|
|
58
|
+
}
|
package/readme.hints.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# smartsecret hints
|
|
2
|
+
|
|
3
|
+
## Architecture
|
|
4
|
+
- 3-tier backend: macOS Keychain → Linux secret-tool → encrypted file vault
|
|
5
|
+
- Zero runtime dependencies (Node.js built-ins only)
|
|
6
|
+
- All OS interactions via child_process.execFile (no shell injection)
|
|
7
|
+
|
|
8
|
+
## File Backend
|
|
9
|
+
- Vault: AES-256-GCM, JSON file with { iv, ciphertext, tag } per entry
|
|
10
|
+
- Keyfile: auto-generated 32 random bytes at ~/.config/smartsecret/.keyfile (mode 0600)
|
|
11
|
+
- Key derivation: PBKDF2 (SHA-512, 100k iterations, service-name salt)
|
|
12
|
+
- Atomic writes: write .tmp then rename()
|
package/readme.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# @push.rocks/smartsecret
|
|
2
|
+
|
|
3
|
+
OS keychain-based secret storage with encrypted-file fallback for Node.js.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
To install `@push.rocks/smartsecret`, use pnpm:
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
pnpm install @push.rocks/smartsecret
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
`@push.rocks/smartsecret` provides a unified API for storing and retrieving secrets. It automatically selects the best available backend for the current platform: macOS Keychain on macOS, `secret-tool` (libsecret / GNOME Keyring) on Linux, or an AES-256-GCM encrypted file as a universal fallback.
|
|
16
|
+
|
|
17
|
+
### Basic Setup
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { SmartSecret } from '@push.rocks/smartsecret';
|
|
21
|
+
|
|
22
|
+
// Create an instance with default settings
|
|
23
|
+
const secretStore = new SmartSecret();
|
|
24
|
+
|
|
25
|
+
// Or specify a custom service name and vault path
|
|
26
|
+
const secretStore = new SmartSecret({
|
|
27
|
+
service: 'my-application',
|
|
28
|
+
vaultPath: '/path/to/custom/vault.json',
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `service` option acts as a namespace, isolating secrets so that different applications do not collide. It defaults to `'smartsecret'` when omitted.
|
|
33
|
+
|
|
34
|
+
The `vaultPath` option only applies to the encrypted-file backend and controls where the vault JSON file is stored. It defaults to `~/.config/smartsecret/vault.json`.
|
|
35
|
+
|
|
36
|
+
### Storing a Secret
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
await secretStore.setSecret('api-key', 'sk-abc123xyz');
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If a secret with the same account name already exists under the configured service, it is overwritten.
|
|
43
|
+
|
|
44
|
+
### Retrieving a Secret
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
const apiKey = await secretStore.getSecret('api-key');
|
|
48
|
+
|
|
49
|
+
if (apiKey !== null) {
|
|
50
|
+
console.log('Retrieved secret:', apiKey);
|
|
51
|
+
} else {
|
|
52
|
+
console.log('Secret not found');
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Returns `null` when no secret exists for the given account.
|
|
57
|
+
|
|
58
|
+
### Deleting a Secret
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const wasDeleted = await secretStore.deleteSecret('api-key');
|
|
62
|
+
console.log(wasDeleted); // true if the secret existed and was removed
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Returns `false` if the secret did not exist.
|
|
66
|
+
|
|
67
|
+
### Listing Accounts
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const accounts = await secretStore.listAccounts();
|
|
71
|
+
console.log(accounts); // e.g. ['api-key', 'db-password', 'oauth-token']
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Returns an array of account names that have stored secrets under the configured service.
|
|
75
|
+
|
|
76
|
+
### Checking the Active Backend
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const backendType = await secretStore.getBackendType();
|
|
80
|
+
console.log(backendType);
|
|
81
|
+
// 'macos-keychain' | 'linux-secret-service' | 'file-encrypted'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This is useful for logging or diagnostics to understand which storage mechanism is in use at runtime.
|
|
85
|
+
|
|
86
|
+
### Service-Based Isolation
|
|
87
|
+
|
|
88
|
+
Different `SmartSecret` instances with different `service` names maintain completely separate secret namespaces, even when sharing the same underlying storage:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const appSecrets = new SmartSecret({ service: 'my-app' });
|
|
92
|
+
const ciSecrets = new SmartSecret({ service: 'ci-pipeline' });
|
|
93
|
+
|
|
94
|
+
await appSecrets.setSecret('token', 'app-token-value');
|
|
95
|
+
await ciSecrets.setSecret('token', 'ci-token-value');
|
|
96
|
+
|
|
97
|
+
const appToken = await appSecrets.getSecret('token'); // 'app-token-value'
|
|
98
|
+
const ciToken = await ciSecrets.getSecret('token'); // 'ci-token-value'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## API Reference
|
|
102
|
+
|
|
103
|
+
### `SmartSecret`
|
|
104
|
+
|
|
105
|
+
The main class. Instantiate it to store and retrieve secrets.
|
|
106
|
+
|
|
107
|
+
#### Constructor
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
new SmartSecret(options?: ISmartSecretOptions)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Option | Type | Default | Description |
|
|
114
|
+
| --- | --- | --- | --- |
|
|
115
|
+
| `service` | `string` | `'smartsecret'` | Namespace for secret isolation |
|
|
116
|
+
| `vaultPath` | `string` | `~/.config/smartsecret/vault.json` | Path to the encrypted vault file (file backend only) |
|
|
117
|
+
|
|
118
|
+
#### Methods
|
|
119
|
+
|
|
120
|
+
| Method | Signature | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `setSecret` | `(account: string, secret: string) => Promise<void>` | Store or overwrite a secret |
|
|
123
|
+
| `getSecret` | `(account: string) => Promise<string \| null>` | Retrieve a secret, or `null` if not found |
|
|
124
|
+
| `deleteSecret` | `(account: string) => Promise<boolean>` | Delete a secret; returns `true` if it existed |
|
|
125
|
+
| `listAccounts` | `() => Promise<string[]>` | List all account names for the configured service |
|
|
126
|
+
| `getBackendType` | `() => Promise<TBackendType>` | Returns the active backend identifier |
|
|
127
|
+
|
|
128
|
+
### Types
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
type TBackendType = 'macos-keychain' | 'linux-secret-service' | 'file-encrypted';
|
|
132
|
+
|
|
133
|
+
interface ISmartSecretOptions {
|
|
134
|
+
service?: string;
|
|
135
|
+
vaultPath?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface ISecretBackend {
|
|
139
|
+
readonly backendType: TBackendType;
|
|
140
|
+
isAvailable(): Promise<boolean>;
|
|
141
|
+
setSecret(account: string, secret: string): Promise<void>;
|
|
142
|
+
getSecret(account: string): Promise<string | null>;
|
|
143
|
+
deleteSecret(account: string): Promise<boolean>;
|
|
144
|
+
listAccounts(): Promise<string[]>;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Backend Classes
|
|
149
|
+
|
|
150
|
+
Each backend implements `ISecretBackend` and can be used directly if needed:
|
|
151
|
+
|
|
152
|
+
- `MacosKeychainBackend` -- macOS Keychain via the `security` CLI
|
|
153
|
+
- `LinuxSecretServiceBackend` -- Linux Secret Service via `secret-tool`
|
|
154
|
+
- `FileEncryptedBackend` -- AES-256-GCM encrypted JSON vault file
|
|
155
|
+
|
|
156
|
+
## Backends
|
|
157
|
+
|
|
158
|
+
`SmartSecret` tries each backend in order and uses the first one that reports itself as available.
|
|
159
|
+
|
|
160
|
+
### macOS Keychain (`macos-keychain`)
|
|
161
|
+
|
|
162
|
+
Used automatically on macOS when the `security` command-line tool is present (ships with macOS by default). Secrets are stored as generic password items in the user's default keychain. The `service` option maps to the keychain service name, and the `account` parameter maps to the keychain account name.
|
|
163
|
+
|
|
164
|
+
### Linux Secret Service (`linux-secret-service`)
|
|
165
|
+
|
|
166
|
+
Used automatically on Linux when `secret-tool` is installed. This integrates with GNOME Keyring, KDE Wallet, or any other provider that implements the freedesktop.org Secret Service D-Bus API. Install the tool on Debian/Ubuntu with:
|
|
167
|
+
|
|
168
|
+
```shell
|
|
169
|
+
sudo apt install libsecret-tools
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Secrets are stored with `service` and `account` as lookup attributes.
|
|
173
|
+
|
|
174
|
+
### Encrypted File (`file-encrypted`)
|
|
175
|
+
|
|
176
|
+
The universal fallback that works on all platforms. Secrets are encrypted with AES-256-GCM and stored in a JSON vault file. A random 32-byte key is generated on first use and stored alongside the vault at `~/.config/smartsecret/.keyfile` (with `0600` permissions). The encryption key is derived from the keyfile using PBKDF2 with 100,000 iterations of SHA-512, salted with the service name.
|
|
177
|
+
|
|
178
|
+
Vault writes are atomic (write to a temporary file, then rename) to prevent corruption. Both the keyfile and the vault file are created with restrictive file permissions.
|
|
179
|
+
|
|
180
|
+
**File locations (defaults):**
|
|
181
|
+
|
|
182
|
+
| File | Path |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| Vault | `~/.config/smartsecret/vault.json` |
|
|
185
|
+
| Keyfile | `~/.config/smartsecret/.keyfile` |
|
|
186
|
+
|
|
187
|
+
Both paths can be influenced by providing a custom `vaultPath` in the constructor options. The keyfile is always stored in the same directory as the vault.
|
|
188
|
+
|
|
189
|
+
## License and Legal Information
|
|
190
|
+
|
|
191
|
+
This project is licensed under the MIT license. For more details, see the `license` file in the repository.
|
|
192
|
+
|
|
193
|
+
By using this package, you agree to the licensing terms.
|
package/ts/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from './smartsecret.classes.smartsecret.js';
|
|
2
|
+
export type { ISecretBackend, TBackendType } from './smartsecret.backends.base.js';
|
|
3
|
+
export { MacosKeychainBackend } from './smartsecret.backends.macos.js';
|
|
4
|
+
export { LinuxSecretServiceBackend } from './smartsecret.backends.linux.js';
|
|
5
|
+
export { FileEncryptedBackend } from './smartsecret.backends.file.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TBackendType = 'macos-keychain' | 'linux-secret-service' | 'file-encrypted';
|
|
2
|
+
|
|
3
|
+
export interface ISecretBackend {
|
|
4
|
+
readonly backendType: TBackendType;
|
|
5
|
+
isAvailable(): Promise<boolean>;
|
|
6
|
+
setSecret(account: string, secret: string): Promise<void>;
|
|
7
|
+
getSecret(account: string): Promise<string | null>;
|
|
8
|
+
deleteSecret(account: string): Promise<boolean>;
|
|
9
|
+
listAccounts(): Promise<string[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as plugins from './smartsecret.plugins.js';
|
|
2
|
+
import type { ISecretBackend, TBackendType } from './smartsecret.backends.base.js';
|
|
3
|
+
|
|
4
|
+
interface IVaultEntry {
|
|
5
|
+
iv: string; // hex
|
|
6
|
+
ciphertext: string; // hex
|
|
7
|
+
tag: string; // hex
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IVaultData {
|
|
11
|
+
[key: string]: IVaultEntry;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class FileEncryptedBackend implements ISecretBackend {
|
|
15
|
+
public readonly backendType: TBackendType = 'file-encrypted';
|
|
16
|
+
private service: string;
|
|
17
|
+
private vaultPath: string;
|
|
18
|
+
private keyfilePath: string;
|
|
19
|
+
|
|
20
|
+
constructor(service: string, vaultPath?: string) {
|
|
21
|
+
this.service = service;
|
|
22
|
+
const configDir = vaultPath
|
|
23
|
+
? plugins.path.dirname(vaultPath)
|
|
24
|
+
: plugins.path.join(plugins.os.homedir(), '.config', 'smartsecret');
|
|
25
|
+
this.vaultPath = vaultPath || plugins.path.join(configDir, 'vault.json');
|
|
26
|
+
this.keyfilePath = plugins.path.join(configDir, '.keyfile');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async isAvailable(): Promise<boolean> {
|
|
30
|
+
return true; // File backend is always available
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async setSecret(account: string, secret: string): Promise<void> {
|
|
34
|
+
const key = await this.deriveKey();
|
|
35
|
+
const vault = await this.readVault();
|
|
36
|
+
const vaultKey = `${this.service}:${account}`;
|
|
37
|
+
|
|
38
|
+
const iv = plugins.crypto.randomBytes(12);
|
|
39
|
+
const cipher = plugins.crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
40
|
+
const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
41
|
+
const tag = cipher.getAuthTag();
|
|
42
|
+
|
|
43
|
+
vault[vaultKey] = {
|
|
44
|
+
iv: iv.toString('hex'),
|
|
45
|
+
ciphertext: encrypted.toString('hex'),
|
|
46
|
+
tag: tag.toString('hex'),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
await this.writeVault(vault);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getSecret(account: string): Promise<string | null> {
|
|
53
|
+
const key = await this.deriveKey();
|
|
54
|
+
const vault = await this.readVault();
|
|
55
|
+
const vaultKey = `${this.service}:${account}`;
|
|
56
|
+
|
|
57
|
+
const entry = vault[vaultKey];
|
|
58
|
+
if (!entry) return null;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const iv = Buffer.from(entry.iv, 'hex');
|
|
62
|
+
const ciphertext = Buffer.from(entry.ciphertext, 'hex');
|
|
63
|
+
const tag = Buffer.from(entry.tag, 'hex');
|
|
64
|
+
|
|
65
|
+
const decipher = plugins.crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
66
|
+
decipher.setAuthTag(tag);
|
|
67
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
68
|
+
return decrypted.toString('utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async deleteSecret(account: string): Promise<boolean> {
|
|
75
|
+
const vault = await this.readVault();
|
|
76
|
+
const vaultKey = `${this.service}:${account}`;
|
|
77
|
+
|
|
78
|
+
if (!(vaultKey in vault)) return false;
|
|
79
|
+
|
|
80
|
+
delete vault[vaultKey];
|
|
81
|
+
await this.writeVault(vault);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async listAccounts(): Promise<string[]> {
|
|
86
|
+
const vault = await this.readVault();
|
|
87
|
+
const prefix = `${this.service}:`;
|
|
88
|
+
return Object.keys(vault)
|
|
89
|
+
.filter((k) => k.startsWith(prefix))
|
|
90
|
+
.map((k) => k.slice(prefix.length));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async deriveKey(): Promise<Buffer> {
|
|
94
|
+
const keyfileContent = await this.ensureKeyfile();
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
plugins.crypto.pbkdf2(
|
|
97
|
+
keyfileContent,
|
|
98
|
+
`smartsecret:${this.service}`,
|
|
99
|
+
100_000,
|
|
100
|
+
32,
|
|
101
|
+
'sha512',
|
|
102
|
+
(err, derivedKey) => {
|
|
103
|
+
if (err) reject(err);
|
|
104
|
+
else resolve(derivedKey);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async ensureKeyfile(): Promise<Buffer> {
|
|
111
|
+
const dir = plugins.path.dirname(this.keyfilePath);
|
|
112
|
+
try {
|
|
113
|
+
await plugins.fs.promises.mkdir(dir, { recursive: true });
|
|
114
|
+
} catch {
|
|
115
|
+
// Already exists
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const content = await plugins.fs.promises.readFile(this.keyfilePath);
|
|
120
|
+
return content;
|
|
121
|
+
} catch {
|
|
122
|
+
// Generate new keyfile
|
|
123
|
+
const key = plugins.crypto.randomBytes(32);
|
|
124
|
+
await plugins.fs.promises.writeFile(this.keyfilePath, key, { mode: 0o600 });
|
|
125
|
+
return key;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async readVault(): Promise<IVaultData> {
|
|
130
|
+
try {
|
|
131
|
+
const content = await plugins.fs.promises.readFile(this.vaultPath, 'utf8');
|
|
132
|
+
return JSON.parse(content) as IVaultData;
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async writeVault(vault: IVaultData): Promise<void> {
|
|
139
|
+
const dir = plugins.path.dirname(this.vaultPath);
|
|
140
|
+
try {
|
|
141
|
+
await plugins.fs.promises.mkdir(dir, { recursive: true });
|
|
142
|
+
} catch {
|
|
143
|
+
// Already exists
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tmpPath = this.vaultPath + '.tmp';
|
|
147
|
+
await plugins.fs.promises.writeFile(tmpPath, JSON.stringify(vault, null, 2), {
|
|
148
|
+
encoding: 'utf8',
|
|
149
|
+
mode: 0o600,
|
|
150
|
+
});
|
|
151
|
+
await plugins.fs.promises.rename(tmpPath, this.vaultPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as plugins from './smartsecret.plugins.js';
|
|
2
|
+
import type { ISecretBackend, TBackendType } from './smartsecret.backends.base.js';
|
|
3
|
+
|
|
4
|
+
function execFile(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
plugins.childProcess.execFile(cmd, args, { encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
7
|
+
if (err) {
|
|
8
|
+
reject(Object.assign(err, { stdout, stderr }));
|
|
9
|
+
} else {
|
|
10
|
+
resolve({ stdout: stdout as string, stderr: stderr as string });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function spawnWithStdin(cmd: string, args: string[], stdinData: string): Promise<{ stdout: string; stderr: string }> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = plugins.childProcess.spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
19
|
+
let stdout = '';
|
|
20
|
+
let stderr = '';
|
|
21
|
+
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
22
|
+
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
23
|
+
child.on('error', reject);
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
if (code === 0) {
|
|
26
|
+
resolve({ stdout, stderr });
|
|
27
|
+
} else {
|
|
28
|
+
reject(Object.assign(new Error(`secret-tool exited with code ${code}`), { stdout, stderr }));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
child.stdin!.write(stdinData);
|
|
32
|
+
child.stdin!.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class LinuxSecretServiceBackend implements ISecretBackend {
|
|
37
|
+
public readonly backendType: TBackendType = 'linux-secret-service';
|
|
38
|
+
private service: string;
|
|
39
|
+
|
|
40
|
+
constructor(service: string) {
|
|
41
|
+
this.service = service;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async isAvailable(): Promise<boolean> {
|
|
45
|
+
if (process.platform !== 'linux') return false;
|
|
46
|
+
try {
|
|
47
|
+
await execFile('which', ['secret-tool']);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async setSecret(account: string, secret: string): Promise<void> {
|
|
55
|
+
// secret-tool store reads password from stdin
|
|
56
|
+
await spawnWithStdin('secret-tool', [
|
|
57
|
+
'store',
|
|
58
|
+
'--label', `${this.service}:${account}`,
|
|
59
|
+
'service', this.service,
|
|
60
|
+
'account', account,
|
|
61
|
+
], secret);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getSecret(account: string): Promise<string | null> {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execFile('secret-tool', [
|
|
67
|
+
'lookup',
|
|
68
|
+
'service', this.service,
|
|
69
|
+
'account', account,
|
|
70
|
+
]);
|
|
71
|
+
// secret-tool returns empty string if not found (exit 0) or exits non-zero
|
|
72
|
+
return stdout.length > 0 ? stdout : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteSecret(account: string): Promise<boolean> {
|
|
79
|
+
try {
|
|
80
|
+
await execFile('secret-tool', [
|
|
81
|
+
'clear',
|
|
82
|
+
'service', this.service,
|
|
83
|
+
'account', account,
|
|
84
|
+
]);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async listAccounts(): Promise<string[]> {
|
|
92
|
+
try {
|
|
93
|
+
const { stdout } = await execFile('secret-tool', [
|
|
94
|
+
'search',
|
|
95
|
+
'--all',
|
|
96
|
+
'service', this.service,
|
|
97
|
+
]);
|
|
98
|
+
const accounts: string[] = [];
|
|
99
|
+
const regex = /attribute\.account = (.+)/g;
|
|
100
|
+
let match: RegExpExecArray | null;
|
|
101
|
+
while ((match = regex.exec(stdout)) !== null) {
|
|
102
|
+
accounts.push(match[1].trim());
|
|
103
|
+
}
|
|
104
|
+
return [...new Set(accounts)];
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as plugins from './smartsecret.plugins.js';
|
|
2
|
+
import type { ISecretBackend, TBackendType } from './smartsecret.backends.base.js';
|
|
3
|
+
|
|
4
|
+
function execFile(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
plugins.childProcess.execFile(cmd, args, { encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
7
|
+
if (err) {
|
|
8
|
+
reject(Object.assign(err, { stdout, stderr }));
|
|
9
|
+
} else {
|
|
10
|
+
resolve({ stdout: stdout as string, stderr: stderr as string });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MacosKeychainBackend implements ISecretBackend {
|
|
17
|
+
public readonly backendType: TBackendType = 'macos-keychain';
|
|
18
|
+
private service: string;
|
|
19
|
+
|
|
20
|
+
constructor(service: string) {
|
|
21
|
+
this.service = service;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async isAvailable(): Promise<boolean> {
|
|
25
|
+
if (process.platform !== 'darwin') return false;
|
|
26
|
+
try {
|
|
27
|
+
await execFile('which', ['security']);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async setSecret(account: string, secret: string): Promise<void> {
|
|
35
|
+
// Delete existing entry first (ignore errors if not found)
|
|
36
|
+
try {
|
|
37
|
+
await execFile('security', [
|
|
38
|
+
'delete-generic-password',
|
|
39
|
+
'-s', this.service,
|
|
40
|
+
'-a', account,
|
|
41
|
+
]);
|
|
42
|
+
} catch {
|
|
43
|
+
// Not found — fine
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await execFile('security', [
|
|
47
|
+
'add-generic-password',
|
|
48
|
+
'-s', this.service,
|
|
49
|
+
'-a', account,
|
|
50
|
+
'-w', secret,
|
|
51
|
+
'-U',
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getSecret(account: string): Promise<string | null> {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execFile('security', [
|
|
58
|
+
'find-generic-password',
|
|
59
|
+
'-s', this.service,
|
|
60
|
+
'-a', account,
|
|
61
|
+
'-w',
|
|
62
|
+
]);
|
|
63
|
+
return stdout.trim();
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async deleteSecret(account: string): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
await execFile('security', [
|
|
72
|
+
'delete-generic-password',
|
|
73
|
+
'-s', this.service,
|
|
74
|
+
'-a', account,
|
|
75
|
+
]);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async listAccounts(): Promise<string[]> {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await execFile('security', [
|
|
85
|
+
'dump-keychain',
|
|
86
|
+
]);
|
|
87
|
+
const accounts: string[] = [];
|
|
88
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${this.service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`);
|
|
89
|
+
const lines = stdout.split('\n');
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
if (serviceRegex.test(lines[i])) {
|
|
92
|
+
// Look for the account line nearby
|
|
93
|
+
for (let j = Math.max(0, i - 5); j < Math.min(lines.length, i + 5); j++) {
|
|
94
|
+
const match = lines[j].match(/"acct"<blob>="([^"]+)"/);
|
|
95
|
+
if (match) {
|
|
96
|
+
accounts.push(match[1]);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...new Set(accounts)];
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|