@athsra/cli 1.1.1 → 1.1.2
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 +56 -0
- package/src/index.ts +4 -1
- package/src/lib/client.ts +18 -2
- package/src/lib/config.ts +21 -8
- package/src/lib/device-login.ts +5 -1
- package/src/lib/legacy-session.ts +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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,56 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
|
|
3
|
+
|
|
4
|
+
const USAGE = [
|
|
5
|
+
'usage: athsra recipients <project>[:<env>]',
|
|
6
|
+
'',
|
|
7
|
+
'envelope 의 recipient 목록(읽기 전용) — master / member / service token 분포 감사.',
|
|
8
|
+
'값·평문 미노출 (recipient kind·id 만 표시). service recipient_id 는 비밀 아님',
|
|
9
|
+
'(whoami / service-token list 가 이미 노출).',
|
|
10
|
+
'',
|
|
11
|
+
CONFIG_USAGE_HINT,
|
|
12
|
+
].join('\n');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 인시던트 후속(2026-06-14) — envelope recipient 구조 운영 가시성. founding sweep / rotate-master /
|
|
16
|
+
* 멤버 removal 후 service:* recipient 가 보존/탈락됐는지 `athsra service-token list` 와 대조 감사.
|
|
17
|
+
* getEnvelope 가 이미 recipients[] 를 주므로 복호 불필요 → 모든 인증 모드(user/identity/service)에서 동작.
|
|
18
|
+
*/
|
|
19
|
+
export async function recipientsCmd(args: string[]): Promise<number> {
|
|
20
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
21
|
+
console.log(USAGE);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
const { project, config } = resolveProject(args, { requirePositional: true });
|
|
25
|
+
if (!project) {
|
|
26
|
+
console.error(USAGE);
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ctx = await loadAuthContext(project);
|
|
31
|
+
if (!ctx) return 1;
|
|
32
|
+
|
|
33
|
+
const env = await ctx.client.getEnvelope(project, config);
|
|
34
|
+
if (!env) {
|
|
35
|
+
console.error(`project not found: ${project}${configTag(config)}`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tag = configTag(config);
|
|
40
|
+
if (env.version !== 2) {
|
|
41
|
+
console.log(
|
|
42
|
+
`${project}${tag}: v1 envelope — single master recipient (run \`athsra rotate-master\` → v2)`,
|
|
43
|
+
);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`${project}${tag} — ${env.recipients.length} recipient(s):`);
|
|
48
|
+
for (const r of env.recipients) {
|
|
49
|
+
console.log(` ${r.kind.padEnd(8)} ${r.id}`);
|
|
50
|
+
}
|
|
51
|
+
const master = env.recipients.filter((r) => r.kind === 'master').length;
|
|
52
|
+
const service = env.recipients.filter((r) => r.kind === 'service').length;
|
|
53
|
+
const member = env.recipients.filter((r) => r.kind === 'member').length;
|
|
54
|
+
console.log(`\n요약: master ${master} · service ${service} · member ${member}`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
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.2';
|
|
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,7 @@ 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, 읽기 전용)
|
|
89
92
|
athsra audit [--actor=...] [--action=...] audit log 조회 (--all=cursor follow, --format=table|json|jsonl)
|
|
90
93
|
athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
|
|
91
94
|
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
|
|
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
|
}
|