@athsra/cli 1.1.1 → 1.1.3
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/package.json +1 -1
- package/src/commands/doctor.ts +6 -4
- package/src/commands/login.ts +8 -0
- package/src/commands/recipients.ts +168 -0
- package/src/commands/service-token.ts +50 -14
- package/src/index.ts +5 -1
- package/src/lib/client.ts +33 -4
- package/src/lib/config.ts +21 -8
- package/src/lib/device-login.ts +5 -1
- package/src/lib/legacy-session.ts +5 -4
- package/src/lib/mcp-tools/admin.ts +46 -14
- package/src/lib/service-tokens.ts +56 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
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",
|
package/src/commands/doctor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import type { UserAuthContext } from '../lib/auth-context.ts';
|
|
3
3
|
import { AthsraClient } from '../lib/client.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { configFile, loadConfig, sessionFile } from '../lib/config.ts';
|
|
5
5
|
import { partitionEnv } from '../lib/env-format.ts';
|
|
6
6
|
import { readPlain } from '../lib/envelope.ts';
|
|
7
7
|
import { getMasterPw, getToken, probeKeyring } from '../lib/keyring.ts';
|
|
@@ -23,7 +23,8 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
const config = loadConfig();
|
|
26
|
-
|
|
26
|
+
const cfgFile = configFile();
|
|
27
|
+
console.log(` config: ${existsSync(cfgFile) ? '✓' : '✗'} ${cfgFile}`);
|
|
27
28
|
if (!config) {
|
|
28
29
|
console.log(' (run `athsra login` first)');
|
|
29
30
|
return kr.ok ? 0 : 1;
|
|
@@ -32,8 +33,9 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
32
33
|
console.log(` machine: ${config.machineId}`);
|
|
33
34
|
console.log(` created: ${config.createdAt}`);
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const sessFile = sessionFile();
|
|
37
|
+
if (existsSync(sessFile)) {
|
|
38
|
+
console.log(` ⚠ legacy: ${sessFile} 잔존 (next \`athsra login\` 시 자동 제거).`);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const masterPw = kr.ok ? getMasterPw(config.machineId) : null;
|
package/src/commands/login.ts
CHANGED
|
@@ -137,10 +137,14 @@ async function ssoLoginCmd(): Promise<number> {
|
|
|
137
137
|
// G-1: per-user salt 로 같은 pw 두 user 도 proof 가 유일. 첫 SSO = bootstrap, 재로그인 = 검증.
|
|
138
138
|
const info = await tempClient.info();
|
|
139
139
|
const proof = deriveMasterProof(masterPw, ssoBody.user_id, info.global_salt);
|
|
140
|
+
// 버전 업그레이드 무중단: 저장 proof 가 옛 스킴(gen-0)이면 worker 가 이 후보로 검증 후 G-1 자동
|
|
141
|
+
// 재작성. legacy proof 는 per-user salt 없는 gen-0 이라 userId 불필요 — 그대로 사용.
|
|
142
|
+
const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
|
|
140
143
|
try {
|
|
141
144
|
const pr = await new AthsraClient(workerUrl, ssoBody.token).setProof(
|
|
142
145
|
proof,
|
|
143
146
|
info.global_salt_version,
|
|
147
|
+
legacyProofs,
|
|
144
148
|
);
|
|
145
149
|
if (pr.userId !== ssoBody.user_id) {
|
|
146
150
|
console.error(`✗ proof user mismatch (${pr.userId} ≠ ${ssoBody.user_id}) — 재로그인 필요.`);
|
|
@@ -148,6 +152,10 @@ async function ssoLoginCmd(): Promise<number> {
|
|
|
148
152
|
}
|
|
149
153
|
if (pr.version_reset) {
|
|
150
154
|
console.log('• Master password re-registered (GLOBAL_SALT changed).');
|
|
155
|
+
} else if (pr.migrated) {
|
|
156
|
+
console.log(
|
|
157
|
+
'• Master password 옛 스킴 → 현 스킴 자동 마이그레이션 ✓ (버전 업그레이드 무중단).',
|
|
158
|
+
);
|
|
151
159
|
} else if (pr.bootstrap) {
|
|
152
160
|
console.log('• Master password set for this account ✓ (worker stores one-way proof only).');
|
|
153
161
|
} else {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { removeRecipient, type SecretEnvelopeV2 } from '@athsra/crypto';
|
|
2
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
4
|
+
import { promptConfirm } from '../lib/prompt.ts';
|
|
5
|
+
|
|
6
|
+
const USAGE = [
|
|
7
|
+
'usage: athsra recipients <project>[:<env>] # recipient 목록 (읽기 전용)',
|
|
8
|
+
' or: athsra recipients prune-orphans [<project>] [--apply] # orphan service recipient 정리',
|
|
9
|
+
'',
|
|
10
|
+
'envelope 의 recipient 목록(읽기 전용) — master / member / service token 분포 감사.',
|
|
11
|
+
'값·평문 미노출 (recipient kind·id 만 표시). service recipient_id 는 비밀 아님',
|
|
12
|
+
'(whoami / service-token list 가 이미 노출).',
|
|
13
|
+
'',
|
|
14
|
+
CONFIG_USAGE_HINT,
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
const PRUNE_USAGE = [
|
|
18
|
+
'usage: athsra recipients prune-orphans [<project>[:<env>]] [--apply|--yes]',
|
|
19
|
+
'',
|
|
20
|
+
'auth_tokens 에 없는 service recipient(orphan — revoke/만료 후 envelope 에 남은 메타)를 제거.',
|
|
21
|
+
'master/member recipient 는 절대 건드리지 않는다. DEK 는 회전하지 않음(메타 정리 — 완전 re-key 는',
|
|
22
|
+
'`athsra rotate-master`). project 생략 시 전 project 를 default 환경에서 스윕.',
|
|
23
|
+
'',
|
|
24
|
+
'기본은 dry-run(변경 없음). 실제 제거는 --apply (confirm: --yes 또는 ATHSRA_PRUNE_CONFIRMED=1).',
|
|
25
|
+
'제거는 org owner 권한 필요(worker 강제) — user(master-pw) 컨텍스트에서 실행.',
|
|
26
|
+
'',
|
|
27
|
+
CONFIG_USAGE_HINT,
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 인시던트 후속(2026-06-14) — envelope recipient 구조 운영 가시성 + orphan 정리.
|
|
32
|
+
* - `athsra recipients <project>` : recipient 분포 감사 (읽기 전용, 모든 인증 모드).
|
|
33
|
+
* - `athsra recipients prune-orphans [...]` : auth_tokens 에 없는 service recipient 정리.
|
|
34
|
+
* getEnvelope 가 이미 recipients[] 를 주므로 복호 불필요.
|
|
35
|
+
*/
|
|
36
|
+
export async function recipientsCmd(args: string[]): Promise<number> {
|
|
37
|
+
if (args[0] === 'prune-orphans') return pruneOrphansCmd(args.slice(1));
|
|
38
|
+
return listRecipientsCmd(args);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listRecipientsCmd(args: string[]): Promise<number> {
|
|
42
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
43
|
+
console.log(USAGE);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const { project, config } = resolveProject(args, { requirePositional: true });
|
|
47
|
+
if (!project) {
|
|
48
|
+
console.error(USAGE);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ctx = await loadAuthContext(project);
|
|
53
|
+
if (!ctx) return 1;
|
|
54
|
+
|
|
55
|
+
const env = await ctx.client.getEnvelope(project, config);
|
|
56
|
+
if (!env) {
|
|
57
|
+
console.error(`project not found: ${project}${configTag(config)}`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tag = configTag(config);
|
|
62
|
+
if (env.version !== 2) {
|
|
63
|
+
console.log(
|
|
64
|
+
`${project}${tag}: v1 envelope — single master recipient (run \`athsra rotate-master\` → v2)`,
|
|
65
|
+
);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(`${project}${tag} — ${env.recipients.length} recipient(s):`);
|
|
70
|
+
for (const r of env.recipients) {
|
|
71
|
+
console.log(` ${r.kind.padEnd(8)} ${r.id}`);
|
|
72
|
+
}
|
|
73
|
+
const master = env.recipients.filter((r) => r.kind === 'master').length;
|
|
74
|
+
const service = env.recipients.filter((r) => r.kind === 'service').length;
|
|
75
|
+
const member = env.recipients.filter((r) => r.kind === 'member').length;
|
|
76
|
+
console.log(`\n요약: master ${master} · service ${service} · member ${member}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* orphan service recipient 정리 — auth_tokens 에 살아있는 service token 의 recipient_id 와
|
|
82
|
+
* envelope 의 service recipient 를 대조해, DB 에 없는 service recipient(revoke/만료 후 envelope 에
|
|
83
|
+
* 남은 메타)만 제거한다. master/member 는 절대 건드리지 않는다(removeRecipient 가 master 제거 차단,
|
|
84
|
+
* 우리는 kind==='service' 만 후보로 둠). DEK 미회전 — 메타 정리(완전 re-key 는 rotate-master).
|
|
85
|
+
*/
|
|
86
|
+
async function pruneOrphansCmd(args: string[]): Promise<number> {
|
|
87
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
88
|
+
console.log(PRUNE_USAGE);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const apply = args.includes('--apply');
|
|
92
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
93
|
+
const positional = args.filter((a) => !a.startsWith('-'));
|
|
94
|
+
const { project, config } = resolveProject(positional, { requirePositional: true });
|
|
95
|
+
|
|
96
|
+
const ctx = await loadAuthContext(project);
|
|
97
|
+
if (!ctx) return 1;
|
|
98
|
+
const { client } = ctx;
|
|
99
|
+
|
|
100
|
+
// 살아있는 service token 의 recipient_id 집합 (전 project — recipient_id 는 전역 유일).
|
|
101
|
+
let live: Set<string>;
|
|
102
|
+
try {
|
|
103
|
+
const { tokens } = await client.listServiceTokens();
|
|
104
|
+
live = new Set(tokens.map((t) => t.recipient_id));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(`service-token list 조회 실패: ${(err as Error).message}`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const targets = project ? [project] : await client.listProjects();
|
|
111
|
+
let totalOrphans = 0;
|
|
112
|
+
let prunedProjects = 0;
|
|
113
|
+
|
|
114
|
+
for (const proj of targets) {
|
|
115
|
+
const env = await client.getEnvelope(proj, config);
|
|
116
|
+
if (env?.version !== 2) continue;
|
|
117
|
+
const orphans = env.recipients.filter((r) => r.kind === 'service' && !live.has(r.id));
|
|
118
|
+
if (orphans.length === 0) continue;
|
|
119
|
+
totalOrphans += orphans.length;
|
|
120
|
+
const tag = configTag(config);
|
|
121
|
+
|
|
122
|
+
console.log(`${proj}${tag} — orphan service recipient(s) (auth_tokens 에 없음):`);
|
|
123
|
+
for (const r of orphans) console.log(` service ${r.id}`);
|
|
124
|
+
|
|
125
|
+
if (!apply) {
|
|
126
|
+
console.log('');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// confirm (dry-run 이 아닌 실제 제거).
|
|
131
|
+
if (!yes && process.env.ATHSRA_PRUNE_CONFIRMED !== '1') {
|
|
132
|
+
const ok = await promptConfirm(
|
|
133
|
+
`${proj}${tag}: 위 ${orphans.length} orphan service recipient 제거? (master/member 보존, DEK 불변)`,
|
|
134
|
+
false,
|
|
135
|
+
);
|
|
136
|
+
if (!ok) {
|
|
137
|
+
console.log(' 건너뜀.\n');
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let pruned: SecretEnvelopeV2 = env;
|
|
143
|
+
for (const r of orphans) pruned = removeRecipient(pruned, r.id);
|
|
144
|
+
try {
|
|
145
|
+
await client.putEnvelope(proj, pruned, config);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(
|
|
148
|
+
` ✗ ${proj}${tag} 저장 실패 (owner 권한 필요할 수 있음): ${(err as Error).message}\n`,
|
|
149
|
+
);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
prunedProjects += 1;
|
|
153
|
+
console.log(` ✓ ${orphans.length} recipient(s) 제거 — master/member 보존, DEK 불변.\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (totalOrphans === 0) {
|
|
157
|
+
console.log(
|
|
158
|
+
'orphan service recipient 없음 (모든 service recipient 가 살아있는 token 과 일치).',
|
|
159
|
+
);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
if (!apply) {
|
|
163
|
+
console.log(`총 ${totalOrphans} orphan. dry-run (변경 없음). 제거하려면 --apply.`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log(`완료: ${prunedProjects} project 에서 orphan 제거.`);
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { removeRecipient } from '@athsra/crypto';
|
|
1
2
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
3
|
import { resolveProject } from '../lib/auto-project.ts';
|
|
3
4
|
import { red, yellow } from '../lib/colors.ts';
|
|
4
5
|
import {
|
|
5
6
|
type CreatedServiceToken,
|
|
6
7
|
createServiceTokenWithRecipient,
|
|
8
|
+
createServiceTokenWithRecipientAsMember,
|
|
7
9
|
} from '../lib/service-tokens.ts';
|
|
8
10
|
|
|
9
11
|
/** worker expiry-notify (apps/worker/src/queue/expiry-notify.ts) 의 최임박 threshold 와 동기. */
|
|
@@ -17,6 +19,8 @@ const USAGE = [
|
|
|
17
19
|
'',
|
|
18
20
|
'service token = scoped + revocable + master-pw 없이 envelope 복호 가능한 token.',
|
|
19
21
|
' → headless 호스트 (NAS Docker, CI, 자동화) 용 — E2EE 유지, master pw 유출 X.',
|
|
22
|
+
'발급 컨텍스트: user(master pw) 또는 identity(`athsra login` 브라우저 — AI-native, master pw 불요).',
|
|
23
|
+
' identity 모드는 v2 envelope + 본인 member recipient 전제(master pw 머신서 1회 준비).',
|
|
20
24
|
].join('\n');
|
|
21
25
|
|
|
22
26
|
async function createCmd(args: string[]): Promise<number> {
|
|
@@ -57,20 +61,32 @@ async function createCmd(args: string[]): Promise<number> {
|
|
|
57
61
|
|
|
58
62
|
const ctx = await loadAuthContext();
|
|
59
63
|
if (!ctx) return 1;
|
|
60
|
-
if (ctx.kind
|
|
61
|
-
console.error(
|
|
64
|
+
if (ctx.kind === 'service') {
|
|
65
|
+
console.error(
|
|
66
|
+
'service token 으로는 다른 service token 을 발급할 수 없습니다 — user(master pw) 또는 identity(device login) 컨텍스트 필요.',
|
|
67
|
+
);
|
|
62
68
|
return 1;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
// 발급 + recipient 추가 (lib/service-tokens.ts — MCP athsra_service_token_create 와 공용 경로)
|
|
71
|
+
// 발급 + recipient 추가 (lib/service-tokens.ts — MCP athsra_service_token_create 와 공용 경로).
|
|
72
|
+
// user 모드(master pw) → master recipient 로 DEK unwrap. identity 모드(device login, master pw
|
|
73
|
+
// 부재) → 본인 raw identity privkey(member 경로)로 DEK unwrap (AI-native onboarding).
|
|
66
74
|
let created: CreatedServiceToken;
|
|
67
75
|
try {
|
|
68
|
-
created =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
created =
|
|
77
|
+
ctx.kind === 'identity'
|
|
78
|
+
? await createServiceTokenWithRecipientAsMember(ctx, {
|
|
79
|
+
project,
|
|
80
|
+
label,
|
|
81
|
+
perms,
|
|
82
|
+
expiresInDays: expiresDays,
|
|
83
|
+
})
|
|
84
|
+
: await createServiceTokenWithRecipient(ctx, {
|
|
85
|
+
project,
|
|
86
|
+
label,
|
|
87
|
+
perms,
|
|
88
|
+
expiresInDays: expiresDays,
|
|
89
|
+
});
|
|
74
90
|
} catch (err) {
|
|
75
91
|
console.error((err as Error).message);
|
|
76
92
|
return 1;
|
|
@@ -113,9 +129,27 @@ async function revokeCmd(args: string[]): Promise<number> {
|
|
|
113
129
|
|
|
114
130
|
const res = await client.revoke(token);
|
|
115
131
|
console.log(`✓ revoked: ${res.revoked}`);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
|
|
133
|
+
// 근본수정(2026-06-15): revoke 후 envelope 의 service recipient 동반 정리 → orphan recipient
|
|
134
|
+
// 재발 차단. worker 가 service token 의 recipient_id + project 를 반환. removeRecipient 는
|
|
135
|
+
// master/member 미접근(service 만). PUT 은 owner 권한 필요(worker 강제) → 실패 시 best-effort
|
|
136
|
+
// 안내(prune-orphans 로 폴백). const 캡처로 closure narrowing 유지.
|
|
137
|
+
const recipientId = res.recipient_id;
|
|
138
|
+
const project = res.project;
|
|
139
|
+
if (recipientId && project) {
|
|
140
|
+
try {
|
|
141
|
+
const env = await client.getEnvelope(project);
|
|
142
|
+
if (env?.version === 2 && env.recipients.some((r) => r.id === recipientId)) {
|
|
143
|
+
await client.putEnvelope(project, removeRecipient(env, recipientId));
|
|
144
|
+
console.log(` ✓ envelope service recipient 정리: ${recipientId} (${project})`);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.log(
|
|
148
|
+
` ⚠ envelope recipient ${recipientId} 남음 (${(err as Error).message}) — ` +
|
|
149
|
+
`\`athsra recipients prune-orphans ${project}\` 로 정리하세요.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
119
153
|
return 0;
|
|
120
154
|
}
|
|
121
155
|
|
|
@@ -124,8 +158,10 @@ async function listCmd(args: string[]): Promise<number> {
|
|
|
124
158
|
|
|
125
159
|
const ctx = await loadAuthContext();
|
|
126
160
|
if (!ctx) return 1;
|
|
127
|
-
if (ctx.kind
|
|
128
|
-
console.error(
|
|
161
|
+
if (ctx.kind === 'service') {
|
|
162
|
+
console.error(
|
|
163
|
+
'service token 으로는 목록 조회 불가 — user(master pw) 또는 identity(device login) 필요.',
|
|
164
|
+
);
|
|
129
165
|
return 1;
|
|
130
166
|
}
|
|
131
167
|
const { client } = ctx;
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { migrateEnvelopesCmd } from './commands/migrate-envelopes.ts';
|
|
|
18
18
|
import { newPhraseCmd } from './commands/new-phrase.ts';
|
|
19
19
|
import { orgCmd } from './commands/org.ts';
|
|
20
20
|
import { purgeCmd } from './commands/purge.ts';
|
|
21
|
+
import { recipientsCmd } from './commands/recipients.ts';
|
|
21
22
|
import { restoreCmd } from './commands/restore.ts';
|
|
22
23
|
import { revokeCmd } from './commands/revoke.ts';
|
|
23
24
|
import { rollbackCmd } from './commands/rollback.ts';
|
|
@@ -28,7 +29,7 @@ import { setCmd } from './commands/set.ts';
|
|
|
28
29
|
import { unsetCmd } from './commands/unset.ts';
|
|
29
30
|
import { versionsCmd } from './commands/versions.ts';
|
|
30
31
|
|
|
31
|
-
const VERSION = '1.1.
|
|
32
|
+
const VERSION = '1.1.3';
|
|
32
33
|
|
|
33
34
|
const commands: Record<string, (args: string[]) => Promise<number>> = {
|
|
34
35
|
login: loginCmd,
|
|
@@ -45,6 +46,7 @@ const commands: Record<string, (args: string[]) => Promise<number>> = {
|
|
|
45
46
|
doctor: doctorCmd,
|
|
46
47
|
'service-token': serviceTokenCmd,
|
|
47
48
|
audit: auditCmd,
|
|
49
|
+
recipients: recipientsCmd,
|
|
48
50
|
dr: drCmd,
|
|
49
51
|
users: usersCmd,
|
|
50
52
|
role: roleCmd,
|
|
@@ -86,6 +88,8 @@ Usage:
|
|
|
86
88
|
athsra service-token create <p> --label=<l> scoped service token 발급 (master pw 없이도 복호, headless 용)
|
|
87
89
|
athsra service-token list [<project>] 발급한 service token 메타 (revoke·만료 점검용)
|
|
88
90
|
athsra service-token revoke <token> service token 무효화
|
|
91
|
+
athsra recipients <project>[:<env>] envelope recipient 목록 감사 (master/member/service, 읽기 전용)
|
|
92
|
+
athsra recipients prune-orphans [<p>] [--apply] auth_tokens 에 없는 orphan service recipient 정리 (dry-run 기본)
|
|
89
93
|
athsra audit [--actor=...] [--action=...] audit log 조회 (--all=cursor follow, --format=table|json|jsonl)
|
|
90
94
|
athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
|
|
91
95
|
athsra role {grant|revoke} <user_id> <R> role 부여/제거 (admin/dev/viewer/auditor/sa)
|
package/src/lib/client.ts
CHANGED
|
@@ -271,15 +271,30 @@ export class AthsraClient {
|
|
|
271
271
|
* Phase 3a — 자기 master pw proof bootstrap-or-verify (POST /auth/proof, Bearer).
|
|
272
272
|
* proof = deriveProof(masterPw, userId, GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
|
|
273
273
|
* 첫 호출 = bootstrap, 이후 = verify (불일치 시 worker 409 → throw).
|
|
274
|
+
*
|
|
275
|
+
* @param legacyProofs - 옛 스킴(gen-0) proof 후보 (버전 업그레이드 무중단 마이그레이션). 현 proof
|
|
276
|
+
* 가 mismatch 일 때 worker 가 이 중 하나로 검증 성공 시 자기 row 를 현 스킴(G-1)으로 자동 재작성
|
|
277
|
+
* (migrated:true). register 의 legacy_proofs 와 동일 의미론.
|
|
274
278
|
*/
|
|
275
279
|
async setProof(
|
|
276
280
|
proof: string,
|
|
277
281
|
globalSaltVersion?: string,
|
|
278
|
-
|
|
282
|
+
legacyProofs?: string[],
|
|
283
|
+
): Promise<{
|
|
284
|
+
ok: boolean;
|
|
285
|
+
bootstrap: boolean;
|
|
286
|
+
userId: number;
|
|
287
|
+
version_reset?: boolean;
|
|
288
|
+
migrated?: boolean;
|
|
289
|
+
}> {
|
|
279
290
|
const res = await fetch(this.url('/auth/proof'), {
|
|
280
291
|
method: 'POST',
|
|
281
292
|
headers: this.headers({ 'content-type': 'application/json' }),
|
|
282
|
-
body: JSON.stringify({
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
proof,
|
|
295
|
+
global_salt_version: globalSaltVersion,
|
|
296
|
+
...(legacyProofs && legacyProofs.length > 0 ? { legacy_proofs: legacyProofs } : {}),
|
|
297
|
+
}),
|
|
283
298
|
});
|
|
284
299
|
if (!res.ok) throw new Error(`set proof ${res.status}: ${await res.text()}`);
|
|
285
300
|
return (await res.json()) as {
|
|
@@ -287,6 +302,7 @@ export class AthsraClient {
|
|
|
287
302
|
bootstrap: boolean;
|
|
288
303
|
userId: number;
|
|
289
304
|
version_reset?: boolean;
|
|
305
|
+
migrated?: boolean;
|
|
290
306
|
};
|
|
291
307
|
}
|
|
292
308
|
|
|
@@ -395,14 +411,27 @@ export class AthsraClient {
|
|
|
395
411
|
return (await res.json()) as { token: string; org_id: number; role: string };
|
|
396
412
|
}
|
|
397
413
|
|
|
398
|
-
async revoke(targetToken?: string): Promise<{
|
|
414
|
+
async revoke(targetToken?: string): Promise<{
|
|
415
|
+
ok: boolean;
|
|
416
|
+
revoked: string;
|
|
417
|
+
self?: boolean;
|
|
418
|
+
/** service token revoke 시 worker 가 동반 반환 — CLI 가 envelope recipient 정리에 사용. */
|
|
419
|
+
recipient_id?: string;
|
|
420
|
+
project?: string;
|
|
421
|
+
}> {
|
|
399
422
|
const res = await fetch(this.url('/auth/revoke'), {
|
|
400
423
|
method: 'POST',
|
|
401
424
|
headers: this.headers({ 'content-type': 'application/json' }),
|
|
402
425
|
body: JSON.stringify(targetToken ? { token: targetToken } : {}),
|
|
403
426
|
});
|
|
404
427
|
if (!res.ok) throw new Error(`revoke ${res.status}: ${await res.text()}`);
|
|
405
|
-
return (await res.json()) as {
|
|
428
|
+
return (await res.json()) as {
|
|
429
|
+
ok: boolean;
|
|
430
|
+
revoked: string;
|
|
431
|
+
self?: boolean;
|
|
432
|
+
recipient_id?: string;
|
|
433
|
+
project?: string;
|
|
434
|
+
};
|
|
406
435
|
}
|
|
407
436
|
|
|
408
437
|
/** DR — BACKUP_STORE → STORE 복원. dry_run=!execute. execute 는 content-bound confirm 필수. */
|
package/src/lib/config.ts
CHANGED
|
@@ -2,9 +2,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* config 디렉터리 — 호출 시점 해석. `ATHSRA_CONFIG_DIR` 로 override (테스트 격리 / 개발자 개인
|
|
7
|
+
* 분리). 미설정 = `~/.athsra`. 경로를 모듈 로드 시점 상수가 아닌 함수로 둬 env override 가
|
|
8
|
+
* 호출마다 반영된다(테스트가 tmp 디렉터리로 안전히 격리 가능 — 실 config 오염 차단).
|
|
9
|
+
*/
|
|
10
|
+
export function configDir(): string {
|
|
11
|
+
return process.env.ATHSRA_CONFIG_DIR ?? join(homedir(), '.athsra');
|
|
12
|
+
}
|
|
13
|
+
export function configFile(): string {
|
|
14
|
+
return join(configDir(), 'config.json');
|
|
15
|
+
}
|
|
16
|
+
export function sessionFile(): string {
|
|
17
|
+
return join(configDir(), 'session');
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
export interface Config {
|
|
10
21
|
workerUrl: string;
|
|
@@ -21,17 +32,19 @@ export interface Config {
|
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export function ensureConfigDir(): void {
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
const dir = configDir();
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export function loadConfig(): Config | null {
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
const file = configFile();
|
|
43
|
+
if (!existsSync(file)) return null;
|
|
44
|
+
return JSON.parse(readFileSync(file, 'utf-8')) as Config;
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
export function saveConfig(config: Config): void {
|
|
35
48
|
ensureConfigDir();
|
|
36
|
-
writeFileSync(
|
|
49
|
+
writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
37
50
|
}
|
package/src/lib/device-login.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { deviceKeyFingerprint, generateDeviceKeypair } from '@athsra/crypto/devi
|
|
|
13
13
|
import { AthsraClient, type DevicePollResult } from './client.ts';
|
|
14
14
|
import { loadConfig, saveConfig } from './config.ts';
|
|
15
15
|
import { unsealIdentityKey } from './identity-key.ts';
|
|
16
|
-
import { probeKeyring, setIdentityKey, setToken } from './keyring.ts';
|
|
16
|
+
import { clearMasterPw, probeKeyring, setIdentityKey, setToken } from './keyring.ts';
|
|
17
17
|
|
|
18
18
|
/** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
|
|
19
19
|
export const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
|
|
@@ -147,6 +147,10 @@ export async function completeIdentityLogin(args: {
|
|
|
147
147
|
);
|
|
148
148
|
setIdentityKey(args.machineId, identityPrivB64);
|
|
149
149
|
setToken(args.machineId, args.result.token);
|
|
150
|
+
// identity 모드는 정의상 이 기기에 master pw 없음 (D-2). 과거 password/SSO 로그인이 남긴 stale
|
|
151
|
+
// master-pw 를 제거 — 안 그러면 loadAuthContext 가 user-mode(캐시 master-pw)를 identity-mode
|
|
152
|
+
// 보다 먼저 반환해 identity 의도를 가린다(우선순위 버그 근본 차단). clearMasterPw 는 멱등.
|
|
153
|
+
clearMasterPw(args.machineId);
|
|
150
154
|
saveConfig({
|
|
151
155
|
workerUrl: args.workerUrl,
|
|
152
156
|
machineId: args.machineId,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { sessionFile } from './config.ts';
|
|
3
3
|
|
|
4
4
|
interface LegacySession {
|
|
5
5
|
masterPw: string;
|
|
@@ -14,10 +14,11 @@ interface LegacySession {
|
|
|
14
14
|
* 기존 사용자 1회 migration 후 영구 deprecated.
|
|
15
15
|
*/
|
|
16
16
|
export function consumeLegacySession(): { masterPw: string } | null {
|
|
17
|
-
|
|
17
|
+
const file = sessionFile();
|
|
18
|
+
if (!existsSync(file)) return null;
|
|
18
19
|
let parsed: LegacySession | null = null;
|
|
19
20
|
try {
|
|
20
|
-
const raw = readFileSync(
|
|
21
|
+
const raw = readFileSync(file, 'utf-8');
|
|
21
22
|
if (raw) {
|
|
22
23
|
const json = JSON.parse(raw) as LegacySession;
|
|
23
24
|
if (typeof json.masterPw === 'string' && json.masterPw.length > 0) {
|
|
@@ -28,7 +29,7 @@ export function consumeLegacySession(): { masterPw: string } | null {
|
|
|
28
29
|
/* fall through to delete */
|
|
29
30
|
}
|
|
30
31
|
try {
|
|
31
|
-
unlinkSync(
|
|
32
|
+
unlinkSync(file);
|
|
32
33
|
} catch {
|
|
33
34
|
/* ignore */
|
|
34
35
|
}
|
|
@@ -8,19 +8,24 @@
|
|
|
8
8
|
* → loadAuthContext → kind 가드 → client 호출 → jsonOk.
|
|
9
9
|
*
|
|
10
10
|
* kind 가드 2단 (epic D-2):
|
|
11
|
-
* - **master pw 소비
|
|
12
|
-
* 재포장
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* - **master pw 소비 2종** (org_remove_member 의 owner DEK 회전 / project_share 의 envelope
|
|
12
|
+
* 재포장) 은 user(master pw) 전용 — identity 디바이스에선 masterPwDenied 로 actionable 거부.
|
|
13
|
+
* - service_token_create 는 user(master 경로) | identity(member 경로, 본인 raw privkey 로 DEK
|
|
14
|
+
* unwrap — AI-native onboarding) 둘 다 — service token 만 거부.
|
|
15
|
+
* - 나머지는 user|identity (둘 다 user-급 토큰) — service token 은 7종 전부 거부.
|
|
15
16
|
*
|
|
16
17
|
* 시크릿 값 무노출 (epic D-1) — 유일 예외는 service_token_create 의 1회성 ats_* 반환
|
|
17
18
|
* (ONE-TIME EXPOSURE warning 동반). 그 외 응답은 메타데이터만.
|
|
18
19
|
*/
|
|
19
20
|
|
|
21
|
+
import { removeRecipient } from '@athsra/crypto';
|
|
20
22
|
import { loadAuthContext } from '../auth-context.ts';
|
|
21
23
|
import type { AthsraClient } from '../client.ts';
|
|
22
24
|
import { grantOrgAccess, rotateAfterRemoval } from '../org-rewrap.ts';
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
createServiceTokenWithRecipient,
|
|
27
|
+
createServiceTokenWithRecipientAsMember,
|
|
28
|
+
} from '../service-tokens.ts';
|
|
24
29
|
import { readInteger, readString } from './args.ts';
|
|
25
30
|
import { requireConfirm } from './confirm.ts';
|
|
26
31
|
import { jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
|
|
@@ -191,7 +196,7 @@ export async function handleProjectUnshare(
|
|
|
191
196
|
/**
|
|
192
197
|
* athsra_service_token_create — scoped service token 발급 + envelope recipient 추가.
|
|
193
198
|
* expires_days 필수 1..365 (MCP 발급 자격증명은 반드시 만료). 유일한 자격증명-반환 도구.
|
|
194
|
-
* master pw
|
|
199
|
+
* user(master pw) 또는 identity(device login, member 경로 — AI-native) 컨텍스트. service 거부.
|
|
195
200
|
*/
|
|
196
201
|
export async function handleServiceTokenCreate(
|
|
197
202
|
args: Record<string, unknown> | undefined,
|
|
@@ -211,13 +216,21 @@ export async function handleServiceTokenCreate(
|
|
|
211
216
|
}
|
|
212
217
|
const ctx = await loadAuthContext();
|
|
213
218
|
if (!ctx) return notLoggedIn();
|
|
214
|
-
if (ctx.kind
|
|
215
|
-
const created =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
if (ctx.kind === 'service') return masterPwDenied('service_token_create', 'service');
|
|
220
|
+
const created =
|
|
221
|
+
ctx.kind === 'identity'
|
|
222
|
+
? await createServiceTokenWithRecipientAsMember(ctx, {
|
|
223
|
+
project,
|
|
224
|
+
label,
|
|
225
|
+
perms,
|
|
226
|
+
expiresInDays: expiresDays,
|
|
227
|
+
})
|
|
228
|
+
: await createServiceTokenWithRecipient(ctx, {
|
|
229
|
+
project,
|
|
230
|
+
label,
|
|
231
|
+
perms,
|
|
232
|
+
expiresInDays: expiresDays,
|
|
233
|
+
});
|
|
221
234
|
return jsonOk({
|
|
222
235
|
...created,
|
|
223
236
|
warning:
|
|
@@ -238,10 +251,29 @@ export async function handleServiceTokenRevoke(
|
|
|
238
251
|
if (!ctx) return notLoggedIn();
|
|
239
252
|
if (ctx.kind === 'service') return masterPwDenied('service_token_revoke', 'service');
|
|
240
253
|
const res = await ctx.client.revoke(token);
|
|
254
|
+
// 근본수정: revoke 후 envelope service recipient 동반 정리(orphan 재발 차단). best-effort —
|
|
255
|
+
// PUT 은 owner 강제. const 캡처로 closure narrowing 유지.
|
|
256
|
+
const recipientId = res.recipient_id;
|
|
257
|
+
const project = res.project;
|
|
258
|
+
let recipientCleaned = false;
|
|
259
|
+
if (recipientId && project) {
|
|
260
|
+
try {
|
|
261
|
+
const env = await ctx.client.getEnvelope(project);
|
|
262
|
+
if (env?.version === 2 && env.recipients.some((r) => r.id === recipientId)) {
|
|
263
|
+
await ctx.client.putEnvelope(project, removeRecipient(env, recipientId));
|
|
264
|
+
recipientCleaned = true;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// best-effort — prune-orphans 로 폴백 (note 로 안내).
|
|
268
|
+
}
|
|
269
|
+
}
|
|
241
270
|
return jsonOk({
|
|
242
271
|
ok: res.ok,
|
|
243
272
|
revoked: res.revoked,
|
|
244
|
-
|
|
273
|
+
recipient_cleaned: recipientCleaned,
|
|
274
|
+
note: recipientCleaned
|
|
275
|
+
? 'worker auth rejects the token immediately; the envelope service recipient was also pruned.'
|
|
276
|
+
: 'worker auth rejects the token immediately. If a stale service recipient remains, run `athsra recipients prune-orphans <project>`.',
|
|
245
277
|
});
|
|
246
278
|
}
|
|
247
279
|
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `commands/service-token.ts` createCmd 에서 추출 — CLI 와 MCP(athsra_service_token_create)가
|
|
5
5
|
* 같은 경로를 소비해 드리프트를 막는다. 발급(worker) → v1→v2 migrate(필요 시) →
|
|
6
|
-
* addServiceRecipient(DEK 를 token secret 으로 wrap) → putEnvelope.
|
|
7
|
-
*
|
|
6
|
+
* addServiceRecipient(DEK 를 token secret 으로 wrap) → putEnvelope.
|
|
7
|
+
*
|
|
8
|
+
* 두 경로: (1) user(master pw) — master recipient 로 DEK unwrap (v1 자동 migrate 가능). (2) identity
|
|
9
|
+
* 디바이스(master pw 부재) — 본인 raw identity privkey(member:userId 경로)로 DEK unwrap. AI-native
|
|
10
|
+
* onboarding: `athsra login`(브라우저) 후 master pw 없이도 발급(member recipient 존재 + v2 전제).
|
|
8
11
|
*/
|
|
9
12
|
import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
|
|
10
|
-
import
|
|
13
|
+
import { addServiceRecipientAsMember } from '@athsra/crypto/member';
|
|
14
|
+
import type { IdentityAuthContext, UserAuthContext } from './auth-context.ts';
|
|
11
15
|
|
|
12
16
|
export interface CreatedServiceToken {
|
|
13
17
|
token: string;
|
|
@@ -60,3 +64,52 @@ export async function createServiceTokenWithRecipient(
|
|
|
60
64
|
|
|
61
65
|
return created;
|
|
62
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* identity 디바이스 모드(master pw 부재) 발급 — 본인 raw identity privkey(member:userId 경로)로
|
|
70
|
+
* DEK 를 unwrap 해 service token secret 으로 wrap. v1 envelope / member recipient 부재 시 throw
|
|
71
|
+
* (둘 다 master pw 머신에서 1회 준비 필요 — v1→v2 마이그레이션, migrate-envelopes --self). recipient
|
|
72
|
+
* 추가는 worker 가 owner/admin 강제 — founding identity 디바이스(owner)는 자기 project 에 발급 가능.
|
|
73
|
+
*/
|
|
74
|
+
export async function createServiceTokenWithRecipientAsMember(
|
|
75
|
+
ctx: Pick<IdentityAuthContext, 'client' | 'identityPrivateKey' | 'userId'>,
|
|
76
|
+
args: { project: string; label: string; perms: 'read' | 'write'; expiresInDays?: number },
|
|
77
|
+
): Promise<CreatedServiceToken> {
|
|
78
|
+
const { client, identityPrivateKey, userId } = ctx;
|
|
79
|
+
|
|
80
|
+
const envelope = await client.getEnvelope(args.project);
|
|
81
|
+
if (!envelope) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`project ${args.project} envelope 없음 — master pw 머신에서 \`athsra set ${args.project} KEY=value\` 1회 실행.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (envelope.version !== 2) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`identity 모드 발급은 v2 envelope 필요 — master pw 머신에서 \`athsra set ${args.project} ...\` 1회로 v1→v2 마이그레이션 후 재시도.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!envelope.recipients.some((r) => r.id === `member:${userId}`)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`member:${userId} recipient 없음 — master pw 머신에서 \`athsra migrate-envelopes --self\` 1회 실행 후 재시도.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const created = await client.createServiceToken({
|
|
98
|
+
project: args.project,
|
|
99
|
+
label: args.label,
|
|
100
|
+
perms: args.perms,
|
|
101
|
+
expiresInDays: args.expiresInDays,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// raw identity privkey 로 member:userId recipient 의 DEK 를 unwrap → token secret 으로 wrap.
|
|
105
|
+
const updated = await addServiceRecipientAsMember(
|
|
106
|
+
envelope,
|
|
107
|
+
identityPrivateKey,
|
|
108
|
+
userId,
|
|
109
|
+
created.token,
|
|
110
|
+
created.recipient_id,
|
|
111
|
+
);
|
|
112
|
+
await client.putEnvelope(args.project, updated);
|
|
113
|
+
|
|
114
|
+
return created;
|
|
115
|
+
}
|