icoa-cli 1.3.3 → 1.3.4

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/index.js CHANGED
@@ -36,7 +36,7 @@ ${B} ${B}
36
36
  ${B} ${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')} ${B}
37
37
  ${B} ${chalk.cyan.underline('https://icoa2026.au')} ${B}
38
38
  ${B} ${B}
39
- ${B} ${chalk.gray('CLI-Native Competition Terminal v1.3.3')} ${B}
39
+ ${B} ${chalk.gray('CLI-Native Competition Terminal v1.3.4')} ${B}
40
40
  ${B} ${B}
41
41
  ${chalk.cyan('╚══════════════════════════════════════════════════════════╝')}
42
42
  `;
@@ -1,6 +1,8 @@
1
1
  export declare function validateToken(token: string): boolean;
2
2
  export declare function isActivated(): boolean;
3
- export declare function activateToken(token: string): boolean;
3
+ export type ActivateResult = 'ok' | 'invalid' | 'already_bound';
4
+ export declare function activateToken(token: string): ActivateResult;
5
+ export declare function isDeviceMatch(): boolean;
4
6
  export declare function isFreeCommand(cmd: string): boolean;
5
7
  interface SessionState {
6
8
  startedAt: string;
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { execSync } from 'node:child_process';
3
+ import { hostname, platform, arch, networkInterfaces } from 'node:os';
2
4
  import { getConfig, saveConfig, getIcoaDir } from './config.js';
3
5
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
6
  import { join } from 'node:path';
@@ -117,12 +119,76 @@ export function isActivated() {
117
119
  const config = getConfig();
118
120
  return !!(config.accessToken && TOKEN_HASHES.has(hashToken(config.accessToken)));
119
121
  }
122
+ function getDeviceFingerprint() {
123
+ const parts = [hostname(), platform(), arch()];
124
+ // Add hardware UUID where possible
125
+ try {
126
+ if (platform() === 'darwin') {
127
+ const out = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' });
128
+ const match = out.match(/"([A-F0-9-]+)"/);
129
+ if (match)
130
+ parts.push(match[1]);
131
+ }
132
+ else if (platform() === 'linux') {
133
+ if (existsSync('/etc/machine-id')) {
134
+ parts.push(readFileSync('/etc/machine-id', 'utf-8').trim());
135
+ }
136
+ }
137
+ else if (platform() === 'win32') {
138
+ const out = execSync('wmic csproduct get uuid', { encoding: 'utf-8' });
139
+ const lines = out.trim().split('\n');
140
+ if (lines.length > 1)
141
+ parts.push(lines[1].trim());
142
+ }
143
+ }
144
+ catch {
145
+ // Fallback: use first non-internal MAC address
146
+ const nets = networkInterfaces();
147
+ for (const ifaces of Object.values(nets)) {
148
+ if (!ifaces)
149
+ continue;
150
+ for (const iface of ifaces) {
151
+ if (!iface.internal && iface.mac !== '00:00:00:00:00:00') {
152
+ parts.push(iface.mac);
153
+ break;
154
+ }
155
+ }
156
+ if (parts.length > 3)
157
+ break;
158
+ }
159
+ }
160
+ return createHash('sha256').update(parts.join('|')).digest('hex');
161
+ }
120
162
  export function activateToken(token) {
121
- if (validateToken(token)) {
122
- saveConfig({ accessToken: token.trim().toUpperCase() });
123
- return true;
163
+ if (!validateToken(token))
164
+ return 'invalid';
165
+ const fingerprint = getDeviceFingerprint();
166
+ const bindingFile = join(getIcoaDir(), 'token-bindings.json');
167
+ // Load existing bindings
168
+ let bindings = {};
169
+ if (existsSync(bindingFile)) {
170
+ try {
171
+ bindings = JSON.parse(readFileSync(bindingFile, 'utf-8'));
172
+ }
173
+ catch { /* ignore */ }
174
+ }
175
+ const tokenHash = hashToken(token);
176
+ const existingDevice = bindings[tokenHash];
177
+ if (existingDevice && existingDevice !== fingerprint) {
178
+ // Token already bound to a different device
179
+ return 'already_bound';
124
180
  }
125
- return false;
181
+ // Bind token to this device
182
+ bindings[tokenHash] = fingerprint;
183
+ writeFileSync(bindingFile, JSON.stringify(bindings, null, 2));
184
+ saveConfig({ accessToken: token.trim().toUpperCase(), deviceFingerprint: fingerprint });
185
+ return 'ok';
186
+ }
187
+ export function isDeviceMatch() {
188
+ const config = getConfig();
189
+ if (!config.deviceFingerprint)
190
+ return true; // Legacy: no fingerprint yet
191
+ return config.deviceFingerprint === getDeviceFingerprint();
126
192
  }
127
193
  export function isFreeCommand(cmd) {
128
194
  return FREE_COMMANDS.has(cmd.toLowerCase());
package/dist/repl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import chalk from 'chalk';
3
3
  import { isConnected, getConfig } from './lib/config.js';
4
- import { isActivated, activateToken, isFreeCommand, recordExit, recordResume } from './lib/access.js';
4
+ import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, recordResume } from './lib/access.js';
5
5
  const INTERCEPT = '__REPL_NO_EXIT__';
6
6
  export function startRepl(program, resumeMode) {
7
7
  const config = getConfig();
@@ -21,7 +21,14 @@ export function startRepl(program, resumeMode) {
21
21
  }
22
22
  console.log();
23
23
  }
24
- if (connected) {
24
+ // Device mismatch check
25
+ if (activated && !isDeviceMatch()) {
26
+ console.log(chalk.red(' Token was activated on a different device. Access denied.'));
27
+ console.log(chalk.gray(' Contact organizer for assistance.'));
28
+ console.log();
29
+ // Fall through to restricted mode
30
+ }
31
+ else if (connected) {
25
32
  console.log(chalk.gray(' Connected to: ') + chalk.white(config.ctfdUrl));
26
33
  console.log(chalk.gray(' User: ') + chalk.white(config.userName));
27
34
  }
@@ -78,8 +85,12 @@ export function startRepl(program, resumeMode) {
78
85
  // Activate token
79
86
  if (input.startsWith('activate ')) {
80
87
  const token = input.slice(9).trim();
81
- if (activateToken(token)) {
82
- console.log(chalk.green(' Access granted! All commands unlocked.'));
88
+ const result = activateToken(token);
89
+ if (result === 'ok') {
90
+ console.log(chalk.green(' Access granted! Token bound to this device.'));
91
+ }
92
+ else if (result === 'already_bound') {
93
+ console.log(chalk.red(' Token already activated on another device.'));
83
94
  }
84
95
  else {
85
96
  console.log(chalk.red(' Invalid token.'));
@@ -94,9 +105,9 @@ export function startRepl(program, resumeMode) {
94
105
  rl.prompt();
95
106
  return;
96
107
  }
97
- // Token check — only ref allowed without activation
108
+ // Token check — only ref allowed without activation or device mismatch
98
109
  const cmd = input.split(/\s+/)[0].toLowerCase();
99
- if (!isActivated() && !isFreeCommand(cmd)) {
110
+ if ((!isActivated() || !isDeviceMatch()) && !isFreeCommand(cmd)) {
100
111
  console.log(chalk.yellow(' Restricted mode. ') + chalk.gray('Enter your access token:'));
101
112
  console.log(chalk.white(' activate <token>'));
102
113
  console.log();
@@ -105,6 +116,19 @@ export function startRepl(program, resumeMode) {
105
116
  rl.prompt();
106
117
  return;
107
118
  }
119
+ // Check if it's a known command before passing to Commander
120
+ const knownCommands = [
121
+ 'join', 'activate', 'challenges', 'ch', 'open', 'submit', 'flag',
122
+ 'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
123
+ 'hint-budget', 'ref', 'shell', 'files', 'connect', 'note', 'log',
124
+ 'lang', 'setup', 'model', 'ctf',
125
+ ];
126
+ if (!knownCommands.includes(cmd)) {
127
+ console.log(chalk.yellow(` Unknown command: ${cmd}. Type 'help' for commands.`));
128
+ console.log();
129
+ rl.prompt();
130
+ return;
131
+ }
108
132
  processing = true;
109
133
  const args = mapCommand(input);
110
134
  process.exit = (() => {
@@ -99,6 +99,7 @@ export interface IcoaConfig {
99
99
  geminiApiKey: string;
100
100
  geminiModel: string;
101
101
  accessToken: string;
102
+ deviceFingerprint: string;
102
103
  }
103
104
  export type CompetitionState = 'pre_competition' | 'demo' | 'live' | 'finished' | 'unknown';
104
105
  export type HintLevel = 'A' | 'B' | 'C';
@@ -27,5 +27,6 @@ export const DEFAULT_CONFIG = {
27
27
  geminiApiKey: '',
28
28
  geminiModel: 'gemini-2.5-flash',
29
29
  accessToken: '',
30
+ deviceFingerprint: '',
30
31
  };
31
32
  export const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko', 'es'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {