@athsra/cli 1.0.4 → 1.1.0

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.
Files changed (45) hide show
  1. package/README.md +34 -10
  2. package/package.json +3 -3
  3. package/src/commands/delete.ts +16 -13
  4. package/src/commands/get.ts +8 -5
  5. package/src/commands/handoff.ts +13 -3
  6. package/src/commands/login.ts +164 -59
  7. package/src/commands/logout.ts +3 -2
  8. package/src/commands/ls.ts +32 -10
  9. package/src/commands/manifest.ts +2 -2
  10. package/src/commands/mcp.ts +259 -7
  11. package/src/commands/migrate-envelopes.ts +55 -3
  12. package/src/commands/purge.ts +13 -10
  13. package/src/commands/restore.ts +10 -6
  14. package/src/commands/rollback.ts +12 -9
  15. package/src/commands/rotate-master.ts +13 -13
  16. package/src/commands/run.ts +6 -24
  17. package/src/commands/service-token.ts +15 -31
  18. package/src/commands/set.ts +7 -6
  19. package/src/commands/unset.ts +11 -8
  20. package/src/commands/versions.ts +7 -5
  21. package/src/index.ts +12 -8
  22. package/src/lib/auth-context.ts +74 -12
  23. package/src/lib/auth-proof.ts +10 -0
  24. package/src/lib/auto-project.ts +58 -14
  25. package/src/lib/client.ts +94 -17
  26. package/src/lib/config.ts +2 -0
  27. package/src/lib/device-login.ts +157 -0
  28. package/src/lib/env-format.ts +1 -1
  29. package/src/lib/envelope.ts +105 -15
  30. package/src/lib/identity-key.ts +21 -0
  31. package/src/lib/keyring.ts +25 -0
  32. package/src/lib/mcp-register.ts +223 -0
  33. package/src/lib/mcp-tools/admin.ts +267 -0
  34. package/src/lib/mcp-tools/args.ts +26 -0
  35. package/src/lib/mcp-tools/confirm.ts +21 -0
  36. package/src/lib/mcp-tools/defs.ts +388 -3
  37. package/src/lib/mcp-tools/login.ts +156 -0
  38. package/src/lib/mcp-tools/mask.ts +41 -0
  39. package/src/lib/mcp-tools/read.ts +115 -1
  40. package/src/lib/mcp-tools/result.ts +5 -5
  41. package/src/lib/mcp-tools/run.ts +101 -0
  42. package/src/lib/mcp-tools/write.ts +84 -5
  43. package/src/lib/oidc-flow.ts +43 -1
  44. package/src/lib/org-rewrap.ts +9 -3
  45. package/src/lib/service-tokens.ts +62 -0
package/src/lib/client.ts CHANGED
@@ -17,6 +17,7 @@ export interface RegisterResponse {
17
17
  token: string;
18
18
  machineId: string;
19
19
  createdAt: string;
20
+ userId?: number;
20
21
  }
21
22
 
22
23
  /** Phase 4 Slice 3 — auth_user_keys row (GET/POST /auth/keys). worker 는 base64 형식만 저장. */
@@ -60,19 +61,31 @@ export interface DeviceCodeResponse {
60
61
  verification_uri_complete: string;
61
62
  expires_in: number;
62
63
  interval: number;
63
- project: string;
64
- perms: 'read' | 'write';
64
+ /** Phase 5 — kind:'user' 면 project/perms 는 null (identity 디바이스는 envelope-scope 없음). */
65
+ kind: 'service' | 'user';
66
+ project: string | null;
67
+ perms: 'read' | 'write' | null;
65
68
  }
66
69
 
67
- /** Phase 3 P3 — POST /auth/device/token (poll) 결과. */
70
+ /** Phase 3 P3 / Phase 5 — POST /auth/device/token (poll) 결과 (service | user kind). */
68
71
  export type DevicePollResult =
69
72
  | {
70
73
  status: 'token';
74
+ tokenType: 'service';
71
75
  token: string;
72
76
  recipientId: string;
73
77
  project: string;
74
78
  perms: 'read' | 'write';
75
79
  }
80
+ | {
81
+ status: 'token';
82
+ tokenType: 'user';
83
+ token: string;
84
+ /** SealedBox JSON — 디바이스 privkey 로 unseal → identity privkey. */
85
+ sealedIdentityKey: string;
86
+ userId: number;
87
+ keyVersion: number;
88
+ }
76
89
  | { status: 'pending'; error: string; interval?: number };
77
90
 
78
91
  /**
@@ -240,7 +253,7 @@ export class AthsraClient {
240
253
 
241
254
  /**
242
255
  * Phase 3a — 자기 master pw proof bootstrap-or-verify (POST /auth/proof, Bearer).
243
- * proof = Argon2id(masterPw + GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
256
+ * proof = deriveProof(masterPw, userId, GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
244
257
  * 첫 호출 = bootstrap, 이후 = verify (불일치 시 worker 409 → throw).
245
258
  */
246
259
  async setProof(
@@ -485,14 +498,21 @@ export class AthsraClient {
485
498
 
486
499
  /** Phase 3 P3 — device-login 시작 (unauth). device_code + user_code + verification_uri. */
487
500
  async deviceCode(args: {
488
- project: string;
501
+ kind?: 'service' | 'user';
502
+ project?: string;
489
503
  perms?: 'read' | 'write';
490
504
  label: string;
505
+ /** kind:'user' — base64 32B 디바이스 X25519 public key. */
506
+ devicePublicKey?: string;
491
507
  }): Promise<DeviceCodeResponse> {
508
+ const body: Record<string, unknown> =
509
+ args.kind === 'user'
510
+ ? { kind: 'user', device_pub_key: args.devicePublicKey, label: args.label }
511
+ : { project: args.project, perms: args.perms, label: args.label };
492
512
  const res = await fetch(this.url('/auth/device/code'), {
493
513
  method: 'POST',
494
514
  headers: { 'content-type': 'application/json' },
495
- body: JSON.stringify({ project: args.project, perms: args.perms, label: args.label }),
515
+ body: JSON.stringify(body),
496
516
  });
497
517
  if (!res.ok) throw new Error(`device code ${res.status}: ${await res.text()}`);
498
518
  return (await res.json()) as DeviceCodeResponse;
@@ -507,8 +527,20 @@ export class AthsraClient {
507
527
  });
508
528
  const body = (await res.json()) as Record<string, unknown>;
509
529
  if (res.ok) {
530
+ if (body.token_type === 'user') {
531
+ return {
532
+ status: 'token',
533
+ tokenType: 'user',
534
+ token: typeof body.token === 'string' ? body.token : '',
535
+ sealedIdentityKey:
536
+ typeof body.sealed_identity_key === 'string' ? body.sealed_identity_key : '',
537
+ userId: typeof body.user_id === 'number' ? body.user_id : 0,
538
+ keyVersion: typeof body.key_version === 'number' ? body.key_version : 1,
539
+ };
540
+ }
510
541
  return {
511
542
  status: 'token',
543
+ tokenType: 'service',
512
544
  token: typeof body.token === 'string' ? body.token : '',
513
545
  recipientId: typeof body.recipient_id === 'string' ? body.recipient_id : '',
514
546
  project: typeof body.project === 'string' ? body.project : '',
@@ -572,15 +604,33 @@ export class AthsraClient {
572
604
  };
573
605
  }
574
606
 
575
- async getEnvelope(project: string): Promise<SecretEnvelopeAny | null> {
576
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`);
607
+ /** secret route URL — config!=='default' 시에만 ?config= 추가 (default 생략, 하위호환·무중단). */
608
+ private secretPath(
609
+ project: string,
610
+ config: string,
611
+ sub = '',
612
+ extra?: Record<string, string>,
613
+ ): string {
614
+ const params = new URLSearchParams();
615
+ if (config && config !== 'default') params.set('config', config);
616
+ if (extra) for (const [k, v] of Object.entries(extra)) params.set(k, v);
617
+ const q = params.toString();
618
+ return `/v1/secrets/${encodeURIComponent(project)}${sub}${q ? `?${q}` : ''}`;
619
+ }
620
+
621
+ async getEnvelope(project: string, config = 'default'): Promise<SecretEnvelopeAny | null> {
622
+ const res = await this.authedFetch(this.secretPath(project, config));
577
623
  if (res.status === 404) return null;
578
624
  if (!res.ok) throw new Error(`fetch ${res.status}: ${await res.text()}`);
579
625
  return (await res.json()) as SecretEnvelopeAny;
580
626
  }
581
627
 
582
- async putEnvelope(project: string, envelope: SecretEnvelopeAny): Promise<void> {
583
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`, {
628
+ async putEnvelope(
629
+ project: string,
630
+ envelope: SecretEnvelopeAny,
631
+ config = 'default',
632
+ ): Promise<void> {
633
+ const res = await this.authedFetch(this.secretPath(project, config), {
584
634
  method: 'PUT',
585
635
  headers: { 'content-type': 'application/json' },
586
636
  body: JSON.stringify(envelope),
@@ -597,14 +647,19 @@ export class AthsraClient {
597
647
 
598
648
  async deleteProject(
599
649
  project: string,
600
- opts?: { hard?: boolean },
650
+ opts?: { hard?: boolean; config?: string },
601
651
  ): Promise<{
602
652
  soft?: boolean;
603
653
  hard?: boolean;
604
654
  recoverable_versions?: number;
605
655
  removed_versions?: number;
606
656
  }> {
607
- const path = `/v1/secrets/${encodeURIComponent(project)}${opts?.hard ? '?hard=true' : ''}`;
657
+ const path = this.secretPath(
658
+ project,
659
+ opts?.config ?? 'default',
660
+ '',
661
+ opts?.hard ? { hard: 'true' } : undefined,
662
+ );
608
663
  const res = await this.authedFetch(path, { method: 'DELETE' });
609
664
  if (!res.ok) throw new Error(`delete ${res.status}: ${await res.text()}`);
610
665
  return (await res.json()) as {
@@ -615,14 +670,17 @@ export class AthsraClient {
615
670
  };
616
671
  }
617
672
 
618
- async listVersions(project: string): Promise<{
673
+ async listVersions(
674
+ project: string,
675
+ config = 'default',
676
+ ): Promise<{
619
677
  project: string;
620
678
  current_version: string | null;
621
679
  tombstone: { deleted_at: string; deleted_by: string } | null;
622
680
  versions: { version_id: string; updated_at: string; size: number }[];
623
681
  count: number;
624
682
  }> {
625
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/versions`);
683
+ const res = await this.authedFetch(this.secretPath(project, config, '/versions'));
626
684
  if (!res.ok) throw new Error(`versions ${res.status}: ${await res.text()}`);
627
685
  return (await res.json()) as {
628
686
  project: string;
@@ -636,8 +694,9 @@ export class AthsraClient {
636
694
  async rollbackProject(
637
695
  project: string,
638
696
  versionId: string,
697
+ config = 'default',
639
698
  ): Promise<{ ok: boolean; project: string; current_version: string }> {
640
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/rollback`, {
699
+ const res = await this.authedFetch(this.secretPath(project, config, '/rollback'), {
641
700
  method: 'POST',
642
701
  headers: { 'content-type': 'application/json' },
643
702
  body: JSON.stringify({ version_id: versionId }),
@@ -646,14 +705,17 @@ export class AthsraClient {
646
705
  return (await res.json()) as { ok: boolean; project: string; current_version: string };
647
706
  }
648
707
 
649
- async restoreProject(project: string): Promise<{
708
+ async restoreProject(
709
+ project: string,
710
+ config = 'default',
711
+ ): Promise<{
650
712
  ok: boolean;
651
713
  project: string;
652
714
  restored_version: string;
653
715
  deleted_at: string;
654
716
  deleted_by: string;
655
717
  }> {
656
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/restore`, {
718
+ const res = await this.authedFetch(this.secretPath(project, config, '/restore'), {
657
719
  method: 'POST',
658
720
  });
659
721
  if (!res.ok) throw new Error(`restore ${res.status}: ${await res.text()}`);
@@ -666,6 +728,21 @@ export class AthsraClient {
666
728
  };
667
729
  }
668
730
 
731
+ /** GET /:project/configs — 환경(config) 목록 + active/deleted. */
732
+ async listConfigs(project: string): Promise<{
733
+ project: string;
734
+ configs: { config: string; active: boolean; deleted: boolean }[];
735
+ count: number;
736
+ }> {
737
+ const res = await this.authedFetch(this.secretPath(project, 'default', '/configs'));
738
+ if (!res.ok) throw new Error(`configs ${res.status}: ${await res.text()}`);
739
+ return (await res.json()) as {
740
+ project: string;
741
+ configs: { config: string; active: boolean; deleted: boolean }[];
742
+ count: number;
743
+ };
744
+ }
745
+
669
746
  async listProjectsExtended(opts?: {
670
747
  includeDeleted?: boolean;
671
748
  }): Promise<
package/src/lib/config.ts CHANGED
@@ -16,6 +16,8 @@ export interface Config {
16
16
  */
17
17
  orgId?: number;
18
18
  orgSlug?: string;
19
+ /** Phase 5 — identity 디바이스 모드 로그인 시 기록 (member 경로 복호의 per-user salt·recipient 매칭). */
20
+ userId?: number;
19
21
  }
20
22
 
21
23
  export function ensureConfigDir(): void {
@@ -0,0 +1,157 @@
1
+ /**
2
+ * device-login.ts — Phase 5 B5. RFC 8628 device flow 의 공용 코어.
3
+ *
4
+ * `commands/login.ts` 의 identityLoginCmd/deviceLoginCmd poll 루프에서 추출 — CLI(블로킹 루프)와
5
+ * MCP in-chat 로그인(athsra_login_start/status 의 단발 step)이 같은 기계 부분을 소비한다.
6
+ *
7
+ * 보안 불변식: `IdentityFlow.deviceCode` 는 **프로세스 메모리 전용** — MCP 응답·로그·콘솔 어디에도
8
+ * 출력 금지 (탈취 시 토큰 가로채기 가능). user 에게 보여줄 것은 user_code/URL/fingerprint 뿐.
9
+ */
10
+ import { hostname } from 'node:os';
11
+ import { toBase64 } from '@athsra/crypto';
12
+ import { deviceKeyFingerprint, generateDeviceKeypair } from '@athsra/crypto/device';
13
+ import { AthsraClient, type DevicePollResult } from './client.ts';
14
+ import { loadConfig, saveConfig } from './config.ts';
15
+ import { unsealIdentityKey } from './identity-key.ts';
16
+ import { probeKeyring, setIdentityKey, setToken } from './keyring.ts';
17
+
18
+ /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
19
+ export const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
20
+
21
+ export type UserTokenResult = Extract<DevicePollResult, { status: 'token'; tokenType: 'user' }>;
22
+
23
+ export interface IdentityFlow {
24
+ client: AthsraClient;
25
+ workerUrl: string;
26
+ machineId: string;
27
+ existingCreatedAt?: string;
28
+ /** 디바이스 X25519 privkey — sealed identity key unseal 용 (이 머신에만). */
29
+ devicePrivateKey: Uint8Array;
30
+ /** 디바이스 pubkey 지문 — 브라우저 화면과 대조 (phishing 가드). 공개 정보. */
31
+ fingerprint: string;
32
+ /** RFC 8628 device_code — 메모리 전용. 어떤 응답에도 미포함. */
33
+ deviceCode: string;
34
+ userCode: string;
35
+ verificationUriComplete: string;
36
+ expiresAt: number; // epoch ms
37
+ intervalMs: number;
38
+ }
39
+
40
+ /**
41
+ * identity device flow 시작: keyring probe → config/worker 해석 → 디바이스 키쌍 생성 →
42
+ * deviceCode(kind:user). 실패는 actionable Error throw (호출자가 ✗/MCP error 로 환원).
43
+ */
44
+ export async function startIdentityFlow(): Promise<IdentityFlow> {
45
+ const probe = probeKeyring();
46
+ if (!probe.ok) {
47
+ throw new Error(
48
+ `keyring backend unavailable: ${probe.error ?? 'unknown'} — run \`athsra doctor\` for setup instructions (apt install + dbus).`,
49
+ );
50
+ }
51
+ const existing = loadConfig();
52
+ const workerUrl = process.env.ATHSRA_WORKER_URL ?? existing?.workerUrl ?? DEFAULT_WORKER_URL;
53
+ const machineId = existing?.machineId ?? `${hostname()}-${Date.now().toString(36)}`;
54
+
55
+ const client = new AthsraClient(workerUrl);
56
+ if (!(await client.health())) {
57
+ throw new Error(`worker unreachable: ${workerUrl}`);
58
+ }
59
+
60
+ const deviceKp = generateDeviceKeypair();
61
+ const fingerprint = deviceKeyFingerprint(deviceKp.publicKey);
62
+ const dc = await client.deviceCode({
63
+ kind: 'user',
64
+ devicePublicKey: toBase64(deviceKp.publicKey),
65
+ label: machineId,
66
+ });
67
+
68
+ return {
69
+ client,
70
+ workerUrl,
71
+ machineId,
72
+ existingCreatedAt: existing?.createdAt,
73
+ devicePrivateKey: deviceKp.privateKey,
74
+ fingerprint,
75
+ deviceCode: dc.device_code,
76
+ userCode: dc.user_code,
77
+ verificationUriComplete: dc.verification_uri_complete,
78
+ expiresAt: Date.now() + dc.expires_in * 1000,
79
+ intervalMs: Math.max(1, dc.interval) * 1000,
80
+ };
81
+ }
82
+
83
+ export type PollStep =
84
+ | { step: 'token'; result: Extract<DevicePollResult, { status: 'token' }> }
85
+ | { step: 'pending' }
86
+ | { step: 'slow_down' }
87
+ | { step: 'denied' }
88
+ | { step: 'expired' }
89
+ /** 네트워크/일시 오류 — pending 취급 (다음 tick 재시도). */
90
+ | { step: 'network' };
91
+
92
+ /** poll 1회 — MCP login_status 의 단발 step. 블로킹 루프는 runDevicePollLoop. */
93
+ export async function pollDeviceTokenOnce(
94
+ client: AthsraClient,
95
+ deviceCode: string,
96
+ ): Promise<PollStep> {
97
+ let result: DevicePollResult;
98
+ try {
99
+ result = await client.devicePollToken(deviceCode);
100
+ } catch {
101
+ return { step: 'network' };
102
+ }
103
+ if (result.status === 'token') return { step: 'token', result };
104
+ if (result.error === 'access_denied') return { step: 'denied' };
105
+ if (result.error === 'expired_token') return { step: 'expired' };
106
+ if (result.error === 'slow_down') return { step: 'slow_down' };
107
+ return { step: 'pending' };
108
+ }
109
+
110
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
111
+
112
+ /**
113
+ * 블로킹 poll 루프 (CLI) — token/denied/expired 또는 deadline 까지 반복.
114
+ * slow_down 시 interval +5s (RFC 8628). onTick 은 진행 표시('.') 용.
115
+ */
116
+ export async function runDevicePollLoop(
117
+ client: AthsraClient,
118
+ deviceCode: string,
119
+ opts: { intervalMs: number; expiresAt: number; onTick?: () => void },
120
+ ): Promise<Extract<PollStep, { step: 'token' | 'denied' | 'expired' }> | { step: 'timeout' }> {
121
+ let interval = opts.intervalMs;
122
+ while (Date.now() < opts.expiresAt) {
123
+ await sleep(interval);
124
+ const r = await pollDeviceTokenOnce(client, deviceCode);
125
+ if (r.step === 'token' || r.step === 'denied' || r.step === 'expired') return r;
126
+ if (r.step === 'slow_down') interval += 5000;
127
+ opts.onTick?.();
128
+ }
129
+ return { step: 'timeout' };
130
+ }
131
+
132
+ /**
133
+ * identity 승인 완료 처리: sealed unseal(user_id 대조 — 키 혼선 차단) → keyring
134
+ * identity-priv + token → config.userId. master pw 는 이 기기에 절대 없음 (D-2).
135
+ */
136
+ export async function completeIdentityLogin(args: {
137
+ result: UserTokenResult;
138
+ devicePrivateKey: Uint8Array;
139
+ machineId: string;
140
+ workerUrl: string;
141
+ existingCreatedAt?: string;
142
+ }): Promise<{ userId: number }> {
143
+ const identityPrivB64 = await unsealIdentityKey(
144
+ args.result.sealedIdentityKey,
145
+ args.devicePrivateKey,
146
+ args.result.userId,
147
+ );
148
+ setIdentityKey(args.machineId, identityPrivB64);
149
+ setToken(args.machineId, args.result.token);
150
+ saveConfig({
151
+ workerUrl: args.workerUrl,
152
+ machineId: args.machineId,
153
+ createdAt: args.existingCreatedAt ?? new Date().toISOString(),
154
+ userId: args.result.userId,
155
+ });
156
+ return { userId: args.result.userId };
157
+ }
@@ -5,4 +5,4 @@
5
5
  * crypto 패키지가 정본. 이 파일은 CLI 내부 사용처 (get/ls/doctor/run/set/envelope) 의
6
6
  * 기존 import 경로 호환을 위한 re-export.
7
7
  */
8
- export { parseEnv, partitionEnv, serializeEnv } from '@athsra/crypto';
8
+ export { buildChildEnv, parseEnv, partitionEnv, serializeEnv } from '@athsra/crypto';
@@ -22,6 +22,9 @@ import {
22
22
  type SecretEnvelopeV2,
23
23
  } from '@athsra/crypto';
24
24
  import {
25
+ addMemberRecipient,
26
+ createEnvelopeAsMember,
27
+ createEnvelopeWithSelf,
25
28
  decryptEnvelopeAsMember,
26
29
  reEncryptAsMember,
27
30
  unwrapPrivateKey,
@@ -62,7 +65,54 @@ async function memberRecipientUserId(
62
65
  return env.recipients.some((r) => r.id === `member:${me.userId}`) ? me.userId : null;
63
66
  }
64
67
 
68
+ /**
69
+ * Phase 5 — master(owner) write 의 member:self 재료 (whoami + getKeys). best-effort: 키 미
70
+ * provisioning/오프라인이면 null → master-only 폴백(기존 동작 보존, migrate --self 가 다음 기회).
71
+ */
72
+ async function selfMember(
73
+ ctx: UserAuthContext,
74
+ ): Promise<{ publicKey: Uint8Array; userId: number } | null> {
75
+ try {
76
+ const me = await ctx.client.whoami();
77
+ if (me.userId === undefined) return null;
78
+ const keyRow = await ctx.client.getKeys();
79
+ if (!keyRow) return null;
80
+ return { publicKey: fromBase64(keyRow.public_key), userId: me.userId };
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** routine master write 시 member:self 부재면 1회 추가. 403/실패면 env 그대로(body-only 폴백). */
87
+ async function ensureSelfRecipient(
88
+ ctx: UserAuthContext,
89
+ env: SecretEnvelopeV2,
90
+ self: { publicKey: Uint8Array; userId: number } | null,
91
+ ): Promise<SecretEnvelopeV2> {
92
+ if (!self) return env;
93
+ if (env.recipients.some((r) => r.id === `member:${self.userId}`)) return env;
94
+ try {
95
+ return await addMemberRecipient(env, ctx.masterPw, self.publicKey, self.userId);
96
+ } catch {
97
+ return env;
98
+ }
99
+ }
100
+
65
101
  async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promise<string> {
102
+ // Phase 5 — identity 모드: master pw 없이 내 X25519 키로 멤버 경로 복호 (member:me recipient).
103
+ if (ctx.kind === 'identity') {
104
+ if (env.version !== 2) {
105
+ throw new Error(
106
+ 'identity 로그인은 envelope v2 만 복호 가능 — master pw 머신에서 `athsra set` 1회로 v1→v2 마이그레이션 필요.',
107
+ );
108
+ }
109
+ if (!env.recipients.some((r) => r.id === `member:${ctx.userId}`)) {
110
+ throw new Error(
111
+ '이 시크릿에 내 멤버 recipient 가 없습니다 — master pw 머신에서 `athsra migrate-envelopes --self` 1회 실행 후 재시도.',
112
+ );
113
+ }
114
+ return decryptEnvelopeAsMember(env, ctx.identityPrivateKey, ctx.userId);
115
+ }
66
116
  if (ctx.kind === 'user') {
67
117
  if (env.version === 2) {
68
118
  try {
@@ -100,8 +150,9 @@ async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promis
100
150
  export async function readPlain(
101
151
  ctx: AuthContext,
102
152
  project: string,
153
+ config = 'default',
103
154
  ): Promise<Record<string, string> | null> {
104
- const env = await ctx.client.getEnvelope(project);
155
+ const env = await ctx.client.getEnvelope(project, config);
105
156
  if (!env) return null;
106
157
  const text = await decryptEnvelope(env, ctx);
107
158
  return parseEnv(text);
@@ -116,30 +167,67 @@ export async function writePlain(
116
167
  ctx: AuthContext,
117
168
  project: string,
118
169
  plain: Record<string, string>,
119
- opts?: { kdfParams?: KDFParams },
170
+ opts?: { kdfParams?: KDFParams; config?: string },
120
171
  ): Promise<SecretEnvelopeV2> {
121
- if (ctx.kind !== 'user') {
172
+ if (ctx.kind === 'service') {
122
173
  throw new Error('service token cannot write envelopes — use a user token (master pw)');
123
174
  }
175
+ const config = opts?.config ?? 'default';
124
176
  const serialized = serializeEnv(plain);
125
177
 
178
+ // Phase 5 — identity 모드: master pw 없이 멤버 경로로 본문 재암호(recipients 보존)/신규 생성.
179
+ if (ctx.kind === 'identity') {
180
+ if (opts?.kdfParams) {
181
+ throw new Error('rotate-master 는 master pw 가 필요합니다 (identity 로그인 머신에선 불가).');
182
+ }
183
+ const existing = await ctx.client.getEnvelope(project, config);
184
+ if (existing && existing.version === 2) {
185
+ if (!existing.recipients.some((r) => r.id === `member:${ctx.userId}`)) {
186
+ throw new Error(
187
+ '이 시크릿에 내 멤버 recipient 가 없습니다 — `athsra migrate-envelopes --self` 1회 후 재시도.',
188
+ );
189
+ }
190
+ const env = await reEncryptAsMember(existing, ctx.identityPrivateKey, ctx.userId, serialized);
191
+ await ctx.client.putEnvelope(project, env, config);
192
+ return env;
193
+ }
194
+ // 신규 프로젝트 — member:self 단독 envelope 생성 (server 의 내 public key 로 wrap).
195
+ const keyRow = await ctx.client.getKeys();
196
+ if (!keyRow) {
197
+ throw new Error('identity key 없음 — `athsra login` 으로 재온보딩 필요.');
198
+ }
199
+ const env = await createEnvelopeAsMember(serialized, fromBase64(keyRow.public_key), ctx.userId);
200
+ await ctx.client.putEnvelope(project, env, config);
201
+ return env;
202
+ }
203
+
204
+ // Phase 5 — master write 는 가능하면 member:self 동반(identity 로그인 머신이 멤버 경로로 복호하는
205
+ // substrate). whoami/getKeys best-effort — 실패(키 미provisioning/오프라인)면 master-only 폴백.
206
+ const self = await selfMember(ctx);
207
+
126
208
  // opts.kdfParams 명시 = rotate-master 의 의도적 reset (ENTERPRISE_KDF 마이그레이션):
127
- // 새 envelope 를 만들어 모든 service recipient 를 의도적으로 폐기 (master pw 재확립
128
- // = scoped 신뢰 reset). routine write 와 구분되는 유일한 신호.
209
+ // 새 envelope 를 만들어 모든 service recipient 를 의도적으로 폐기. master(+self) recipient 만.
129
210
  if (opts?.kdfParams) {
130
- const env = await createEnvelopeV2(serialized, ctx.masterPw, { kdfParams: opts.kdfParams });
131
- await ctx.client.putEnvelope(project, env);
211
+ const env = self
212
+ ? await createEnvelopeWithSelf(serialized, ctx.masterPw, self.publicKey, self.userId, {
213
+ kdfParams: opts.kdfParams,
214
+ })
215
+ : await createEnvelopeV2(serialized, ctx.masterPw, { kdfParams: opts.kdfParams });
216
+ await ctx.client.putEnvelope(project, env, config);
132
217
  return env;
133
218
  }
134
219
 
135
220
  // routine write (set/unset/mcp/sync): 기존 v2 envelope 가 있으면 본문만 재암호화해
136
221
  // kdf_params 와 모든 recipient (service token 포함) 를 보존한다. createEnvelopeV2 로
137
222
  // 새로 만들면 service recipient 가 silent 삭제되고 kdf 가 DEFAULT(64MB) 로 downgrade 됨.
138
- const existing = await ctx.client.getEnvelope(project);
223
+ // config 전파 필수 — 생략 시 default envelope 를 읽어 다른 환경의 recipient 로 덮어쓰는 버그.
224
+ const existing = await ctx.client.getEnvelope(project, config);
139
225
  if (existing && existing.version === 2) {
140
226
  try {
141
- const env = await reEncryptEnvelopeBody(existing, ctx.masterPw, serialized);
142
- await ctx.client.putEnvelope(project, env);
227
+ const reenc = await reEncryptEnvelopeBody(existing, ctx.masterPw, serialized);
228
+ // lazy self-grant: member:self 부재 시 1회 추가 (identity 복호 substrate). 실패=body-only.
229
+ const env = await ensureSelfRecipient(ctx, reenc, self);
230
+ await ctx.client.putEnvelope(project, env, config);
143
231
  return env;
144
232
  } catch (masterErr) {
145
233
  // Slice 6c — master(owner) 가 아니면 member 경로: 내 X25519 키로 DEK 얻어 본문만 재암호
@@ -149,14 +237,16 @@ export async function writePlain(
149
237
  const priv = await memberPrivateKey(ctx);
150
238
  if (!priv) throw masterErr;
151
239
  const env = await reEncryptAsMember(existing, priv, memberUserId, serialized);
152
- await ctx.client.putEnvelope(project, env);
240
+ await ctx.client.putEnvelope(project, env, config);
153
241
  return env;
154
242
  }
155
243
  }
156
244
 
157
- // 신규 프로젝트 또는 v1 (recipients[] 없음) → 새 v2 (DEFAULT_KDF). v1 의 enterprise
158
- // 마이그레이션은 rotate-master / service-token create (migrateV1ToV2) 경로가 담당.
159
- const env = await createEnvelopeV2(serialized, ctx.masterPw);
160
- await ctx.client.putEnvelope(project, env);
245
+ // 신규 프로젝트 또는 v1 (recipients[] 없음) → 새 v2 (DEFAULT_KDF). master(+self) recipient.
246
+ // v1 의 enterprise 마이그레이션은 rotate-master / service-token create (migrateV1ToV2) 담당.
247
+ const env = self
248
+ ? await createEnvelopeWithSelf(serialized, ctx.masterPw, self.publicKey, self.userId)
249
+ : await createEnvelopeV2(serialized, ctx.masterPw);
250
+ await ctx.client.putEnvelope(project, env, config);
161
251
  return env;
162
252
  }
@@ -6,6 +6,7 @@
6
6
  * crypto 는 @athsra/crypto/member (subpath — worker 번들 격리).
7
7
  */
8
8
  import { toBase64 } from '@athsra/crypto';
9
+ import { parseSealedBox, unseal } from '@athsra/crypto/device';
9
10
  import { generateIdentityKeypair, unwrapPrivateKey, wrapPrivateKey } from '@athsra/crypto/member';
10
11
  import type { AthsraClient, IdentityKeyRewrap } from './client.ts';
11
12
 
@@ -57,3 +58,23 @@ export async function rewrapForRotation(
57
58
  kdf_params: rewrapped.kdfParams,
58
59
  };
59
60
  }
61
+
62
+ /**
63
+ * Phase 5 — device-login(identity) poll 의 sealed_identity_key(SealedBox JSON)를 디바이스 privkey
64
+ * 로 unseal → identity private key (base64). payload.user_id 를 poll 응답 user_id 와 대조해 키
65
+ * 혼선/스왑을 수령 시점에 차단한다. 변조·타 디바이스 키·user_id 불일치는 전부 throw.
66
+ */
67
+ export async function unsealIdentityKey(
68
+ sealedJson: string,
69
+ devicePrivateKey: Uint8Array,
70
+ expectedUserId: number,
71
+ ): Promise<string> {
72
+ const box = parseSealedBox(sealedJson);
73
+ const payload = await unseal(box, devicePrivateKey);
74
+ if (payload.user_id !== expectedUserId) {
75
+ throw new Error(
76
+ `sealed identity user_id 불일치 (${payload.user_id} ≠ ${expectedUserId}) — 재시도 또는 \`athsra login\`.`,
77
+ );
78
+ }
79
+ return payload.identity_private_key;
80
+ }
@@ -72,6 +72,31 @@ export function clearDeviceToken(project: string): void {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * Phase 5 (2026-06-12) — identity 디바이스 모드. `athsra login`(identity) 이 device flow 로
77
+ * 수령·unseal 한 identity X25519 private key (base64 32B) 를 머신 전역 slot 에 저장. master pw 가
78
+ * 이 머신에 없어도 멤버 경로로 envelope 복호. loadAuthContext 의 identity 모드가 조회.
79
+ */
80
+ export function setIdentityKey(machineId: string, privateKeyB64: string): void {
81
+ entry(`identity-priv:${machineId}`).setPassword(privateKeyB64);
82
+ }
83
+
84
+ export function getIdentityKey(machineId: string): string | null {
85
+ try {
86
+ return entry(`identity-priv:${machineId}`).getPassword();
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function clearIdentityKey(machineId: string): void {
93
+ try {
94
+ entry(`identity-priv:${machineId}`).deletePassword();
95
+ } catch {
96
+ /* ignore */
97
+ }
98
+ }
99
+
75
100
  export interface ProbeResult {
76
101
  ok: boolean;
77
102
  error?: string;