@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.
- package/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +164 -59
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +74 -12
- package/src/lib/auth-proof.ts +10 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +94 -17
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- 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
|
-
|
|
64
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
576
|
-
|
|
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(
|
|
583
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
@@ -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
|
+
}
|
package/src/lib/env-format.ts
CHANGED
package/src/lib/envelope.ts
CHANGED
|
@@ -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
|
|
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 를 의도적으로
|
|
128
|
-
// = scoped 신뢰 reset). routine write 와 구분되는 유일한 신호.
|
|
209
|
+
// 새 envelope 를 만들어 모든 service recipient 를 의도적으로 폐기. master(+self) recipient 만.
|
|
129
210
|
if (opts?.kdfParams) {
|
|
130
|
-
const env =
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
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).
|
|
158
|
-
// 마이그레이션은 rotate-master / service-token create (migrateV1ToV2)
|
|
159
|
-
const env =
|
|
160
|
-
|
|
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
|
}
|
package/src/lib/identity-key.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/keyring.ts
CHANGED
|
@@ -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;
|