@athsra/cli 1.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@athsra/cli",
3
- "version": "1.1.0",
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",
@@ -1,6 +1,7 @@
1
1
  import { hostname } from 'node:os';
2
2
  import { isValidPhrase, normalizePhrase, wordCount } from '@athsra/crypto';
3
- import { deriveMasterProof } from '../lib/auth-proof.ts';
3
+ import type { UserAuthContext } from '../lib/auth-context.ts';
4
+ import { deriveLegacyMasterProofs, deriveMasterProof } from '../lib/auth-proof.ts';
4
5
  import { resolveProject } from '../lib/auto-project.ts';
5
6
  import { AthsraClient } from '../lib/client.ts';
6
7
  import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
@@ -11,6 +12,7 @@ import {
11
12
  runDevicePollLoop,
12
13
  startIdentityFlow,
13
14
  } from '../lib/device-login.ts';
15
+ import { readPlain } from '../lib/envelope.ts';
14
16
  import { ensureKeypair } from '../lib/identity-key.ts';
15
17
  import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
16
18
  import { consumeLegacySession } from '../lib/legacy-session.ts';
@@ -551,11 +553,13 @@ async function passwordLoginCmd(): Promise<number> {
551
553
  // 다중 사용자 self-serve password register 는 out-of-scope(SSO-gated 유지).
552
554
  const FOUNDING_USER_ID = 1;
553
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);
554
558
 
555
559
  // 8. register → token
556
560
  let reg: Awaited<ReturnType<typeof tempClient.register>>;
557
561
  try {
558
- reg = await tempClient.register(proofBase64, machineId);
562
+ reg = await tempClient.register(proofBase64, machineId, legacyProofs);
559
563
  if (reg.userId !== undefined && reg.userId !== FOUNDING_USER_ID) {
560
564
  console.error(
561
565
  `✗ register user mismatch (${reg.userId} ≠ ${FOUNDING_USER_ID}) — worker 확인 필요.`,
@@ -583,12 +587,50 @@ async function passwordLoginCmd(): Promise<number> {
583
587
  saveConfig(config);
584
588
  setMasterPw(machineId, masterPw);
585
589
  setToken(machineId, reg.token);
586
- await provisionIdentityKey(new AthsraClient(workerUrl, reg.token), masterPw);
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
+ }
587
626
 
588
627
  console.log(`\n✓ logged in (machine: ${machineId})`);
589
628
  console.log(` worker: ${workerUrl}`);
590
629
  console.log(` config: ~/.athsra/config.json`);
591
630
  console.log(' keyring: master-pw + token saved (OS keyring, 무기한)');
631
+ if (reg.migrated) {
632
+ console.log(' proof: 옛 스킴 → 현 스킴 자동 마이그레이션 ✓ (CLI 버전 정합, 무중단)');
633
+ }
592
634
  if (legacy) {
593
635
  console.log(' legacy: ~/.athsra/session migrated and removed.');
594
636
  }
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ import { setCmd } from './commands/set.ts';
28
28
  import { unsetCmd } from './commands/unset.ts';
29
29
  import { versionsCmd } from './commands/versions.ts';
30
30
 
31
- const VERSION = '1.1.0';
31
+ const VERSION = '1.1.1';
32
32
 
33
33
  const commands: Record<string, (args: string[]) => Promise<number>> = {
34
34
  login: loginCmd,
@@ -1,5 +1,5 @@
1
1
  import { fromBase64 } from '@athsra/crypto';
2
- import { deriveMasterProof } from './auth-proof.ts';
2
+ import { deriveLegacyMasterProofs, deriveMasterProof } from './auth-proof.ts';
3
3
  import { AthsraClient } from './client.ts';
4
4
  import { type Config, loadConfig, saveConfig } from './config.ts';
5
5
  import { getDeviceToken, getIdentityKey, getMasterPw, getToken, setToken } from './keyring.ts';
@@ -125,7 +125,9 @@ async function loadUserContext(): Promise<UserAuthContext | null> {
125
125
  if (config.userId !== 1) return null;
126
126
  const info = await client.info();
127
127
  const proof = deriveMasterProof(masterPw, config.userId, info.global_salt);
128
- const reg = await client.register(proof, config.machineId);
128
+ // 버전 업그레이드 무중단: 저장 proof 가 옛 스킴이면 worker 가 자동 G-1 마이그레이션.
129
+ const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
130
+ const reg = await client.register(proof, config.machineId, legacyProofs);
129
131
  if (reg.userId !== undefined && reg.userId !== config.userId) return null;
130
132
  let token = reg.token;
131
133
  // Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
@@ -1,4 +1,4 @@
1
- import { deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
1
+ import { deriveKey, deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
2
2
 
3
3
  /** G-1 auth proof — per-user salt + GLOBAL_SALT pepper, base64 wire format. */
4
4
  export function deriveMasterProof(
@@ -8,3 +8,19 @@ export function deriveMasterProof(
8
8
  ): string {
9
9
  return toBase64(deriveProof(masterPw, userId, fromBase64(globalSaltBase64)));
10
10
  }
11
+
12
+ /**
13
+ * 레거시 proof (pre-G-1, ≤1.0.2): `deriveKey(masterPw, GLOBAL_SALT)` — userId/도메인 prefix 없음.
14
+ *
15
+ * **버전 업그레이드 무중단 마이그레이션 전용.** 현 스킴(G-1) proof 가 mismatch 일 때 CLI 가 이 레거시
16
+ * proof 를 함께 보내고, worker 가 그것으로 검증 성공 시 저장 proof 를 G-1 으로 자동 재작성한다 →
17
+ * CLI 버전이 바뀌어도(옛 스킴 stored ↔ 새 스킴 client) 로그인이 깨지지 않는다. 정공법: 스킴 전환을
18
+ * 게이트가 아니라 자동 마이그레이션으로 흡수. 신규 스킴 추가 시 이 배열에 한 줄만 더하면 된다.
19
+ */
20
+ export function deriveLegacyMasterProofs(masterPw: string, globalSaltBase64: string): string[] {
21
+ const salt = fromBase64(globalSaltBase64);
22
+ return [
23
+ // gen-0 (≤1.0.2): Argon2id(masterPw, GLOBAL_SALT) — per-user salt 도입 전.
24
+ toBase64(deriveKey(masterPw, salt)),
25
+ ];
26
+ }
package/src/lib/client.ts CHANGED
@@ -18,6 +18,10 @@ export interface RegisterResponse {
18
18
  machineId: string;
19
19
  createdAt: string;
20
20
  userId?: number;
21
+ /** 첫 proof 설정(검증 불가) — typo-guard 대상. */
22
+ bootstrap?: boolean;
23
+ /** 옛 스킴 proof → 현 스킴 자동 마이그레이션됨 (버전 업그레이드 무중단). */
24
+ migrated?: boolean;
21
25
  }
22
26
 
23
27
  /** Phase 4 Slice 3 — auth_user_keys row (GET/POST /auth/keys). worker 는 base64 형식만 저장. */
@@ -235,11 +239,23 @@ export class AthsraClient {
235
239
  return (await res.json()) as WorkerInfo;
236
240
  }
237
241
 
238
- async register(masterPwProof: string, label: string): Promise<RegisterResponse> {
242
+ /**
243
+ * @param legacyProofs - 옛 스킴 proof 후보 (버전 업그레이드 무중단 마이그레이션). 현 proof 가
244
+ * mismatch 일 때 worker 가 이 중 하나로 검증 성공 시 저장 proof 를 현 스킴으로 자동 재작성.
245
+ */
246
+ async register(
247
+ masterPwProof: string,
248
+ label: string,
249
+ legacyProofs?: string[],
250
+ ): Promise<RegisterResponse> {
239
251
  const res = await fetch(this.url('/auth/register'), {
240
252
  method: 'POST',
241
253
  headers: { 'content-type': 'application/json' },
242
- body: JSON.stringify({ master_pw_proof: masterPwProof, label }),
254
+ body: JSON.stringify({
255
+ master_pw_proof: masterPwProof,
256
+ label,
257
+ ...(legacyProofs && legacyProofs.length > 0 ? { legacy_proofs: legacyProofs } : {}),
258
+ }),
243
259
  });
244
260
  if (!res.ok) throw new Error(`register ${res.status}: ${await res.text()}`);
245
261
  return (await res.json()) as RegisterResponse;