@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.
- package/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +208 -61
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +77 -13
- package/src/lib/auth-proof.ts +26 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +112 -19
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- 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.
|
|
44
|
+
### 2. 로그인
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
|
-
#
|
|
46
|
-
athsra
|
|
47
|
+
# 일반 머신/AI agent: 브라우저 device flow.
|
|
48
|
+
ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login
|
|
49
|
+
# URL + 코드 + 디바이스 지문 출력 → 브라우저에서 승인
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
#
|
|
50
|
-
|
|
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` |
|
|
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` (
|
|
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
|
|
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.
|
|
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
|
|
46
|
+
"@athsra/crypto": "^1.1.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
-
"@napi-rs/keyring": "^1.
|
|
48
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
49
49
|
"prompts": "^2.4.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
package/src/commands/delete.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
23
|
-
const yes =
|
|
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;
|
package/src/commands/get.ts
CHANGED
|
@@ -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
|
|
7
|
-
const key =
|
|
9
|
+
const { project, config, rest } = resolveProject(args, { requirePositional: true });
|
|
10
|
+
const key = rest[0];
|
|
8
11
|
if (!project) {
|
|
9
|
-
console.error(
|
|
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
|
|
package/src/commands/handoff.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/login.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { hostname } from 'node:os';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
318
|
+
} catch {
|
|
319
|
+
console.warn(' verify: 보류 (login 자체는 성공 — 다음 명령에서 재확인).');
|
|
310
320
|
}
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/logout.ts
CHANGED
|
@@ -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) {
|