icoa-cli 1.3.3 → 1.4.0

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.4.0')} ${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,8 @@
1
1
  import { createInterface } from 'node:readline';
2
+ import { spawn } from 'node:child_process';
2
3
  import chalk from 'chalk';
3
4
  import { isConnected, getConfig } from './lib/config.js';
4
- import { isActivated, activateToken, isFreeCommand, recordExit, recordResume } from './lib/access.js';
5
+ import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, recordResume } from './lib/access.js';
5
6
  const INTERCEPT = '__REPL_NO_EXIT__';
6
7
  export function startRepl(program, resumeMode) {
7
8
  const config = getConfig();
@@ -21,7 +22,14 @@ export function startRepl(program, resumeMode) {
21
22
  }
22
23
  console.log();
23
24
  }
24
- if (connected) {
25
+ // Device mismatch check
26
+ if (activated && !isDeviceMatch()) {
27
+ console.log(chalk.red(' Token was activated on a different device. Access denied.'));
28
+ console.log(chalk.gray(' Contact organizer for assistance.'));
29
+ console.log();
30
+ // Fall through to restricted mode
31
+ }
32
+ else if (connected) {
25
33
  console.log(chalk.gray(' Connected to: ') + chalk.white(config.ctfdUrl));
26
34
  console.log(chalk.gray(' User: ') + chalk.white(config.userName));
27
35
  }
@@ -78,8 +86,12 @@ export function startRepl(program, resumeMode) {
78
86
  // Activate token
79
87
  if (input.startsWith('activate ')) {
80
88
  const token = input.slice(9).trim();
81
- if (activateToken(token)) {
82
- console.log(chalk.green(' Access granted! All commands unlocked.'));
89
+ const result = activateToken(token);
90
+ if (result === 'ok') {
91
+ console.log(chalk.green(' Access granted! Token bound to this device.'));
92
+ }
93
+ else if (result === 'already_bound') {
94
+ console.log(chalk.red(' Token already activated on another device.'));
83
95
  }
84
96
  else {
85
97
  console.log(chalk.red(' Invalid token.'));
@@ -94,9 +106,9 @@ export function startRepl(program, resumeMode) {
94
106
  rl.prompt();
95
107
  return;
96
108
  }
97
- // Token check — only ref allowed without activation
109
+ // Token check — only ref allowed without activation or device mismatch
98
110
  const cmd = input.split(/\s+/)[0].toLowerCase();
99
- if (!isActivated() && !isFreeCommand(cmd)) {
111
+ if ((!isActivated() || !isDeviceMatch()) && !isFreeCommand(cmd)) {
100
112
  console.log(chalk.yellow(' Restricted mode. ') + chalk.gray('Enter your access token:'));
101
113
  console.log(chalk.white(' activate <token>'));
102
114
  console.log();
@@ -105,6 +117,27 @@ export function startRepl(program, resumeMode) {
105
117
  rl.prompt();
106
118
  return;
107
119
  }
120
+ // Check if it's a known ICOA command or a system command
121
+ const knownCommands = [
122
+ 'join', 'activate', 'challenges', 'ch', 'open', 'submit', 'flag',
123
+ 'scoreboard', 'sb', 'status', 'time', 'hint', 'hint-b', 'hint-c',
124
+ 'hint-budget', 'ref', 'shell', 'files', 'connect', 'note', 'log',
125
+ 'lang', 'setup', 'model', 'ctf',
126
+ ];
127
+ if (!knownCommands.includes(cmd)) {
128
+ // Pass through to system shell
129
+ processing = true;
130
+ try {
131
+ await runSystemCommand(input, rl);
132
+ }
133
+ catch {
134
+ console.log(chalk.yellow(` Command failed: ${cmd}`));
135
+ }
136
+ processing = false;
137
+ console.log();
138
+ rl.prompt();
139
+ return;
140
+ }
108
141
  processing = true;
109
142
  const args = mapCommand(input);
110
143
  process.exit = (() => {
@@ -137,6 +170,24 @@ export function startRepl(program, resumeMode) {
137
170
  realExit(0);
138
171
  });
139
172
  }
173
+ function runSystemCommand(input, rl) {
174
+ return new Promise((resolve) => {
175
+ // Pause readline so the child process gets full terminal control
176
+ rl.pause();
177
+ const child = spawn(input, {
178
+ shell: true,
179
+ stdio: 'inherit',
180
+ });
181
+ child.on('close', () => {
182
+ rl.resume();
183
+ resolve();
184
+ });
185
+ child.on('error', () => {
186
+ rl.resume();
187
+ resolve();
188
+ });
189
+ });
190
+ }
140
191
  function mapCommand(input) {
141
192
  const parts = input.split(/\s+/);
142
193
  const cmd = parts[0].toLowerCase();
@@ -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.4.0",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {