@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 CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@athsra/cli",
3
- "version": "1.1.4",
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": "./src/index.ts"
8
+ "athsra": "src/index.ts"
9
9
  },
10
10
  "license": "MIT",
11
11
  "repository": {
@@ -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
  }
@@ -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
- try {
34
- const created = await ensureKeypair(client, masterPw);
35
- console.log(created ? ' identity key: provisioned ✓' : ' identity key: present ✓');
36
- } catch (err) {
37
- console.warn(
38
- ` identity key: provisioning deferred (${err instanceof Error ? err.message : String(err)})`,
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
 
@@ -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] — 멤버 초대 (JIT pending)',
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. 모든 projects decrypt (옛 master pw, v1+v2 dispatcher)
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
- console.log(`\n• Decrypting ${projects.length} projects with old master pw...`);
94
- const plaintexts: Record<string, Record<string, string>> = {};
95
+ const targets: { project: string; config: string }[] = [];
95
96
  for (const p of projects) {
96
- const plain = await readPlain(ctx, p);
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
- plaintexts[p] = plain;
99
- console.log(` ✓ ${p} (${Object.keys(plain).length} keys)`);
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 ${Object.keys(plaintexts).length} projects with new master pw ` +
156
+ `\n• Re-encrypting ${decrypted.length} envelope(s) with new master pw ` +
139
157
  '(v2, Argon2id m=256MB enterprise)...',
140
158
  );
141
- for (const [p, plain] of Object.entries(plaintexts)) {
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, p, plain, { kdfParams: ENTERPRISE_KDF });
145
- console.log(` ✓ ${p}`);
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. ${Object.keys(plaintexts).length} projects re-encrypted ` +
167
+ `\n✓ master password rotated. ${decrypted.length} envelope(s) re-encrypted ` +
150
168
  'as v2 (Argon2id m=256MB).',
151
169
  );
152
170
  console.log(