@khanglvm/outline-cli 0.1.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.
@@ -0,0 +1,290 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { Entry } from "@napi-rs/keyring";
4
+ import { CliError } from "./errors.js";
5
+
6
+ const KEYCHAIN_SECRET_VERSION = 1;
7
+ const KEYCHAIN_SECRET_KIND = "outline-cli.profile-auth";
8
+ const DEFAULT_KEYCHAIN_SERVICE = "com.khanglvm.outline-cli";
9
+ const MODE_REQUIRED = "required";
10
+ const MODE_OPTIONAL = "optional";
11
+ const MODE_DISABLED = "disabled";
12
+
13
+ function normalizeMode(mode) {
14
+ const value = String(mode || "").trim().toLowerCase();
15
+ if (value === MODE_DISABLED || value === MODE_OPTIONAL || value === MODE_REQUIRED) {
16
+ return value;
17
+ }
18
+ return MODE_REQUIRED;
19
+ }
20
+
21
+ export function getKeychainMode() {
22
+ return normalizeMode(process.env.OUTLINE_CLI_KEYCHAIN_MODE || process.env.OUTLINE_AGENT_KEYCHAIN_MODE);
23
+ }
24
+
25
+ export function getKeychainServiceName() {
26
+ const value = String(process.env.OUTLINE_CLI_KEYCHAIN_SERVICE || "").trim();
27
+ return value || DEFAULT_KEYCHAIN_SERVICE;
28
+ }
29
+
30
+ function digestHex(value) {
31
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
32
+ }
33
+
34
+ function accountScope(configPath) {
35
+ return digestHex(path.resolve(configPath)).slice(0, 16);
36
+ }
37
+
38
+ function hasKeychainRef(auth) {
39
+ return !!(auth && auth.credentialRef && auth.credentialRef.service && auth.credentialRef.account);
40
+ }
41
+
42
+ function isNotFoundError(err) {
43
+ const message = String(err?.message || "");
44
+ return /not found|no matching|could not be found|item/i.test(message);
45
+ }
46
+
47
+ function toCredentialRef(configPath, profileId, auth) {
48
+ if (hasKeychainRef(auth)) {
49
+ return {
50
+ service: String(auth.credentialRef.service),
51
+ account: String(auth.credentialRef.account),
52
+ schemaVersion: Number(auth.credentialRef.schemaVersion || KEYCHAIN_SECRET_VERSION),
53
+ };
54
+ }
55
+ return {
56
+ service: getKeychainServiceName(),
57
+ account: `profile-auth:${accountScope(configPath)}:${String(profileId)}`,
58
+ schemaVersion: KEYCHAIN_SECRET_VERSION,
59
+ };
60
+ }
61
+
62
+ function sanitizeAuth(auth, ref) {
63
+ const clone = structuredClone(auth || {});
64
+ delete clone.apiKey;
65
+ delete clone.password;
66
+ clone.credentialStore = "os-keychain";
67
+ clone.credentialRef = ref;
68
+ return clone;
69
+ }
70
+
71
+ function encodeSecret(payload) {
72
+ return JSON.stringify({
73
+ version: KEYCHAIN_SECRET_VERSION,
74
+ kind: KEYCHAIN_SECRET_KIND,
75
+ payload,
76
+ });
77
+ }
78
+
79
+ function decodeSecret(raw) {
80
+ const parsed = JSON.parse(raw);
81
+ if (!parsed || typeof parsed !== "object") {
82
+ throw new Error("Invalid keychain payload");
83
+ }
84
+ if (parsed.kind !== KEYCHAIN_SECRET_KIND) {
85
+ throw new Error("Unsupported keychain payload kind");
86
+ }
87
+ return parsed.payload || {};
88
+ }
89
+
90
+ function secretPayloadFromAuth(auth) {
91
+ if (!auth || typeof auth !== "object") {
92
+ return null;
93
+ }
94
+ if (auth.type === "apiKey" && auth.apiKey) {
95
+ return { apiKey: String(auth.apiKey) };
96
+ }
97
+ if ((auth.type === "basic" || auth.type === "password") && auth.password) {
98
+ return { password: String(auth.password) };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function createEntry(ref) {
104
+ return new Entry(ref.service, ref.account);
105
+ }
106
+
107
+ function buildKeychainError(message, context, err) {
108
+ return new CliError(message, {
109
+ ...context,
110
+ code: "KEYCHAIN_ERROR",
111
+ keychainMessage: err?.message || String(err),
112
+ });
113
+ }
114
+
115
+ export function secureProfileForStorage({ configPath, profileId, profile }) {
116
+ const mode = getKeychainMode();
117
+ const clone = structuredClone(profile);
118
+ const payload = secretPayloadFromAuth(clone.auth);
119
+ if (!payload) {
120
+ return {
121
+ profile: clone,
122
+ keychain: {
123
+ used: false,
124
+ mode,
125
+ reason: "no-sensitive-fields",
126
+ },
127
+ };
128
+ }
129
+
130
+ if (mode === MODE_DISABLED) {
131
+ clone.auth = {
132
+ ...(clone.auth || {}),
133
+ credentialStore: "config-inline",
134
+ };
135
+ return {
136
+ profile: clone,
137
+ keychain: {
138
+ used: false,
139
+ mode,
140
+ reason: "disabled",
141
+ },
142
+ };
143
+ }
144
+
145
+ const ref = toCredentialRef(configPath, profileId, clone.auth);
146
+ try {
147
+ const entry = createEntry(ref);
148
+ entry.setPassword(encodeSecret(payload));
149
+ clone.auth = sanitizeAuth(clone.auth, ref);
150
+ return {
151
+ profile: clone,
152
+ keychain: {
153
+ used: true,
154
+ mode,
155
+ ref,
156
+ },
157
+ };
158
+ } catch (err) {
159
+ if (mode === MODE_OPTIONAL) {
160
+ clone.auth = {
161
+ ...(clone.auth || {}),
162
+ credentialStore: "config-inline",
163
+ };
164
+ return {
165
+ profile: clone,
166
+ keychain: {
167
+ used: false,
168
+ mode,
169
+ reason: "store-failed-optional",
170
+ keychainMessage: err?.message || String(err),
171
+ },
172
+ };
173
+ }
174
+ throw buildKeychainError("Failed to write credentials to OS keychain", {
175
+ profileId,
176
+ mode,
177
+ service: ref.service,
178
+ account: ref.account,
179
+ }, err);
180
+ }
181
+ }
182
+
183
+ function mergeSecretIntoAuth(auth, payload) {
184
+ const next = {
185
+ ...(auth || {}),
186
+ };
187
+ if (payload.apiKey) {
188
+ next.apiKey = payload.apiKey;
189
+ }
190
+ if (payload.password) {
191
+ next.password = payload.password;
192
+ }
193
+ return next;
194
+ }
195
+
196
+ function authRequiresSecret(auth) {
197
+ if (!auth || typeof auth !== "object") {
198
+ return false;
199
+ }
200
+ if (auth.type === "apiKey") {
201
+ return true;
202
+ }
203
+ return auth.type === "basic" || auth.type === "password";
204
+ }
205
+
206
+ export function hydrateProfileFromKeychain({ configPath, profile }) {
207
+ const mode = getKeychainMode();
208
+ const clone = structuredClone(profile);
209
+ const auth = clone.auth || {};
210
+ if (!authRequiresSecret(auth)) {
211
+ return clone;
212
+ }
213
+
214
+ if (auth.apiKey || auth.password) {
215
+ return clone;
216
+ }
217
+
218
+ if (mode === MODE_DISABLED) {
219
+ throw new CliError("Profile requires OS keychain credentials but keychain mode is disabled", {
220
+ code: "KEYCHAIN_DISABLED",
221
+ profileId: clone.id,
222
+ mode,
223
+ });
224
+ }
225
+
226
+ const ref = toCredentialRef(configPath, clone.id, auth);
227
+ try {
228
+ const entry = createEntry(ref);
229
+ const raw = entry.getPassword();
230
+ const payload = decodeSecret(raw);
231
+ clone.auth = mergeSecretIntoAuth(auth, payload);
232
+ return clone;
233
+ } catch (err) {
234
+ if (isNotFoundError(err)) {
235
+ throw new CliError("Profile credentials are missing in OS keychain", {
236
+ code: "KEYCHAIN_SECRET_NOT_FOUND",
237
+ profileId: clone.id,
238
+ service: ref.service,
239
+ account: ref.account,
240
+ });
241
+ }
242
+ throw buildKeychainError("Failed to read credentials from OS keychain", {
243
+ profileId: clone.id,
244
+ service: ref.service,
245
+ account: ref.account,
246
+ mode,
247
+ }, err);
248
+ }
249
+ }
250
+
251
+ export function removeProfileFromKeychain({ configPath, profileId, profile }) {
252
+ const auth = profile?.auth || {};
253
+ if (!authRequiresSecret(auth)) {
254
+ return {
255
+ removed: false,
256
+ reason: "no-sensitive-fields",
257
+ };
258
+ }
259
+ if (!hasKeychainRef(auth) && auth.credentialStore === "config-inline") {
260
+ return {
261
+ removed: false,
262
+ reason: "inline-storage",
263
+ };
264
+ }
265
+
266
+ const ref = toCredentialRef(configPath, profileId, auth);
267
+ try {
268
+ const entry = createEntry(ref);
269
+ entry.deletePassword();
270
+ return {
271
+ removed: true,
272
+ service: ref.service,
273
+ account: ref.account,
274
+ };
275
+ } catch (err) {
276
+ if (isNotFoundError(err)) {
277
+ return {
278
+ removed: false,
279
+ reason: "not-found",
280
+ service: ref.service,
281
+ account: ref.account,
282
+ };
283
+ }
284
+ throw buildKeychainError("Failed to delete credentials from OS keychain", {
285
+ profileId,
286
+ service: ref.service,
287
+ account: ref.account,
288
+ }, err);
289
+ }
290
+ }