@athsra/cli 1.0.4 → 1.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.
Files changed (45) hide show
  1. package/README.md +34 -10
  2. package/package.json +3 -3
  3. package/src/commands/delete.ts +16 -13
  4. package/src/commands/get.ts +8 -5
  5. package/src/commands/handoff.ts +13 -3
  6. package/src/commands/login.ts +208 -61
  7. package/src/commands/logout.ts +3 -2
  8. package/src/commands/ls.ts +32 -10
  9. package/src/commands/manifest.ts +2 -2
  10. package/src/commands/mcp.ts +259 -7
  11. package/src/commands/migrate-envelopes.ts +55 -3
  12. package/src/commands/purge.ts +13 -10
  13. package/src/commands/restore.ts +10 -6
  14. package/src/commands/rollback.ts +12 -9
  15. package/src/commands/rotate-master.ts +13 -13
  16. package/src/commands/run.ts +6 -24
  17. package/src/commands/service-token.ts +15 -31
  18. package/src/commands/set.ts +7 -6
  19. package/src/commands/unset.ts +11 -8
  20. package/src/commands/versions.ts +7 -5
  21. package/src/index.ts +12 -8
  22. package/src/lib/auth-context.ts +77 -13
  23. package/src/lib/auth-proof.ts +26 -0
  24. package/src/lib/auto-project.ts +58 -14
  25. package/src/lib/client.ts +112 -19
  26. package/src/lib/config.ts +2 -0
  27. package/src/lib/device-login.ts +157 -0
  28. package/src/lib/env-format.ts +1 -1
  29. package/src/lib/envelope.ts +105 -15
  30. package/src/lib/identity-key.ts +21 -0
  31. package/src/lib/keyring.ts +25 -0
  32. package/src/lib/mcp-register.ts +223 -0
  33. package/src/lib/mcp-tools/admin.ts +267 -0
  34. package/src/lib/mcp-tools/args.ts +26 -0
  35. package/src/lib/mcp-tools/confirm.ts +21 -0
  36. package/src/lib/mcp-tools/defs.ts +388 -3
  37. package/src/lib/mcp-tools/login.ts +156 -0
  38. package/src/lib/mcp-tools/mask.ts +41 -0
  39. package/src/lib/mcp-tools/read.ts +115 -1
  40. package/src/lib/mcp-tools/result.ts +5 -5
  41. package/src/lib/mcp-tools/run.ts +101 -0
  42. package/src/lib/mcp-tools/write.ts +84 -5
  43. package/src/lib/oidc-flow.ts +43 -1
  44. package/src/lib/org-rewrap.ts +9 -3
  45. package/src/lib/service-tokens.ts +62 -0
package/README.md CHANGED
@@ -21,6 +21,8 @@ curl -fsSL https://bun.sh/install | bash
21
21
 
22
22
  # CLI 설치
23
23
  bun add -g @athsra/cli
24
+ # 또는
25
+ npm i -g @athsra/cli
24
26
  ```
25
27
 
26
28
  > Bun runtime 강제 — TypeScript 직접 실행 + native crypto 성능. Node 호환은 후속 버전.
@@ -39,16 +41,16 @@ cd ~/code/athsra && bun install
39
41
  bash scripts/setup-worker.sh # R2 + KV + GLOBAL_SALT + deploy 멱등
40
42
  ```
41
43
 
42
- ### 2. 첫 머신 등록 (PROOF bootstrap)
44
+ ### 2. 로그인
43
45
 
44
46
  ```bash
45
- # (권장) BIP-39 12-word recovery phrase 생성
46
- athsra new-phrase # 12 단어 출력 → 종이에 정확히 적기
47
+ # 일반 머신/AI agent: 브라우저 device flow.
48
+ ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login
49
+ # URL + 코드 + 디바이스 지문 출력 → 브라우저에서 승인
47
50
 
48
- athsra login
49
- # Worker URL: https://athsra-worker.<account>.workers.dev
50
- # Master password: <위 phrase 또는 자유 phrase>
51
- # Paper backup confirm: yes
51
+ # founding owner / 복구용 full-access 머신:
52
+ athsra new-phrase # 12 단어 출력 → 종이에 정확히 적기
53
+ ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login --password
52
54
  ```
53
55
 
54
56
  ### 3. 평소 사용
@@ -99,11 +101,32 @@ ATHSRA_MASTER_PW='<기존과 동일>' \
99
101
  athsra handoff --accept
100
102
  ```
101
103
 
104
+ ## MCP — AI agent 표면 (Claude Code / Cursor / VS Code)
105
+
106
+ npm 에서 이 패키지를 발견한 AI 에이전트라면: athsra 는 **MCP 로 전 기능을 자율 조작**하도록 설계됐다 — 평문 시크릿 값은 단 한 번도 에이전트에게 노출되지 않는다.
107
+
108
+ ```sh
109
+ athsra mcp install --write --apply # .mcp.json 등록 (dry-run 기본 — --apply 가 실제 변경)
110
+ # --client cursor|vscode --scope user --admin 지원
111
+ ```
112
+
113
+ 1. **인증은 채팅 안에서** — `athsra_whoami` → 미인증이면 `athsra_login_start` 호출 (터미널 불필요). 반환된 URL+코드+디바이스 지문을 사용자에게 전달 → 사용자가 브라우저에서 가입/로그인 + master password 로 승인 (**master pw 는 브라우저 밖으로 안 나감**) → `athsra_login_status` poll (retry_after 준수) → approved.
114
+ 2. **3-tier 도구 27종**:
115
+ - read 12 (항상): `whoami` · `login_start/status` · `list_projects` · `get_project_keys`(키 이름만) · `versions` · `audit` · `list_orgs` · `org_info` · `doctor` · `show/validate_manifest`
116
+ - write 8 (`ATHSRA_MCP_WRITE=1`): `set_secret` · `unset_secret` · `bulk_set` · `rollback` · `delete_project`(soft) · `restore_project` · `manifest_init/modify`
117
+ - admin 7 (`ATHSRA_MCP_ADMIN=1`): `org_invite` · `org_remove_member` · `project_share/unshare` · `service_token_create/revoke` · `purge` — destructive 는 `confirm` 정확일치 필수
118
+ 3. **값 무노출 원칙**: 값 소비는 MCP 밖 `athsra run <p> -- <cmd>` 주입으로만. `athsra get`/`run` 은 의도적으로 MCP 도구가 아니다. 유일 예외 = `athsra_service_token_create` 의 1회성 `ats_*` 반환 (즉시 secret store 보관).
119
+
120
+ 전체 가이드: [athsra.com/ai](https://athsra.com/ai) · [athsra.com/llms.txt](https://athsra.com/llms.txt)
121
+
102
122
  ## 전체 명령
103
123
 
104
124
  | 명령 | 동작 |
105
125
  |---|---|
106
- | `athsra login` | 등록 (PROOF bootstrap) |
126
+ | `athsra login` | 브라우저 완결 identity 로그인 기본 (`--password` = founding master pw 등록, `--sso`, `--device`) |
127
+ | `athsra mcp` | MCP stdio server (AI agent 표면 — 위 섹션) |
128
+ | `athsra mcp install` | MCP client 설정 등록/갱신 (claude/cursor/vscode, dry-run 기본) |
129
+ | `athsra service-token create/list/revoke` | scoped headless 토큰 (NAS/CI — master pw 없이 복호) |
107
130
  | `athsra set <p> KEY=val [...]` | secret 추가/수정 (`--from-file` / `--stdin` 지원) |
108
131
  | `athsra unset <p> KEY [...]` | 특정 key 제거 (envelope 유지) |
109
132
  | `athsra get <p> [KEY]` | 값 출력 (single 또는 dump) |
@@ -126,7 +149,7 @@ ATHSRA_MASTER_PW='<기존과 동일>' \
126
149
  | 위협 | 완화 |
127
150
  |---|---|
128
151
  | R2 leak (CF 침해) | E2EE — ciphertext 만 노출. Argon2id m=64MB × t=3 brute-force 비용 |
129
- | token leak (머신 도난) | `athsra revoke` (KV ~60s eventual). master pw 모름 decrypt 불가 |
152
+ | token leak (머신 도난) | `athsra revoke` (D1 strong consistency). master pw 또는 identity key 없으면 decrypt 불가 |
130
153
  | handoff token 가로챔 | TTL 1h + single-use settle (Phase 1.2) |
131
154
  | master pw leak | `rotate-master` — 모든 PROOF/token 갱신 + 모든 envelope re-encrypt |
132
155
  | **master pw 분실** | **종이 backup 필수** + BIP-39 12-word phrase. recovery 없음 (E2EE 본질) |
@@ -150,6 +173,7 @@ Server (athsra-worker, BSL 1.1) 는 별도 license — see [main repo](https://g
150
173
 
151
174
  ## Status
152
175
 
153
- **Phase 1.x.1 active** (2026-05-04+) — soft-delete + version history. universe internal alpha.
176
+ **Phase 5 RC** (2026-06-12) — identity device-login + MCP 27 tools + envelope member/self
177
+ recipients. Published package target: `@athsra/cli@1.1.0`.
154
178
 
155
179
  [ROADMAP.md](https://github.com/modfolio/athsra/blob/main/docs/ROADMAP.md) — 남은 작업 + 미래 분기점 SSoT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@athsra/cli",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -43,9 +43,9 @@
43
43
  "typecheck": "tsc --noEmit"
44
44
  },
45
45
  "dependencies": {
46
- "@athsra/crypto": "^1.0.1",
46
+ "@athsra/crypto": "^1.1.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "@napi-rs/keyring": "^1.1.6",
48
+ "@napi-rs/keyring": "^1.3.0",
49
49
  "prompts": "^2.4.2"
50
50
  },
51
51
  "devDependencies": {
@@ -1,26 +1,29 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, projectRef, resolveProject } from '../lib/auto-project.ts';
2
3
  import { promptConfirm } from '../lib/prompt.ts';
3
4
 
4
5
  const USAGE = [
5
- 'usage: athsra delete <project> # soft-delete (versions 보존, restore 가능)',
6
- ' or: athsra delete <project> --hard # hard-delete (모든 versions 영구 제거)',
7
- ' or: athsra delete <project> --yes # confirm 우회 (CI 용)',
6
+ 'usage: athsra delete <project>[:<env>] # soft-delete (versions 보존, restore 가능)',
7
+ ' or: athsra delete <project>[:<env>] --hard # hard-delete (모든 versions 영구 제거)',
8
+ ' or: athsra delete <project>[:<env>] --yes # confirm 우회 (CI 용)',
9
+ CONFIG_USAGE_HINT,
8
10
  ].join('\n');
9
11
 
10
12
  /**
11
- * athsra delete <project>
12
- * - soft (default): tombstone marker 작성, versions 보존, restore 가능
13
+ * athsra delete <project>[:<env>]
14
+ * - soft (default): tombstone marker 작성, versions 보존, restore 가능 (해당 환경(config) 한정)
13
15
  * - --hard: 모든 versions + tombstone 영구 제거. 복원 불가능.
14
16
  * - confirm 필요 (--yes 또는 ATHSRA_DELETE_CONFIRMED=1 로 우회)
15
17
  */
16
18
  export async function deleteCmd(args: string[]): Promise<number> {
17
- const project = args[0];
19
+ const { project, config, rest } = resolveProject(args, { requirePositional: true });
18
20
  if (!project) {
19
21
  console.error(USAGE);
20
22
  return 2;
21
23
  }
22
- const hard = args.includes('--hard');
23
- const yes = args.includes('--yes') || args.includes('-y');
24
+ const hard = rest.includes('--hard');
25
+ const yes = rest.includes('--yes') || rest.includes('-y');
26
+ const tag = configTag(config);
24
27
 
25
28
  const ctx = await loadAuthContext();
26
29
  if (!ctx) return 1;
@@ -28,20 +31,20 @@ export async function deleteCmd(args: string[]): Promise<number> {
28
31
 
29
32
  if (!yes && process.env.ATHSRA_DELETE_CONFIRMED !== '1') {
30
33
  const msg = hard
31
- ? `HARD-DELETE ${project}? All version history permanently removed. NOT RECOVERABLE.`
32
- : `Soft-delete ${project}? Restore via 'athsra restore ${project}'.`;
34
+ ? `HARD-DELETE ${project}${tag}? All version history permanently removed. NOT RECOVERABLE.`
35
+ : `Soft-delete ${project}${tag}? Restore via 'athsra restore ${projectRef(project, config)}'.`;
33
36
  const ok = await promptConfirm(msg, false);
34
37
  if (!ok) return 0;
35
38
  }
36
39
 
37
- const result = await client.deleteProject(project, { hard });
40
+ const result = await client.deleteProject(project, { hard, config });
38
41
  if (hard) {
39
42
  console.log(
40
- `✓ ${project}: hard-deleted (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} removed)`,
43
+ `✓ ${project}${tag}: hard-deleted (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} removed)`,
41
44
  );
42
45
  } else {
43
46
  console.log(
44
- `✓ ${project}: soft-deleted (${result.recoverable_versions ?? 0} version${(result.recoverable_versions ?? 0) === 1 ? '' : 's'} recoverable via 'athsra restore')`,
47
+ `✓ ${project}${tag}: soft-deleted (${result.recoverable_versions ?? 0} version${(result.recoverable_versions ?? 0) === 1 ? '' : 's'} recoverable via 'athsra restore')`,
45
48
  );
46
49
  }
47
50
  return 0;
@@ -1,21 +1,24 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
2
3
  import { serializeEnv } from '../lib/env-format.ts';
3
4
  import { readPlain } from '../lib/envelope.ts';
4
5
 
6
+ const USAGE = ['usage: athsra get <project>[:<env>] [KEY]', CONFIG_USAGE_HINT].join('\n');
7
+
5
8
  export async function getCmd(args: string[]): Promise<number> {
6
- const project = args[0];
7
- const key = args[1];
9
+ const { project, config, rest } = resolveProject(args, { requirePositional: true });
10
+ const key = rest[0];
8
11
  if (!project) {
9
- console.error('usage: athsra get <project> [KEY]');
12
+ console.error(USAGE);
10
13
  return 2;
11
14
  }
12
15
 
13
16
  const ctx = await loadAuthContext();
14
17
  if (!ctx) return 1;
15
18
 
16
- const plain = await readPlain(ctx, project);
19
+ const plain = await readPlain(ctx, project, config);
17
20
  if (!plain) {
18
- console.error(`project not found: ${project}`);
21
+ console.error(`project not found: ${project}${configTag(config)}`);
19
22
  return 1;
20
23
  }
21
24
 
@@ -1,6 +1,6 @@
1
1
  import { hostname } from 'node:os';
2
- import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
3
2
  import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
3
+ import { deriveMasterProof } from '../lib/auth-proof.ts';
4
4
  import { AthsraClient } from '../lib/client.ts';
5
5
  import { type Config, saveConfig } from '../lib/config.ts';
6
6
  import { readPlain } from '../lib/envelope.ts';
@@ -29,7 +29,12 @@ async function issueToken(): Promise<number> {
29
29
  }
30
30
 
31
31
  const info = await client.info();
32
- const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
32
+ const me = await client.whoami();
33
+ if (me.userId === undefined) {
34
+ console.error('whoami 에 user_id 없음 — 재로그인 필요 (`athsra login`).');
35
+ return 1;
36
+ }
37
+ const proof = deriveMasterProof(masterPw, me.userId, info.global_salt);
33
38
  const result = await client.handoff(proof, newLabel);
34
39
 
35
40
  const ttlMin = result.ttlSeconds ? Math.floor(result.ttlSeconds / 60) : null;
@@ -93,6 +98,10 @@ async function acceptToken(): Promise<number> {
93
98
  console.error(`✗ token invalid: ${(err as Error).message}`);
94
99
  return 1;
95
100
  }
101
+ if (me.userId === undefined) {
102
+ console.error('✗ token 에 user_id 없음 — 재로그인 필요 (`athsra login`).');
103
+ return 1;
104
+ }
96
105
 
97
106
  // master pw 검증: 첫 envelope (있으면) decrypt 시도 (v1/v2 dispatcher)
98
107
  const projects = await client.listProjects();
@@ -100,7 +109,7 @@ async function acceptToken(): Promise<number> {
100
109
  if (first) {
101
110
  const tempCtx: UserAuthContext = {
102
111
  kind: 'user',
103
- config: { workerUrl, machineId, createdAt: me.createdAt },
112
+ config: { workerUrl, machineId, createdAt: me.createdAt, userId: me.userId },
104
113
  masterPw,
105
114
  token,
106
115
  client,
@@ -118,6 +127,7 @@ async function acceptToken(): Promise<number> {
118
127
  workerUrl,
119
128
  machineId,
120
129
  createdAt: me.createdAt,
130
+ userId: me.userId,
121
131
  };
122
132
  saveConfig(config);
123
133
  setMasterPw(machineId, masterPw);
@@ -1,15 +1,18 @@
1
1
  import { hostname } from 'node:os';
2
- import {
3
- deriveProof,
4
- fromBase64,
5
- isValidPhrase,
6
- normalizePhrase,
7
- toBase64,
8
- wordCount,
9
- } from '@athsra/crypto';
2
+ import { isValidPhrase, normalizePhrase, wordCount } from '@athsra/crypto';
3
+ import type { UserAuthContext } from '../lib/auth-context.ts';
4
+ import { deriveLegacyMasterProofs, deriveMasterProof } from '../lib/auth-proof.ts';
10
5
  import { resolveProject } from '../lib/auto-project.ts';
11
6
  import { AthsraClient } from '../lib/client.ts';
12
7
  import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
8
+ import {
9
+ completeIdentityLogin,
10
+ DEFAULT_WORKER_URL,
11
+ type IdentityFlow,
12
+ runDevicePollLoop,
13
+ startIdentityFlow,
14
+ } from '../lib/device-login.ts';
15
+ import { readPlain } from '../lib/envelope.ts';
13
16
  import { ensureKeypair } from '../lib/identity-key.ts';
14
17
  import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
15
18
  import { consumeLegacySession } from '../lib/legacy-session.ts';
@@ -133,12 +136,16 @@ async function ssoLoginCmd(): Promise<number> {
133
136
  // proof = Argon2id(masterPw, perUserSalt(user_id, GLOBAL_SALT)) 단방향 해시 — 평문 송신 X (E2EE).
134
137
  // G-1: per-user salt 로 같은 pw 두 user 도 proof 가 유일. 첫 SSO = bootstrap, 재로그인 = 검증.
135
138
  const info = await tempClient.info();
136
- const proof = toBase64(deriveProof(masterPw, ssoBody.user_id, fromBase64(info.global_salt)));
139
+ const proof = deriveMasterProof(masterPw, ssoBody.user_id, info.global_salt);
137
140
  try {
138
141
  const pr = await new AthsraClient(workerUrl, ssoBody.token).setProof(
139
142
  proof,
140
143
  info.global_salt_version,
141
144
  );
145
+ if (pr.userId !== ssoBody.user_id) {
146
+ console.error(`✗ proof user mismatch (${pr.userId} ≠ ${ssoBody.user_id}) — 재로그인 필요.`);
147
+ return 1;
148
+ }
142
149
  if (pr.version_reset) {
143
150
  console.log('• Master password re-registered (GLOBAL_SALT changed).');
144
151
  } else if (pr.bootstrap) {
@@ -162,6 +169,7 @@ async function ssoLoginCmd(): Promise<number> {
162
169
  workerUrl,
163
170
  machineId,
164
171
  createdAt: existing?.createdAt ?? ssoBody.createdAt,
172
+ userId: ssoBody.user_id,
165
173
  };
166
174
  saveConfig(config);
167
175
  setMasterPw(machineId, masterPw);
@@ -178,9 +186,6 @@ async function ssoLoginCmd(): Promise<number> {
178
186
  return 0;
179
187
  }
180
188
 
181
- /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
182
- const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
183
-
184
189
  interface DeviceArgs {
185
190
  project?: string;
186
191
  perms: 'read' | 'write';
@@ -216,8 +221,6 @@ function parseDeviceArgs(args: string[]): DeviceArgs {
216
221
  return { project, perms, noBrowser };
217
222
  }
218
223
 
219
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
220
-
221
224
  /**
222
225
  * Phase 3 P3 (2026-05-31) — device-login (RFC 8628 적응).
223
226
  *
@@ -268,56 +271,143 @@ async function deviceLoginCmd(args: string[]): Promise<number> {
268
271
  console.log(' (master pw 는 그 브라우저에서 1회 — 이 기기엔 저장되지 않습니다)\n');
269
272
  if (!noBrowser) openBrowser(dc.verification_uri_complete);
270
273
 
271
- // poll
272
- let interval = Math.max(1, dc.interval) * 1000;
273
- const deadline = Date.now() + dc.expires_in * 1000;
274
+ // poll (lib/device-login.ts 공용 루프 — Phase 5 B5 추출)
274
275
  process.stdout.write(' 대기 중');
275
- while (Date.now() < deadline) {
276
- await sleep(interval);
277
- let result: Awaited<ReturnType<typeof client.devicePollToken>>;
278
- try {
279
- result = await client.devicePollToken(dc.device_code);
280
- } catch {
281
- process.stdout.write('.');
282
- continue;
283
- }
284
- if (result.status === 'token') {
285
- setDeviceToken(project, result.token);
286
- saveConfig({
287
- workerUrl,
288
- machineId,
289
- createdAt: existing?.createdAt ?? new Date().toISOString(),
290
- });
291
- console.log('\n\n✓ device-login 완료');
292
- console.log(` project: ${result.project} (${result.perms})`);
293
- console.log(' keyring: device token 저장 (master pw 불필요)');
294
- console.log(` 사용: athsra run ${result.project} -- <command>\n`);
295
- return 0;
296
- }
297
- // pending / terminal
298
- if (result.error === 'access_denied') {
299
- console.error('\n\n✗ 승인이 거부되었습니다.');
300
- return 1;
301
- }
302
- if (result.error === 'expired_token') {
303
- console.error('\n\n 요청이 만료되었습니다. `athsra login --device` 재시도.');
304
- return 1;
305
- }
306
- if (result.error === 'slow_down') {
307
- interval += 5000;
276
+ const outcome = await runDevicePollLoop(client, dc.device_code, {
277
+ intervalMs: Math.max(1, dc.interval) * 1000,
278
+ expiresAt: Date.now() + dc.expires_in * 1000,
279
+ onTick: () => process.stdout.write('.'),
280
+ });
281
+ if (outcome.step === 'denied') {
282
+ console.error('\n\n✗ 승인이 거부되었습니다.');
283
+ return 1;
284
+ }
285
+ if (outcome.step === 'expired') {
286
+ console.error('\n\n✗ 요청이 만료되었습니다. `athsra login --device` 재시도.');
287
+ return 1;
288
+ }
289
+ if (outcome.step === 'timeout') {
290
+ console.error('\n\n✗ 승인 대기 시간 초과. `athsra login --device` 재시도.');
291
+ return 1;
292
+ }
293
+ const result = outcome.result;
294
+ if (result.tokenType !== 'service') {
295
+ console.error('\n\n✗ 예상치 못한 user-kind 토큰 (service device-login).');
296
+ return 1;
297
+ }
298
+ setDeviceToken(project, result.token);
299
+ saveConfig({
300
+ workerUrl,
301
+ machineId,
302
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
303
+ });
304
+ console.log('\n\n device-login 완료');
305
+ console.log(` project: ${result.project} (${result.perms})`);
306
+ console.log(' keyring: device token 저장 (master pw 불필요)');
307
+ console.log(` 사용: athsra run ${result.project} -- <command>\n`);
308
+ return 0;
309
+ }
310
+
311
+ /** identity login 후 best-effort 검증 — whoami (실패해도 login 성공 유지). */
312
+ async function verifyIdentityLogin(client: AthsraClient): Promise<void> {
313
+ try {
314
+ const me = await client.whoami();
315
+ if (me.userId !== undefined) {
316
+ console.log(` verified: user #${me.userId} ✓`);
308
317
  }
309
- process.stdout.write('.');
318
+ } catch {
319
+ console.warn(' verify: 보류 (login 자체는 성공 — 다음 명령에서 재확인).');
310
320
  }
311
- console.error('\n\n✗ 승인 대기 시간 초과. `athsra login --device` 재시도.');
312
- return 1;
321
+ }
322
+
323
+ /**
324
+ * Phase 5 (2026-06-12) — identity device-login (기본 `athsra login`).
325
+ *
326
+ * 브라우저 완결 흐름 (TTY 0회, master pw 이 머신 미보관):
327
+ * 1. 디바이스 X25519 키쌍 생성 (privkey 는 이 머신에만 — sealed-box unseal 용)
328
+ * 2. deviceCode(kind:user, device_pub_key) → user_code + URL + fingerprint 출력 + openBrowser
329
+ * 3. 사용자가 athsra.com/device 에서 로그인 + master pw 로 identity priv 봉인 → complete
330
+ * 4. poll → {token(atk_*), sealed_identity_key, user_id, key_version}
331
+ * 5. unseal(user_id 대조) → identity priv → keyring 저장 + token + config.userId
332
+ * 6. whoami best-effort 검증
333
+ */
334
+ async function identityLoginCmd(args: string[]): Promise<number> {
335
+ const noBrowser = args.includes('--no-browser');
336
+
337
+ // 1. flow 시작 (keyring probe → config/worker 해석 → 키쌍 생성 → deviceCode) —
338
+ // lib/device-login.ts 공용 코어 (Phase 5 B5 추출, MCP athsra_login_start 와 동일 경로).
339
+ let flow: IdentityFlow;
340
+ try {
341
+ flow = await startIdentityFlow();
342
+ } catch (err) {
343
+ console.error(`✗ ${(err as Error).message}`);
344
+ return 1;
345
+ }
346
+
347
+ console.log('\n● athsra login — 브라우저에서 승인이 필요합니다\n');
348
+ console.log(` 1. 열기: ${flow.verificationUriComplete}`);
349
+ console.log(` 2. 코드: ${flow.userCode}`);
350
+ console.log(
351
+ ` 3. 지문: ${flow.fingerprint} ← 브라우저 화면의 지문과 일치하는지 확인 (phishing 가드)`,
352
+ );
353
+ console.log('\n athsra.com 에 로그인 후 master password 로 승인하면 자동 진행됩니다.');
354
+ console.log(' (master password 는 그 브라우저에서만 — 이 기기엔 저장되지 않습니다)\n');
355
+ if (!noBrowser) openBrowser(flow.verificationUriComplete);
356
+
357
+ // 2. poll (공용 루프)
358
+ process.stdout.write(' 대기 중');
359
+ const outcome = await runDevicePollLoop(flow.client, flow.deviceCode, {
360
+ intervalMs: flow.intervalMs,
361
+ expiresAt: flow.expiresAt,
362
+ onTick: () => process.stdout.write('.'),
363
+ });
364
+ if (outcome.step === 'denied') {
365
+ console.error('\n\n✗ 승인이 거부되었습니다.');
366
+ return 1;
367
+ }
368
+ if (outcome.step === 'expired') {
369
+ console.error('\n\n✗ 요청이 만료되었습니다. `athsra login` 재시도.');
370
+ return 1;
371
+ }
372
+ if (outcome.step === 'timeout') {
373
+ console.error('\n\n✗ 승인 대기 시간 초과. `athsra login` 재시도.');
374
+ return 1;
375
+ }
376
+ const result = outcome.result;
377
+ if (result.tokenType !== 'user') {
378
+ console.error('\n\n✗ 예상치 못한 service 토큰 (identity login).');
379
+ return 1;
380
+ }
381
+
382
+ // 3. unseal(user_id 대조) → keyring identity+token → config.userId (공용 완료 처리)
383
+ try {
384
+ await completeIdentityLogin({
385
+ result,
386
+ devicePrivateKey: flow.devicePrivateKey,
387
+ machineId: flow.machineId,
388
+ workerUrl: flow.workerUrl,
389
+ existingCreatedAt: flow.existingCreatedAt,
390
+ });
391
+ } catch (err) {
392
+ console.error(`\n\n✗ identity key unseal 실패: ${(err as Error).message}`);
393
+ return 1;
394
+ }
395
+ console.log('\n\n✓ logged in (identity 모드)');
396
+ console.log(` machine: ${flow.machineId}`);
397
+ console.log(` worker: ${flow.workerUrl}`);
398
+ console.log(' keyring: identity key + token 저장 (master pw 이 기기엔 없음)');
399
+ // 4. best-effort 검증
400
+ await verifyIdentityLogin(new AthsraClient(flow.workerUrl, result.token));
401
+ return 0;
313
402
  }
314
403
 
315
404
  const LOGIN_USAGE = [
316
- 'usage: athsra login [--sso | --device]',
405
+ 'usage: athsra login [--password | --sso | --device]',
317
406
  '',
318
- '기본 (master pw + paper backup): athsra login',
407
+ '기본 (identity device-login, master pw 기기 미보관): athsra login',
408
+ 'master pw register (founding owner, full 권한): athsra login --password',
319
409
  'SSO (modfolio-connect OIDC PKCE — Phase 2.8): athsra login --sso',
320
- 'device (비-TTY agent, master pw 불필요 — Phase 3 P3): athsra login --device [--project <p>] [--write]',
410
+ 'device (비-TTY agent, project-scoped — Phase 3 P3): athsra login --device [--project <p>] [--write]',
321
411
  '',
322
412
  'device-login: agent 가 user_code 출력 → 사용자가 athsra.com/device 에서 1-click 승인 →',
323
413
  ' project-scoped ats_* 수령 (keyring 저장). master pw 는 승인 브라우저에서만 사용.',
@@ -342,7 +432,18 @@ export async function loginCmd(args: string[]): Promise<number> {
342
432
  if (args.includes('--sso')) {
343
433
  return ssoLoginCmd();
344
434
  }
345
- console.log('athsra login\n');
435
+ if (args.includes('--password')) {
436
+ return passwordLoginCmd();
437
+ }
438
+ return identityLoginCmd(args);
439
+ }
440
+
441
+ /**
442
+ * 기존 기본 — founding(user 1) master pw register. Phase 5 부터 `--password` 로 명시.
443
+ * identity 모드(신규 기본)와 달리 이 머신 keyring 에 master pw 보관 (full owner 권한).
444
+ */
445
+ async function passwordLoginCmd(): Promise<number> {
446
+ console.log('athsra login --password\n');
346
447
 
347
448
  // 1. keyring backend probe (정공법: fallback 없음)
348
449
  const probe = probeKeyring();
@@ -451,13 +552,20 @@ export async function loginCmd(args: string[]): Promise<number> {
451
552
  // password register 는 founding singleton(user 1) 전용(SSO 외 유일 경로) — userId=1 고정.
452
553
  // 다중 사용자 self-serve password register 는 out-of-scope(SSO-gated 유지).
453
554
  const FOUNDING_USER_ID = 1;
454
- const proofBytes = deriveProof(masterPw, FOUNDING_USER_ID, fromBase64(info.global_salt));
455
- const proofBase64 = toBase64(proofBytes);
555
+ const proofBase64 = deriveMasterProof(masterPw, FOUNDING_USER_ID, info.global_salt);
556
+ // 버전 업그레이드 무중단: 저장 proof 가 옛 스킴(≤1.0.2)이면 worker 가 이 후보로 검증 후 G-1 자동 재작성.
557
+ const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
456
558
 
457
559
  // 8. register → token
458
560
  let reg: Awaited<ReturnType<typeof tempClient.register>>;
459
561
  try {
460
- reg = await tempClient.register(proofBase64, machineId);
562
+ reg = await tempClient.register(proofBase64, machineId, legacyProofs);
563
+ if (reg.userId !== undefined && reg.userId !== FOUNDING_USER_ID) {
564
+ console.error(
565
+ `✗ register user mismatch (${reg.userId} ≠ ${FOUNDING_USER_ID}) — worker 확인 필요.`,
566
+ );
567
+ return 1;
568
+ }
461
569
  } catch (err) {
462
570
  const msg = (err as Error).message;
463
571
  if (msg.includes('401')) {
@@ -474,16 +582,55 @@ export async function loginCmd(args: string[]): Promise<number> {
474
582
  workerUrl,
475
583
  machineId,
476
584
  createdAt: existing?.createdAt ?? reg.createdAt,
585
+ userId: reg.userId ?? FOUNDING_USER_ID,
477
586
  };
478
587
  saveConfig(config);
479
588
  setMasterPw(machineId, masterPw);
480
589
  setToken(machineId, reg.token);
481
- await provisionIdentityKey(new AthsraClient(workerUrl, reg.token), masterPw);
590
+ const userClient = new AthsraClient(workerUrl, reg.token);
591
+ await provisionIdentityKey(userClient, masterPw);
592
+
593
+ // bootstrap typo-guard — worker 는 proof 만 저장(평문 pw·키 모름)하므로 오타 pw 도 bootstrap 된다.
594
+ // 기존 envelope 가 있으면 실제 복호를 시도해, 잘못된 pw 로 인한 "조용한 lockout" 을 즉시 경고로 전환.
595
+ // (proof 검증 경로[verify/migrate]는 pw 가 이미 맞으므로 bootstrap 일 때만 검사 = 로그인 지연 최소.)
596
+ if (reg.bootstrap) {
597
+ try {
598
+ const projects = await userClient.listProjects();
599
+ const probe = projects[0];
600
+ if (probe) {
601
+ const probeCtx: UserAuthContext = {
602
+ kind: 'user',
603
+ config,
604
+ masterPw,
605
+ token: reg.token,
606
+ client: userClient,
607
+ };
608
+ await readPlain(probeCtx, probe); // 잘못된 pw 면 decrypt 에서 throw
609
+ }
610
+ } catch (err) {
611
+ const msg = (err as Error).message;
612
+ if (msg.includes('decrypt') || msg.includes('auth tag')) {
613
+ console.error(
614
+ '\n⚠ 경고: 방금 설정한 master password 가 기존 secret 을 복호화하지 못합니다 (오타 가능성 높음).',
615
+ );
616
+ console.error(
617
+ ' secret 데이터는 안전합니다 — 올바른 password 로 `athsra login --password` 를 다시 실행하세요.',
618
+ );
619
+ console.error(
620
+ ' (worker 는 proof 만 보관 → password 자체는 검증 불가. 이 복호 검사가 유일한 typo 방어선입니다.)',
621
+ );
622
+ }
623
+ // 그 외(네트워크 등)는 무시 — 로그인 자체는 성공.
624
+ }
625
+ }
482
626
 
483
627
  console.log(`\n✓ logged in (machine: ${machineId})`);
484
628
  console.log(` worker: ${workerUrl}`);
485
629
  console.log(` config: ~/.athsra/config.json`);
486
630
  console.log(' keyring: master-pw + token saved (OS keyring, 무기한)');
631
+ if (reg.migrated) {
632
+ console.log(' proof: 옛 스킴 → 현 스킴 자동 마이그레이션 ✓ (CLI 버전 정합, 무중단)');
633
+ }
487
634
  if (legacy) {
488
635
  console.log(' legacy: ~/.athsra/session migrated and removed.');
489
636
  }
@@ -2,7 +2,7 @@ import { existsSync, unlinkSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { loadConfig } from '../lib/config.ts';
5
- import { clearMasterPw, clearToken } from '../lib/keyring.ts';
5
+ import { clearIdentityKey, clearMasterPw, clearToken } from '../lib/keyring.ts';
6
6
  import { promptConfirm } from '../lib/prompt.ts';
7
7
 
8
8
  /**
@@ -33,8 +33,9 @@ export async function logoutCmd(args: string[]): Promise<number> {
33
33
 
34
34
  clearMasterPw(config.machineId);
35
35
  clearToken(config.machineId);
36
+ clearIdentityKey(config.machineId);
36
37
  console.log(`✓ keyring cleared for machine: ${config.machineId}`);
37
- console.log(' • master-pw + Bearer token removed from OS keyring');
38
+ console.log(' • master-pw + identity key + Bearer token removed from OS keyring');
38
39
  console.log(' • worker-side token still active — `athsra revoke` to invalidate fully');
39
40
 
40
41
  if (full) {