ds-01 1.0.4 → 1.0.6

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.
@@ -1,103 +1,336 @@
1
1
  import { Command } from "commander";
2
- import { intro, outro, spinner, note, cancel } from "@clack/prompts";
2
+ import {
3
+ intro,
4
+ outro,
5
+ spinner,
6
+ note,
7
+ cancel,
8
+ } from "@clack/prompts";
3
9
  import pc from "picocolors";
4
10
  import open from "open";
5
11
  import machineIdPkg from "node-machine-id";
12
+
6
13
  import { saveToken } from "../utils/config.js";
14
+ import {
15
+ createDeviceKey,
16
+ getDevicePublicKey,
17
+ } from "../utils/secureKeyStore.js";
18
+
19
+ const API_BASE_URL =
20
+ process.env.DS01_API_URL ||
21
+ "https://ds-01.vercel.app";
7
22
 
8
- const API_BASE_URL = process.env.DS01_API_URL || "https://ds-01.vercel.app";
9
23
  const { machineIdSync } = machineIdPkg;
10
24
 
11
25
  export const login = new Command()
12
26
  .name("login")
13
- .description("Authenticate your terminal with DS01 via browser")
27
+ .description(
28
+ "Authenticate your terminal with DS01 via browser",
29
+ )
14
30
  .action(async () => {
15
- console.log(); // Spacing for breathing room
31
+ console.log();
16
32
 
17
- // 1. Header banner
18
33
  intro(
19
- `${pc.bgWhite(pc.black(pc.bold(" DS01 ")))} ${pc.gray("v1.0.0 — Terminal Authentication")}`
34
+ `${pc.bgWhite(
35
+ pc.black(
36
+ pc.bold(" DS01 "),
37
+ ),
38
+ )} ${pc.gray(
39
+ "v1.0.0 — Terminal Authentication",
40
+ )}`,
20
41
  );
21
42
 
22
43
  const s = spinner();
23
44
 
24
45
  try {
25
- // 2. Hardware ID Generation & Device Code Request
26
- s.start("Generating hardware fingerprint & requesting pairing code...");
27
- const hardwareId = machineIdSync();
46
+ // --------------------------------------------------
47
+ // 1. Create / load the device's secure key
48
+ // --------------------------------------------------
49
+
50
+ s.start(
51
+ "Preparing secure device identity...",
52
+ );
53
+
54
+ await createDeviceKey();
55
+
56
+ // Only the PUBLIC key/certificate leaves
57
+ // the user's machine.
58
+ const publicKey =
59
+ await getDevicePublicKey();
60
+
61
+ // Existing machine fingerprint.
62
+ const hardwareId =
63
+ machineIdSync();
64
+
65
+ // Detect the cryptographic key type.
66
+ const publicKeyType =
67
+ process.platform === "win32"
68
+ ? "windows-rsa"
69
+ : "macos-ec";
70
+
71
+ // --------------------------------------------------
72
+ // 2. Start device authorization
73
+ // --------------------------------------------------
74
+
75
+ s.start(
76
+ "Requesting pairing code...",
77
+ );
78
+
79
+ const initResponse =
80
+ await fetch(
81
+ `${API_BASE_URL}/api/auth/device/code`,
82
+ {
83
+ method: "POST",
84
+
85
+ headers: {
86
+ "Content-Type":
87
+ "application/json",
88
+ },
89
+
90
+ body: JSON.stringify({
91
+ machineId:
92
+ hardwareId,
28
93
 
29
- const initResponse = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
30
- method: "POST",
31
- headers: { "Content-Type": "application/json" },
32
- body: JSON.stringify({ machineId: hardwareId }),
33
- });
94
+ publicKey,
95
+
96
+ publicKeyType,
97
+ }),
98
+ },
99
+ );
34
100
 
35
101
  if (!initResponse.ok) {
36
- s.stop(pc.red("Failed to reach DS01 authorization server."));
37
- cancel(`Server responded with HTTP ${initResponse.status}`);
102
+ s.stop(
103
+ pc.red(
104
+ "Failed to reach DS01 authorization server.",
105
+ ),
106
+ );
107
+
108
+ cancel(
109
+ `Server responded with HTTP ${initResponse.status}`,
110
+ );
111
+
38
112
  process.exit(1);
39
113
  }
40
114
 
41
- const { deviceCode, userCode, verificationUrl, interval } =
42
- await initResponse.json();
115
+ const {
116
+ deviceCode,
117
+ userCode,
118
+ verificationUrl,
119
+ interval,
120
+ } = await initResponse.json();
43
121
 
44
- s.stop(pc.green("Device authorization code generated."));
122
+ if (
123
+ !deviceCode ||
124
+ !userCode ||
125
+ !verificationUrl
126
+ ) {
127
+ throw new Error(
128
+ "Authorization server returned an invalid device response.",
129
+ );
130
+ }
131
+
132
+ s.stop(
133
+ pc.green(
134
+ "Secure device identity registered.",
135
+ ),
136
+ );
137
+
138
+ // --------------------------------------------------
139
+ // 3. Ask user to approve in browser
140
+ // --------------------------------------------------
45
141
 
46
- // 3. Highlighted Box for User Action
47
142
  note(
48
- `${pc.bold("Pairing Code:")} ${pc.cyan(pc.bold(` ${userCode} `))}\n` +
49
- `${pc.bold("Verification URL:")} ${pc.underline(verificationUrl)}`,
50
- "Action Required"
143
+ `${pc.bold(
144
+ "Pairing Code:",
145
+ )} ${pc.cyan(
146
+ pc.bold(
147
+ ` ${userCode} `,
148
+ ),
149
+ )}\n` +
150
+ `${pc.bold(
151
+ "Verification URL:",
152
+ )} ${pc.underline(
153
+ verificationUrl,
154
+ )}`,
155
+ "Action Required",
51
156
  );
52
157
 
53
- // 4. Auto-launch Browser
54
158
  try {
55
- await open(verificationUrl);
159
+ await open(
160
+ verificationUrl,
161
+ );
56
162
  } catch {
57
- // Non-blocking fallback if browser launch fails on headless setups
163
+ // Browser launch failure
164
+ // is non-fatal.
58
165
  }
59
166
 
60
- // 5. Polling Loop
61
- s.start("Waiting for web authorization...");
167
+ // --------------------------------------------------
168
+ // 4. Poll for authorization
169
+ // --------------------------------------------------
62
170
 
63
- let token: string | null = null;
64
- const pollInterval = (interval || 5) * 1000;
171
+ s.start(
172
+ "Waiting for web authorization...",
173
+ );
65
174
 
66
- while (!token) {
67
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
175
+ const pollInterval =
176
+ (interval || 5) * 1000;
68
177
 
69
- const tokenResponse = await fetch(
70
- `${API_BASE_URL}/api/auth/device/token`,
71
- {
72
- method: "POST",
73
- headers: { "Content-Type": "application/json" },
74
- body: JSON.stringify({ deviceCode, machineId: hardwareId }),
75
- }
178
+ let token: string | null =
179
+ null;
180
+
181
+ while (true) {
182
+ await new Promise(
183
+ (resolve) =>
184
+ setTimeout(
185
+ resolve,
186
+ pollInterval,
187
+ ),
76
188
  );
77
189
 
78
- const tokenData = await tokenResponse.json();
190
+ const tokenResponse =
191
+ await fetch(
192
+ `${API_BASE_URL}/api/auth/device/token`,
193
+ {
194
+ method: "POST",
195
+
196
+ headers: {
197
+ "Content-Type":
198
+ "application/json",
199
+ },
200
+
201
+ body: JSON.stringify({
202
+ deviceCode,
203
+ machineId:
204
+ hardwareId,
205
+ }),
206
+ },
207
+ );
208
+
209
+ let tokenData: any;
210
+
211
+ try {
212
+ tokenData =
213
+ await tokenResponse.json();
214
+ } catch {
215
+ s.stop(
216
+ pc.red(
217
+ "Authorization server returned invalid JSON.",
218
+ ),
219
+ );
220
+
221
+ cancel(
222
+ "Unable to read authorization response.",
223
+ );
79
224
 
80
- if (tokenResponse.ok && tokenData.accessToken) {
81
- token = tokenData.accessToken;
82
- } else if (tokenData.error !== "authorization_pending") {
83
- s.stop(pc.red("Authorization failed or expired."));
84
- cancel(`Reason: ${tokenData.error || "Unknown authorization error"}`);
85
225
  process.exit(1);
86
226
  }
227
+
228
+ // --------------------------------------------------
229
+ // SUCCESS
230
+ // --------------------------------------------------
231
+
232
+ if (
233
+ tokenResponse.ok &&
234
+ typeof tokenData.accessToken ===
235
+ "string" &&
236
+ tokenData.accessToken.length > 0
237
+ ) {
238
+ token =
239
+ tokenData.accessToken;
240
+
241
+ // IMPORTANT:
242
+ // Stop the spinner immediately.
243
+ s.stop(
244
+ pc.green(
245
+ "Hardware signature linked & token verified!",
246
+ ),
247
+ );
248
+
249
+ // IMPORTANT:
250
+ // Leave the polling loop immediately.
251
+ break;
252
+ }
253
+
254
+ // --------------------------------------------------
255
+ // STILL WAITING
256
+ // --------------------------------------------------
257
+
258
+ if (
259
+ tokenData.error ===
260
+ "authorization_pending"
261
+ ) {
262
+ continue;
263
+ }
264
+
265
+ // --------------------------------------------------
266
+ // FAILED / EXPIRED
267
+ // --------------------------------------------------
268
+
269
+ s.stop(
270
+ pc.red(
271
+ "Authorization failed or expired.",
272
+ ),
273
+ );
274
+
275
+ cancel(
276
+ `Reason: ${
277
+ tokenData.error ||
278
+ "Unknown authorization error"
279
+ }`,
280
+ );
281
+
282
+ process.exit(1);
87
283
  }
88
284
 
89
- // 6. Save token to local wallet
90
- saveToken(token, hardwareId);
285
+ // --------------------------------------------------
286
+ // 5. Make absolutely sure token exists
287
+ // --------------------------------------------------
288
+
289
+ if (!token) {
290
+ throw new Error(
291
+ "Authorization completed without receiving an access token.",
292
+ );
293
+ }
91
294
 
92
- s.stop(pc.green("Hardware signature linked & token verified!"));
295
+ // --------------------------------------------------
296
+ // 6. Save CLI token + machine ID
297
+ // --------------------------------------------------
298
+
299
+ saveToken(
300
+ token,
301
+ hardwareId,
302
+ );
303
+
304
+ // --------------------------------------------------
305
+ // 7. Success
306
+ // --------------------------------------------------
93
307
 
94
- // 7. Clean Success Outro
95
308
  outro(
96
- `${pc.white("✔")} ${pc.bold("Terminal synced successfully")} ${pc.gray("Session token saved locally.")}`
309
+ `${pc.white(
310
+ "✔",
311
+ )} ${pc.bold(
312
+ "Terminal synced successfully",
313
+ )}\n` +
314
+ pc.gray(
315
+ "Secure device key and session token are ready.",
316
+ ),
97
317
  );
98
318
  } catch (error: any) {
99
- s.stop(pc.red("An error occurred during login."));
100
- cancel(pc.gray(error.message || "Unknown CLI Error"));
319
+ // Only stop the spinner if it is
320
+ // still running.
321
+ s.stop(
322
+ pc.red(
323
+ "An error occurred during login.",
324
+ ),
325
+ );
326
+
327
+ cancel(
328
+ pc.gray(
329
+ error?.message ||
330
+ "Unknown CLI Error",
331
+ ),
332
+ );
333
+
101
334
  process.exit(1);
102
335
  }
103
336
  });
@@ -2,28 +2,57 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { deleteToken, getToken } from "../utils/config.js";
5
+ import machineIdPkg from "node-machine-id";
6
+ const { machineIdSync } = machineIdPkg;
7
+ const API_BASE_URL = process.env.DS01_API_URL || "https://ds-01.vercel.app";
5
8
 
6
9
  export const logout = new Command()
7
10
  .name("logout")
8
- .description("Log out of your DS01 account and clear local credentials")
11
+ .description("Log out of your DS01 account and revoke the CLI session")
9
12
  .action(async () => {
10
13
  const token = getToken();
14
+ const machineId = machineIdSync();
11
15
 
12
- if (!token) {
16
+ if (!token || !machineId) {
13
17
  console.log(chalk.yellow("You are already logged out."));
14
18
  return;
15
19
  }
16
20
 
17
- const spinner = ora("Logging out...").start();
21
+ const spinner = ora("Revoking CLI session...").start();
18
22
 
19
23
  try {
20
- // Throw away the digital wallet / token
24
+ const response = await fetch(`${API_BASE_URL}/api/auth/cli/logout`, {
25
+ method: "POST",
26
+ headers: {
27
+ Authorization: `Bearer ${token}`,
28
+ "X-Machine-ID": machineId,
29
+ },
30
+ });
31
+
32
+ // NEW: Do not silently delete local credentials if the server
33
+ // failed to revoke the session.
34
+ if (!response.ok) {
35
+ const data = await response.json().catch(() => null);
36
+
37
+ throw new Error(
38
+ data?.error || `Logout failed (HTTP ${response.status})`,
39
+ );
40
+ }
41
+
42
+ // NEW: Delete local credentials only after the server confirms
43
+ // that the CLI session has been revoked.
44
+ console.log("✅ Server confirmed CLI session was revoked.");
21
45
  deleteToken();
22
-
46
+ console.log("✅ Local credentials deleted.");
23
47
  spinner.succeed(chalk.green("Successfully logged out."));
24
- console.log(chalk.gray("Your local credentials have been cleared."));
25
- } catch (error) {
26
- spinner.fail(chalk.red("Failed to log out cleanly."));
27
- console.error(error);
48
+ console.log(
49
+ chalk.gray(
50
+ "Your CLI session has been revoked and local credentials have been cleared.",
51
+ ),
52
+ );
53
+ } catch (error: any) {
54
+ spinner.fail(chalk.red("Failed to log out."));
55
+
56
+ console.error(chalk.red(error.message || "Unknown logout error."));
28
57
  }
29
- });
58
+ });
package/src/index.ts CHANGED
@@ -22,4 +22,6 @@ program.addCommand(init);
22
22
  program.addCommand(add); // <-- 2. Register the add command!
23
23
 
24
24
  // Parse the arguments from the terminal
25
+ // It is the point where Commander reads the user's terminal input and decides
26
+ // which registered command's .action() function should execute.
25
27
  program.parse(process.argv);
@@ -0,0 +1,206 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ let keyTag = "com.ds01.cli.device-key"
5
+
6
+ func fail(_ message: String) -> Never {
7
+ fputs(message + "\n", stderr)
8
+ exit(1)
9
+ }
10
+
11
+ func getAccessControl() -> SecAccessControl {
12
+ var error: Unmanaged<CFError>?
13
+
14
+ guard let access = SecAccessControlCreateWithFlags(
15
+ nil,
16
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
17
+ .privateKeyUsage,
18
+ &error
19
+ ) else {
20
+ if let error {
21
+ fail(error.takeRetainedValue().localizedDescription)
22
+ }
23
+
24
+ fail("Unable to create Secure Enclave access control.")
25
+ }
26
+
27
+ return access
28
+ }
29
+
30
+ func findPrivateKey() -> SecKey? {
31
+ let query: [String: Any] = [
32
+ kSecClass as String: kSecClassKey,
33
+ kSecAttrApplicationTag as String: keyTag.data(using: .utf8)!,
34
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
35
+ kSecReturnRef as String: true
36
+ ]
37
+
38
+ var result: CFTypeRef?
39
+
40
+ let status = SecItemCopyMatching(
41
+ query as CFDictionary,
42
+ &result
43
+ )
44
+
45
+ guard status == errSecSuccess else {
46
+ return nil
47
+ }
48
+
49
+ return (result as! SecKey)
50
+ }
51
+
52
+ func createKey() {
53
+ if findPrivateKey() != nil {
54
+ print("EXISTS")
55
+ return
56
+ }
57
+
58
+ let access = getAccessControl()
59
+
60
+ let attributes: [String: Any] = [
61
+ kSecAttrKeyType as String:
62
+ kSecAttrKeyTypeECSECPrimeRandom,
63
+
64
+ kSecAttrKeySizeInBits as String:
65
+ 256,
66
+
67
+ kSecAttrTokenID as String:
68
+ kSecAttrTokenIDSecureEnclave,
69
+
70
+ kSecPrivateKeyAttrs as String: [
71
+ kSecAttrIsPermanent as String:
72
+ true,
73
+
74
+ kSecAttrApplicationTag as String:
75
+ keyTag.data(using: .utf8)!,
76
+
77
+ kSecAttrAccessControl as String:
78
+ access
79
+ ]
80
+ ]
81
+
82
+ var error: Unmanaged<CFError>?
83
+
84
+ guard SecKeyCreateRandomKey(
85
+ attributes as CFDictionary,
86
+ &error
87
+ ) != nil else {
88
+ if let error {
89
+ fail(error.takeRetainedValue().localizedDescription)
90
+ }
91
+
92
+ fail("Unable to create Secure Enclave key.")
93
+ }
94
+
95
+ print("CREATED")
96
+ }
97
+
98
+ func publicKey() {
99
+ guard let privateKey = findPrivateKey() else {
100
+ fail("DS01 Secure Enclave key not found.")
101
+ }
102
+
103
+ guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
104
+ fail("Unable to obtain public key.")
105
+ }
106
+
107
+ var error: Unmanaged<CFError>?
108
+
109
+ guard let data = SecKeyCopyExternalRepresentation(
110
+ publicKey,
111
+ &error
112
+ ) else {
113
+ if let error {
114
+ fail(error.takeRetainedValue().localizedDescription)
115
+ }
116
+
117
+ fail("Unable to export public key.")
118
+ }
119
+
120
+ let base64 = (data as Data).base64EncodedString()
121
+
122
+ print(base64)
123
+ }
124
+
125
+ func sign(_ base64Data: String) {
126
+ guard let privateKey = findPrivateKey() else {
127
+ fail("DS01 Secure Enclave key not found.")
128
+ }
129
+
130
+ guard let data = Data(base64Encoded: base64Data) else {
131
+ fail("Invalid input data.")
132
+ }
133
+
134
+ let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
135
+
136
+ guard SecKeyIsAlgorithmSupported(
137
+ privateKey,
138
+ .sign,
139
+ algorithm
140
+ ) else {
141
+ fail("Secure Enclave key does not support signing.")
142
+ }
143
+
144
+ var error: Unmanaged<CFError>?
145
+
146
+ guard let signature = SecKeyCreateSignature(
147
+ privateKey,
148
+ algorithm,
149
+ data as CFData,
150
+ &error
151
+ ) else {
152
+ if let error {
153
+ fail(error.takeRetainedValue().localizedDescription)
154
+ }
155
+
156
+ fail("Secure Enclave signing failed.")
157
+ }
158
+
159
+ print((signature as Data).base64EncodedString())
160
+ }
161
+
162
+ func deleteKey() {
163
+ let query: [String: Any] = [
164
+ kSecClass as String: kSecClassKey,
165
+ kSecAttrApplicationTag as String:
166
+ keyTag.data(using: .utf8)!,
167
+ kSecAttrKeyType as String:
168
+ kSecAttrKeyTypeECSECPrimeRandom
169
+ ]
170
+
171
+ let status = SecItemDelete(
172
+ query as CFDictionary
173
+ )
174
+
175
+ if status != errSecSuccess &&
176
+ status != errSecItemNotFound {
177
+ fail("Unable to delete DS01 Secure Enclave key.")
178
+ }
179
+
180
+ print("DELETED")
181
+ }
182
+
183
+ guard CommandLine.arguments.count >= 2 else {
184
+ fail("Missing operation.")
185
+ }
186
+
187
+ switch CommandLine.arguments[1] {
188
+ case "create":
189
+ createKey()
190
+
191
+ case "public":
192
+ publicKey()
193
+
194
+ case "sign":
195
+ guard CommandLine.arguments.count >= 3 else {
196
+ fail("Missing data to sign.")
197
+ }
198
+
199
+ sign(CommandLine.arguments[2])
200
+
201
+ case "delete":
202
+ deleteKey()
203
+
204
+ default:
205
+ fail("Unknown operation.")
206
+ }
@@ -0,0 +1,20 @@
1
+ import {
2
+ createDeviceKey,
3
+ getDevicePublicKey,
4
+ signWithDeviceKey,
5
+ } from "./utils/secureKeyStore.js";
6
+
7
+ await createDeviceKey();
8
+
9
+ const publicKey = await getDevicePublicKey();
10
+
11
+ console.log("Public key:", publicKey);
12
+
13
+ const message = new TextEncoder().encode("hello DS01");
14
+
15
+ const signature = await signWithDeviceKey(message);
16
+
17
+ console.log(
18
+ "Signature:",
19
+ Buffer.from(signature).toString("base64"),
20
+ );