@athsra/cli 1.1.4 → 1.1.6
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 +2 -2
- package/src/commands/doctor.ts +31 -0
- package/src/commands/login.ts +19 -7
- package/src/commands/org.ts +24 -2
- package/src/commands/recipients.ts +23 -0
- package/src/commands/rotate-master.ts +30 -12
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"athsra": "
|
|
8
|
+
"athsra": "src/index.ts"
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"repository": {
|
package/src/commands/doctor.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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';
|
|
8
|
+
import { summarizeMemberCoverage } from './recipients.ts';
|
|
8
9
|
|
|
9
10
|
export async function doctorCmd(args: string[]): Promise<number> {
|
|
10
11
|
const audit = args.includes('--audit');
|
|
@@ -68,13 +69,28 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
68
69
|
console.log(` info: error — ${(err as Error).message}`);
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
let selfUserId: number | undefined;
|
|
71
73
|
if (token) {
|
|
72
74
|
try {
|
|
73
75
|
const me = await client.whoami();
|
|
76
|
+
selfUserId = me.userId;
|
|
74
77
|
console.log(` whoami: machineId=${me.machineId} lastSeenAt=${me.lastSeenAt}`);
|
|
75
78
|
} catch (err) {
|
|
76
79
|
console.log(` whoami: ✗ ${(err as Error).message}`);
|
|
77
80
|
}
|
|
81
|
+
// identity key (server) — 다른 머신·위치의 브라우저 device-login 이 sealed key 를 받는 전제.
|
|
82
|
+
try {
|
|
83
|
+
const hasIdentityKey = (await client.getKeys()) !== null;
|
|
84
|
+
console.log(
|
|
85
|
+
` identity key (server): ${
|
|
86
|
+
hasIdentityKey
|
|
87
|
+
? '✓ provisioned (어디서든 device-login 가능)'
|
|
88
|
+
: '✗ missing — 다른 머신 device-login 불가. `athsra login` 재실행으로 생성.'
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.log(` identity key (server): ? ${(err as Error).message}`);
|
|
93
|
+
}
|
|
78
94
|
try {
|
|
79
95
|
const projects = await client.listProjects();
|
|
80
96
|
const head = projects.slice(0, 5).join(', ');
|
|
@@ -82,6 +98,21 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
82
98
|
console.log(
|
|
83
99
|
` projects: ${projects.length}${projects.length ? ` (${head}${more})` : ''}`,
|
|
84
100
|
);
|
|
101
|
+
// recipient 커버리지 — 브라우저 로그인(identity) 머신이 master pw 없이 복호 가능한 프로젝트 수.
|
|
102
|
+
if (selfUserId !== undefined && projects.length > 0) {
|
|
103
|
+
const uid = selfUserId;
|
|
104
|
+
const envs = await Promise.all(
|
|
105
|
+
projects.map((p) => client.getEnvelope(p).catch(() => null)),
|
|
106
|
+
);
|
|
107
|
+
const cov = summarizeMemberCoverage(projects, envs, uid);
|
|
108
|
+
console.log(
|
|
109
|
+
` recipient coverage: ${cov.covered}/${cov.total} member-accessible (브라우저 로그인으로 복호)`,
|
|
110
|
+
);
|
|
111
|
+
if (cov.gaps.length > 0) {
|
|
112
|
+
console.log(` ⚠ master-only (브라우저 로그인 복호 불가): ${cov.gaps.join(', ')}`);
|
|
113
|
+
console.log(' → 소유자가 `athsra migrate-envelopes --self --apply` 로 보강.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
85
116
|
} catch (err) {
|
|
86
117
|
console.log(` projects: fetch error — ${(err as Error).message}`);
|
|
87
118
|
}
|
package/src/commands/login.ts
CHANGED
|
@@ -30,13 +30,25 @@ import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
|
|
|
30
30
|
* 실제 공유는 Slice 6. master pw 로 wrap 된 privkey 만 server 보관(평문 미노출).
|
|
31
31
|
*/
|
|
32
32
|
async function provisionIdentityKey(client: AthsraClient, masterPw: string): Promise<void> {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
// identity key = "어디서든 브라우저 device-login" 의 substrate (다른 머신이 sealed key 를 받는 전제).
|
|
34
|
+
// transient 실패에 1회 재시도, 영구 실패면 silent 가 아니라 결과(다른 머신 device-login 불가)를
|
|
35
|
+
// 명시 + 멱등 복구 경로 안내. master-pw 사용 자체는 정상이므로 login 을 hard-fail 하진 않는다.
|
|
36
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
const created = await ensureKeypair(client, masterPw);
|
|
39
|
+
console.log(created ? ' identity key: provisioned ✓' : ' identity key: present ✓');
|
|
40
|
+
return;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (attempt < 2) continue;
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.warn(` ⚠ identity key 미provisioning (${msg})`);
|
|
45
|
+
console.warn(
|
|
46
|
+
' → 다른 머신·위치에서 브라우저 device-login 이 불가합니다 (이 머신의 master-pw 접근은 정상).',
|
|
47
|
+
);
|
|
48
|
+
console.warn(
|
|
49
|
+
' → 복구: 네트워크 확인 후 `athsra login` 재실행(멱등). 상태는 `athsra doctor` 로 확인.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
54
|
|
package/src/commands/org.ts
CHANGED
|
@@ -22,7 +22,7 @@ const USAGE = [
|
|
|
22
22
|
' athsra org create <name> — company org 생성 (owner)',
|
|
23
23
|
' athsra org ls — 내가 멤버인 org 목록 (* = 현재)',
|
|
24
24
|
' athsra org members — 현재 org 멤버',
|
|
25
|
-
' athsra org invite <identifier> [--role=member|admin] — 멤버 초대 (
|
|
25
|
+
' athsra org invite <identifier> [--role=member|admin] [--grant] — 멤버 초대 (--grant: 공유 즉시 re-wrap)',
|
|
26
26
|
' athsra org remove <user_id> — 멤버 제거 (owner 면 DEK 자동 회전)',
|
|
27
27
|
' athsra org rotate-after-removal <user_id> — 제거 후 DEK 회전 재개 (부분 실패 복구, owner)',
|
|
28
28
|
' athsra org grant-access <identifier|id> [--replace] — 멤버에게 공유 시크릿 re-wrap (--replace=key reset 복구)',
|
|
@@ -130,7 +130,7 @@ async function membersCmd(): Promise<number> {
|
|
|
130
130
|
async function inviteCmd(args: string[]): Promise<number> {
|
|
131
131
|
const identifier = args.filter((a) => !a.startsWith('-'))[0];
|
|
132
132
|
if (!identifier) {
|
|
133
|
-
console.error('usage: athsra org invite <identifier> [--role=member|admin]');
|
|
133
|
+
console.error('usage: athsra org invite <identifier> [--role=member|admin] [--grant]');
|
|
134
134
|
return 2;
|
|
135
135
|
}
|
|
136
136
|
let role: 'member' | 'admin' = 'member';
|
|
@@ -153,6 +153,28 @@ async function inviteCmd(args: string[]): Promise<number> {
|
|
|
153
153
|
console.log(
|
|
154
154
|
' 초대받은 사람: athsra login → `athsra org ls` 에 pending 표시 → `athsra org use` 로 수락',
|
|
155
155
|
);
|
|
156
|
+
// --grant: 초대와 동시에 공유 시크릿 re-wrap(member recipient 추가) — 멤버가 이미 identity 키를
|
|
157
|
+
// provisioning(로그인)했다면 1-command 으로 접근 부여. 키 부재면 명시 안내(로그인 후 grant-access).
|
|
158
|
+
if (args.includes('--grant')) {
|
|
159
|
+
try {
|
|
160
|
+
const g = await grantOrgAccess(ctx.client, ctx.masterPw, res.user_id, {});
|
|
161
|
+
if (g.granted.length > 0) {
|
|
162
|
+
console.log(`✓ 공유 re-wrap — granted ${g.granted.length}: ${g.granted.join(', ')}`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log(
|
|
165
|
+
' (re-wrap 대상 없음 — 멤버 identity 키 미provisioning 또는 v2 envelope 없음. 멤버 `athsra login` 후 `athsra org grant-access` 재시도)',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (g.legacyV1.length > 0) {
|
|
169
|
+
console.log(` v1 envelope(먼저 \`athsra migrate-envelopes\`): ${g.legacyV1.join(', ')}`);
|
|
170
|
+
}
|
|
171
|
+
for (const f of g.failed) console.error(` ✗ ${f.project}: ${f.error}`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.warn(
|
|
174
|
+
` ⚠ 자동 공유 실패 (${(err as Error).message}) — 멤버 \`athsra login\` 후 \`athsra org grant-access ${identifier}\`.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
156
178
|
return 0;
|
|
157
179
|
} catch (err) {
|
|
158
180
|
const msg = (err as Error).message;
|
|
@@ -38,6 +38,29 @@ export async function recipientsCmd(args: string[]): Promise<number> {
|
|
|
38
38
|
return listRecipientsCmd(args);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* member-recipient 커버리지 요약 — browser-login(identity) 머신이 복호 가능한 프로젝트 수.
|
|
43
|
+
* envelope 에 `member:<userId>` 가 있어야 master pw 없이 복호된다 (`athsra doctor` 가 사용).
|
|
44
|
+
* 순수 함수 (네트워크 X) — fetch 한 envelope 배열을 받아 집계만 한다.
|
|
45
|
+
*/
|
|
46
|
+
export function summarizeMemberCoverage(
|
|
47
|
+
projects: string[],
|
|
48
|
+
envelopes: ({ version: number; recipients?: { id: string }[] } | null)[],
|
|
49
|
+
userId: number,
|
|
50
|
+
): { covered: number; total: number; gaps: string[] } {
|
|
51
|
+
const gaps: string[] = [];
|
|
52
|
+
let covered = 0;
|
|
53
|
+
projects.forEach((project, i) => {
|
|
54
|
+
const env = envelopes[i];
|
|
55
|
+
if (env && env.version === 2 && env.recipients?.some((r) => r.id === `member:${userId}`)) {
|
|
56
|
+
covered++;
|
|
57
|
+
} else {
|
|
58
|
+
gaps.push(project);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return { covered, total: projects.length, gaps };
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
async function listRecipientsCmd(args: string[]): Promise<number> {
|
|
42
65
|
if (args.includes('--help') || args.includes('-h')) {
|
|
43
66
|
console.log(USAGE);
|
|
@@ -88,15 +88,33 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// 1. 모든
|
|
91
|
+
// 1. 모든 project × 모든 활성 config decrypt (옛 master pw, v1+v2 dispatcher). 비-default 환경
|
|
92
|
+
// (dev/staging/prod)까지 포함해야 한다 — 누락하면 회전 후 옛 pw 소실로 그 환경 시크릿이 영구
|
|
93
|
+
// 복호 불가(데이터 손실). dashboard lib/rotate-master 와 동일 정합.
|
|
92
94
|
const projects = await client.listProjects();
|
|
93
|
-
|
|
94
|
-
const plaintexts: Record<string, Record<string, string>> = {};
|
|
95
|
+
const targets: { project: string; config: string }[] = [];
|
|
95
96
|
for (const p of projects) {
|
|
96
|
-
|
|
97
|
+
try {
|
|
98
|
+
const res = await client.listConfigs(p);
|
|
99
|
+
const active = res.configs.filter((c) => c.active && !c.deleted).map((c) => c.config);
|
|
100
|
+
if (active.length === 0) targets.push({ project: p, config: 'default' });
|
|
101
|
+
else for (const config of active) targets.push({ project: p, config });
|
|
102
|
+
} catch {
|
|
103
|
+
// listConfigs 실패 — 최소 default 는 시도(누락보다 안전).
|
|
104
|
+
targets.push({ project: p, config: 'default' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const cfgLabel = (t: { project: string; config: string }) =>
|
|
108
|
+
t.config === 'default' ? t.project : `${t.project}:${t.config}`;
|
|
109
|
+
console.log(
|
|
110
|
+
`\n• Decrypting ${targets.length} envelope(s) across ${projects.length} project(s) with old master pw...`,
|
|
111
|
+
);
|
|
112
|
+
const decrypted: { project: string; config: string; plain: Record<string, string> }[] = [];
|
|
113
|
+
for (const t of targets) {
|
|
114
|
+
const plain = await readPlain(ctx, t.project, t.config);
|
|
97
115
|
if (!plain) continue;
|
|
98
|
-
|
|
99
|
-
console.log(` ✓ ${
|
|
116
|
+
decrypted.push({ project: t.project, config: t.config, plain });
|
|
117
|
+
console.log(` ✓ ${cfgLabel(t)} (${Object.keys(plain).length} keys)`);
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
// 2. identity 키쌍 재포장 재료 계산 (있으면) — old pw 로 unwrap → new pw 로 wrap. proof 회전
|
|
@@ -135,18 +153,18 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
|
135
153
|
|
|
136
154
|
// 4. re-encrypt + PUT (v2 envelope, 자동 마이그레이션)
|
|
137
155
|
console.log(
|
|
138
|
-
`\n• Re-encrypting ${
|
|
156
|
+
`\n• Re-encrypting ${decrypted.length} envelope(s) with new master pw ` +
|
|
139
157
|
'(v2, Argon2id m=256MB enterprise)...',
|
|
140
158
|
);
|
|
141
|
-
for (const
|
|
159
|
+
for (const { project, config, plain } of decrypted) {
|
|
142
160
|
// rotate-master = enterprise KDF 마이그레이션 지점 (m=256MB). 이후 routine set 은
|
|
143
|
-
// writePlain 의 보존 로직으로 256MB 유지 (silent downgrade 없음).
|
|
144
|
-
await writePlain(newCtx,
|
|
145
|
-
console.log(` ✓ ${
|
|
161
|
+
// writePlain 의 보존 로직으로 256MB 유지 (silent downgrade 없음). config 전파 필수.
|
|
162
|
+
await writePlain(newCtx, project, plain, { kdfParams: ENTERPRISE_KDF, config });
|
|
163
|
+
console.log(` ✓ ${cfgLabel({ project, config })}`);
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
console.log(
|
|
149
|
-
`\n✓ master password rotated. ${
|
|
167
|
+
`\n✓ master password rotated. ${decrypted.length} envelope(s) re-encrypted ` +
|
|
150
168
|
'as v2 (Argon2id m=256MB).',
|
|
151
169
|
);
|
|
152
170
|
console.log(
|