ds-01 1.0.4 → 1.0.5

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.
@@ -3,64 +3,106 @@ import { intro, outro, spinner, note, cancel } from "@clack/prompts";
3
3
  import pc from "picocolors";
4
4
  import open from "open";
5
5
  import machineIdPkg from "node-machine-id";
6
+
6
7
  import { saveToken } from "../utils/config.js";
8
+ import {
9
+ createDeviceKey,
10
+ getDevicePublicKey,
11
+ } from "../utils/secureKeyStore.js";
7
12
 
8
13
  const API_BASE_URL = process.env.DS01_API_URL || "https://ds-01.vercel.app";
14
+
9
15
  const { machineIdSync } = machineIdPkg;
10
16
 
11
17
  export const login = new Command()
12
18
  .name("login")
13
19
  .description("Authenticate your terminal with DS01 via browser")
14
20
  .action(async () => {
15
- console.log(); // Spacing for breathing room
21
+ console.log();
16
22
 
17
- // 1. Header banner
18
23
  intro(
19
- `${pc.bgWhite(pc.black(pc.bold(" DS01 ")))} ${pc.gray("v1.0.0 — Terminal Authentication")}`
24
+ `${pc.bgWhite(pc.black(pc.bold(" DS01 ")))} ${pc.gray(
25
+ "v1.0.0 — Terminal Authentication",
26
+ )}`,
20
27
  );
21
28
 
22
29
  const s = spinner();
23
30
 
24
31
  try {
25
- // 2. Hardware ID Generation & Device Code Request
26
- s.start("Generating hardware fingerprint & requesting pairing code...");
32
+ // --------------------------------------------------
33
+ // 1. Create / load the device's secure key
34
+ // --------------------------------------------------
35
+ s.start("Preparing secure device identity...");
36
+
37
+ await createDeviceKey();
38
+
39
+ // Only the PUBLIC certificate/key leaves the machine.
40
+ const publicKey = await getDevicePublicKey();
41
+
42
+ // Existing machine fingerprint.
27
43
  const hardwareId = machineIdSync();
28
44
 
45
+ // --------------------------------------------------
46
+ // 2. Start device authorization
47
+ // --------------------------------------------------
48
+ s.start("Requesting pairing code...");
49
+
29
50
  const initResponse = await fetch(`${API_BASE_URL}/api/auth/device/code`, {
30
51
  method: "POST",
31
- headers: { "Content-Type": "application/json" },
32
- body: JSON.stringify({ machineId: hardwareId }),
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ },
55
+ body: JSON.stringify({
56
+ machineId: hardwareId,
57
+ publicKey,
58
+ publicKeyType:
59
+ process.platform === "win32" ? "windows-rsa" : "macos-ec",
60
+ }),
33
61
  });
34
62
 
35
63
  if (!initResponse.ok) {
36
64
  s.stop(pc.red("Failed to reach DS01 authorization server."));
65
+
37
66
  cancel(`Server responded with HTTP ${initResponse.status}`);
67
+
38
68
  process.exit(1);
39
69
  }
40
70
 
41
71
  const { deviceCode, userCode, verificationUrl, interval } =
42
72
  await initResponse.json();
43
73
 
44
- s.stop(pc.green("Device authorization code generated."));
74
+ if (!deviceCode || !userCode || !verificationUrl) {
75
+ throw new Error(
76
+ "Authorization server returned an invalid device response.",
77
+ );
78
+ }
45
79
 
46
- // 3. Highlighted Box for User Action
80
+ s.stop(pc.green("Secure device identity registered."));
81
+
82
+ // --------------------------------------------------
83
+ // 3. Ask user to approve in browser
84
+ // --------------------------------------------------
47
85
  note(
48
- `${pc.bold("Pairing Code:")} ${pc.cyan(pc.bold(` ${userCode} `))}\n` +
86
+ `${pc.bold("Pairing Code:")} ${pc.cyan(
87
+ pc.bold(` ${userCode} `),
88
+ )}\n` +
49
89
  `${pc.bold("Verification URL:")} ${pc.underline(verificationUrl)}`,
50
- "Action Required"
90
+ "Action Required",
51
91
  );
52
92
 
53
- // 4. Auto-launch Browser
54
93
  try {
55
94
  await open(verificationUrl);
56
95
  } catch {
57
- // Non-blocking fallback if browser launch fails on headless setups
96
+ // Browser launch failure is non-fatal.
58
97
  }
59
98
 
60
- // 5. Polling Loop
99
+ // --------------------------------------------------
100
+ // 4. Poll for authorization
101
+ // --------------------------------------------------
61
102
  s.start("Waiting for web authorization...");
62
103
 
63
104
  let token: string | null = null;
105
+
64
106
  const pollInterval = (interval || 5) * 1000;
65
107
 
66
108
  while (!token) {
@@ -70,9 +112,14 @@ export const login = new Command()
70
112
  `${API_BASE_URL}/api/auth/device/token`,
71
113
  {
72
114
  method: "POST",
73
- headers: { "Content-Type": "application/json" },
74
- body: JSON.stringify({ deviceCode, machineId: hardwareId }),
75
- }
115
+ headers: {
116
+ "Content-Type": "application/json",
117
+ },
118
+ body: JSON.stringify({
119
+ deviceCode,
120
+ machineId: hardwareId,
121
+ }),
122
+ },
76
123
  );
77
124
 
78
125
  const tokenData = await tokenResponse.json();
@@ -81,23 +128,29 @@ export const login = new Command()
81
128
  token = tokenData.accessToken;
82
129
  } else if (tokenData.error !== "authorization_pending") {
83
130
  s.stop(pc.red("Authorization failed or expired."));
131
+
84
132
  cancel(`Reason: ${tokenData.error || "Unknown authorization error"}`);
133
+
85
134
  process.exit(1);
86
135
  }
87
136
  }
88
137
 
89
- // 6. Save token to local wallet
138
+ // --------------------------------------------------
139
+ // 5. Save CLI token + machine ID
140
+ // --------------------------------------------------
90
141
  saveToken(token, hardwareId);
91
142
 
92
143
  s.stop(pc.green("Hardware signature linked & token verified!"));
93
144
 
94
- // 7. Clean Success Outro
95
145
  outro(
96
- `${pc.white("✔")} ${pc.bold("Terminal synced successfully")} ${pc.gray("Session token saved locally.")}`
146
+ `${pc.white("✔")} ${pc.bold("Terminal synced successfully")}\n` +
147
+ pc.gray("Secure device key and session token are ready."),
97
148
  );
98
149
  } catch (error: any) {
99
150
  s.stop(pc.red("An error occurred during login."));
100
- cancel(pc.gray(error.message || "Unknown CLI Error"));
151
+
152
+ cancel(pc.gray(error?.message || "Unknown CLI Error"));
153
+
101
154
  process.exit(1);
102
155
  }
103
- });
156
+ });
@@ -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
+ );
@@ -35,18 +35,7 @@ export function getToken(): string | null {
35
35
  return null;
36
36
  }
37
37
 
38
- export function getMachineId(): string | null {
39
- if (fs.existsSync(credentialsPath)) {
40
- try {
41
- const data = fs.readFileSync(credentialsPath, "utf-8");
42
- const parsed = JSON.parse(data);
43
- return parsed.machineId || null;
44
- } catch {
45
- return null;
46
- }
47
- }
48
- return null;
49
- }
38
+
50
39
 
51
40
  export function deleteToken() {
52
41
  if (fs.existsSync(credentialsPath)) {
@@ -0,0 +1,19 @@
1
+ import { webcrypto } from "node:crypto";
2
+
3
+ const { subtle } = webcrypto;
4
+
5
+ export async function generateDeviceKeyPair() {
6
+ // Generate an asymmetric key pair:
7
+ // - publicKey → can be shared with the server
8
+ // - privateKey → stays on this device
9
+ const keyPair = await subtle.generateKey(
10
+ {
11
+ name: "ECDSA",
12
+ namedCurve: "P-256",
13
+ },
14
+ false, // 🔐 Private key is NON-EXPORTABLE
15
+ ["sign", "verify"],
16
+ );
17
+
18
+ return keyPair;
19
+ }