@athsra/cli 1.0.2 → 1.0.3
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 -2
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +1 -5
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +3 -10
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +2 -8
- package/src/lib/bip39.ts +0 -45
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/write.ts — write MCP tool 핸들러 (`ATHSRA_MCP_WRITE=1` opt-in 시에만 dispatch).
|
|
3
|
+
*
|
|
4
|
+
* `commands/mcp.ts` 에서 분리 (2026-06-02 리팩토링). 동작 보존. secret 값은 응답에 미포함.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inferAdoptContext } from '../adopt-context.ts';
|
|
8
|
+
import { loadAuthContext } from '../auth-context.ts';
|
|
9
|
+
import { readPlain, writePlain } from '../envelope.ts';
|
|
10
|
+
import { createManifest, loadManifest, saveManifest } from '../secrets-manifest.ts';
|
|
11
|
+
import { pickWorker, readString, readStringArray } from './args.ts';
|
|
12
|
+
import { jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
|
|
13
|
+
|
|
14
|
+
export async function handleSetSecret(
|
|
15
|
+
args: Record<string, unknown> | undefined,
|
|
16
|
+
): Promise<ToolTextResult> {
|
|
17
|
+
const project = readString(args, 'project');
|
|
18
|
+
const key = readString(args, 'key');
|
|
19
|
+
const value = readString(args, 'value');
|
|
20
|
+
if (!project || !key || value === undefined) {
|
|
21
|
+
return jsonError('missing required args: project, key, value');
|
|
22
|
+
}
|
|
23
|
+
const ctx = await loadAuthContext();
|
|
24
|
+
if (!ctx) return notLoggedIn();
|
|
25
|
+
if (ctx.kind !== 'user') {
|
|
26
|
+
return jsonError('service token cannot write — user token (master pw) required');
|
|
27
|
+
}
|
|
28
|
+
const existing = (await readPlain(ctx, project)) ?? {};
|
|
29
|
+
const updated: Record<string, string> = { ...existing, [key]: value };
|
|
30
|
+
await writePlain(ctx, project, updated);
|
|
31
|
+
// 보안: value 는 응답에 절대 포함 X — 키 이름만 confirm
|
|
32
|
+
return jsonOk({ project, key, action: 'set', total_keys: Object.keys(updated).length });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function handleUnsetSecret(
|
|
36
|
+
args: Record<string, unknown> | undefined,
|
|
37
|
+
): Promise<ToolTextResult> {
|
|
38
|
+
const project = readString(args, 'project');
|
|
39
|
+
const key = readString(args, 'key');
|
|
40
|
+
if (!project || !key) return jsonError('missing required args: project, key');
|
|
41
|
+
const ctx = await loadAuthContext();
|
|
42
|
+
if (!ctx) return notLoggedIn();
|
|
43
|
+
if (ctx.kind !== 'user') {
|
|
44
|
+
return jsonError('service token cannot write — user token (master pw) required');
|
|
45
|
+
}
|
|
46
|
+
const existing = await readPlain(ctx, project);
|
|
47
|
+
if (!existing) return jsonError(`envelope '${project}' not found`);
|
|
48
|
+
if (!(key in existing)) {
|
|
49
|
+
return jsonOk({ project, key, action: 'noop', reason: 'key not present' });
|
|
50
|
+
}
|
|
51
|
+
const updated: Record<string, string> = { ...existing };
|
|
52
|
+
delete updated[key];
|
|
53
|
+
await writePlain(ctx, project, updated);
|
|
54
|
+
return jsonOk({ project, key, action: 'unset', total_keys: Object.keys(updated).length });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function handleManifestInit(args: Record<string, unknown> | undefined): ToolTextResult {
|
|
58
|
+
const keys = readStringArray(args, 'keys');
|
|
59
|
+
if (!keys || keys.length === 0) return jsonError('missing required arg: keys (non-empty array)');
|
|
60
|
+
const at = readString(args, 'cwd') ?? process.cwd();
|
|
61
|
+
const worker = readString(args, 'worker');
|
|
62
|
+
const inferred = inferAdoptContext(at);
|
|
63
|
+
const picked = pickWorker(inferred.workers, worker);
|
|
64
|
+
if ('error' in picked) return jsonError(picked.error);
|
|
65
|
+
const existing = loadManifest({ workerCwd: picked.cwd });
|
|
66
|
+
if (existing.manifest) {
|
|
67
|
+
return jsonError(`manifest already exists at ${existing.path} — use athsra_manifest_modify`);
|
|
68
|
+
}
|
|
69
|
+
if (existing.error) {
|
|
70
|
+
return jsonError(`manifest invalid (${existing.path}): ${existing.error}`);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const manifest = createManifest(keys);
|
|
74
|
+
const saved = saveManifest(picked.cwd, manifest);
|
|
75
|
+
return jsonOk({
|
|
76
|
+
worker: picked.name,
|
|
77
|
+
manifestPath: saved,
|
|
78
|
+
secrets: manifest.secrets,
|
|
79
|
+
count: manifest.secrets.length,
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return jsonError(`createManifest failed: ${(err as Error).message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function handleManifestModify(args: Record<string, unknown> | undefined): ToolTextResult {
|
|
87
|
+
const op = readString(args, 'op');
|
|
88
|
+
const keys = readStringArray(args, 'keys');
|
|
89
|
+
if (!op || (op !== 'add' && op !== 'remove')) {
|
|
90
|
+
return jsonError("invalid 'op' — must be 'add' or 'remove'");
|
|
91
|
+
}
|
|
92
|
+
if (!keys || keys.length === 0) return jsonError('missing required arg: keys (non-empty array)');
|
|
93
|
+
const at = readString(args, 'cwd') ?? process.cwd();
|
|
94
|
+
const worker = readString(args, 'worker');
|
|
95
|
+
const inferred = inferAdoptContext(at);
|
|
96
|
+
const picked = pickWorker(inferred.workers, worker);
|
|
97
|
+
if ('error' in picked) return jsonError(picked.error);
|
|
98
|
+
const existing = loadManifest({ workerCwd: picked.cwd });
|
|
99
|
+
if (existing.error) {
|
|
100
|
+
return jsonError(`manifest invalid (${existing.path}): ${existing.error}`);
|
|
101
|
+
}
|
|
102
|
+
if (!existing.manifest) {
|
|
103
|
+
return jsonError(`no manifest at ${existing.path} — use athsra_manifest_init`);
|
|
104
|
+
}
|
|
105
|
+
const set = new Set(existing.manifest.secrets);
|
|
106
|
+
const before = set.size;
|
|
107
|
+
for (const k of keys) {
|
|
108
|
+
if (op === 'add') set.add(k);
|
|
109
|
+
else set.delete(k);
|
|
110
|
+
}
|
|
111
|
+
if (set.size === before) {
|
|
112
|
+
return jsonOk({ worker: picked.name, op, action: 'noop', secrets: existing.manifest.secrets });
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const manifest = createManifest(Array.from(set));
|
|
116
|
+
const saved = saveManifest(picked.cwd, manifest);
|
|
117
|
+
return jsonOk({
|
|
118
|
+
worker: picked.name,
|
|
119
|
+
op,
|
|
120
|
+
manifestPath: saved,
|
|
121
|
+
secrets: manifest.secrets,
|
|
122
|
+
count: manifest.secrets.length,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return jsonError(`createManifest failed: ${(err as Error).message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oidc-flow.ts — modfolio-connect SSO 의 OIDC PKCE 브라우저 플로우 (Phase 2.8).
|
|
3
|
+
*
|
|
4
|
+
* `commands/login.ts` 에서 분리 (2026-06-02 리팩토링) — 프로토콜 무관 OAuth redirect 기계 부분
|
|
5
|
+
* (PKCE 생성 → loopback callback → /token 교환)을 독립 모듈로. login.ts 는 athsra 고유 셸
|
|
6
|
+
* (keyring·config·worker /auth/sso 교환·master pw)만 유지. 동작·출력·exit code 보존 (순수 추출).
|
|
7
|
+
*
|
|
8
|
+
* `openBrowser` 는 device-login (login.ts deviceLoginCmd) 도 사용 — 여기서 export (중복 사본 금지).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
13
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
14
|
+
|
|
15
|
+
/** OIDC PKCE 플로우 엔드포인트 + 파라미터 (env override 는 호출부에서 해석해 주입). */
|
|
16
|
+
export interface OidcFlowOptions {
|
|
17
|
+
/** Connect authorize endpoint */
|
|
18
|
+
authorizeUrl: string;
|
|
19
|
+
/** Connect token endpoint */
|
|
20
|
+
tokenUrl: string;
|
|
21
|
+
/** Connect 측 CLI client (PKCE public, no secret) */
|
|
22
|
+
clientId: string;
|
|
23
|
+
/** OIDC scope */
|
|
24
|
+
scope: string;
|
|
25
|
+
/** callback timeout (ms) — 사용자 browser 작업 대기 */
|
|
26
|
+
callbackTimeoutMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SSO_DEFAULTS: OidcFlowOptions = {
|
|
30
|
+
authorizeUrl: 'https://login.modfolio.io/authorize',
|
|
31
|
+
tokenUrl: 'https://login.modfolio.io/token',
|
|
32
|
+
/** Connect 측 CLI client (PKCE public, no secret). 사용자가 별도 등록. */
|
|
33
|
+
clientId: 'athsra-cli',
|
|
34
|
+
scope: 'openid profile email',
|
|
35
|
+
callbackTimeoutMs: 5 * 60 * 1000,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface CallbackResult {
|
|
39
|
+
code: string;
|
|
40
|
+
state: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Start ephemeral loopback HTTP callback server. Closes after first valid callback or timeout. */
|
|
44
|
+
async function waitForCallback(
|
|
45
|
+
expectedState: string,
|
|
46
|
+
port: number,
|
|
47
|
+
timeoutMs: number,
|
|
48
|
+
): Promise<CallbackResult> {
|
|
49
|
+
return await new Promise<CallbackResult>((resolve, reject) => {
|
|
50
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
51
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
52
|
+
if (url.pathname !== '/callback') {
|
|
53
|
+
res.statusCode = 404;
|
|
54
|
+
res.end('not found');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const code = url.searchParams.get('code');
|
|
58
|
+
const state = url.searchParams.get('state');
|
|
59
|
+
const error = url.searchParams.get('error');
|
|
60
|
+
if (error) {
|
|
61
|
+
res.statusCode = 400;
|
|
62
|
+
res.end(`Authorization failed: ${error}. Return to terminal.`);
|
|
63
|
+
server.close();
|
|
64
|
+
reject(new Error(`Connect authorize error: ${error}`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!code || !state) {
|
|
68
|
+
res.statusCode = 400;
|
|
69
|
+
res.end('Missing code or state. Return to terminal.');
|
|
70
|
+
server.close();
|
|
71
|
+
reject(new Error('Missing code or state in callback'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (state !== expectedState) {
|
|
75
|
+
res.statusCode = 400;
|
|
76
|
+
res.end('State mismatch — possible CSRF. Return to terminal.');
|
|
77
|
+
server.close();
|
|
78
|
+
reject(new Error('State mismatch'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.statusCode = 200;
|
|
82
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
83
|
+
res.end(
|
|
84
|
+
'<!doctype html><html><body style="font-family:system-ui;padding:2rem;">' +
|
|
85
|
+
'<h2>✓ athsra SSO 로그인 완료</h2>' +
|
|
86
|
+
'<p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p>' +
|
|
87
|
+
'</body></html>',
|
|
88
|
+
);
|
|
89
|
+
server.close();
|
|
90
|
+
resolve({ code, state });
|
|
91
|
+
});
|
|
92
|
+
server.listen(port, '127.0.0.1');
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
server.close();
|
|
95
|
+
reject(new Error(`callback timeout after ${timeoutMs / 1000}s`));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function base64urlBuf(buf: Buffer): string {
|
|
101
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function openBrowser(url: string): void {
|
|
105
|
+
const platform = process.platform;
|
|
106
|
+
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
107
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
108
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function findFreePort(): Promise<number> {
|
|
112
|
+
// 49152-65535 ephemeral range. createServer port=0 자동 할당.
|
|
113
|
+
return await new Promise<number>((resolve, reject) => {
|
|
114
|
+
const server = createServer();
|
|
115
|
+
server.on('error', reject);
|
|
116
|
+
server.listen(0, '127.0.0.1', () => {
|
|
117
|
+
const address = server.address();
|
|
118
|
+
if (typeof address !== 'object' || address === null) {
|
|
119
|
+
server.close();
|
|
120
|
+
reject(new Error('no port'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const port = address.port;
|
|
124
|
+
server.close(() => resolve(port));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* OIDC PKCE 플로우 실행 (login.ts ssoLoginCmd 의 step 4-8):
|
|
131
|
+
* 4) PKCE code_verifier + challenge + state 생성
|
|
132
|
+
* 5) localhost callback HTTP server start (ephemeral port)
|
|
133
|
+
* 6) browser open → Connect /authorize
|
|
134
|
+
* 7) callback ?code= 대기
|
|
135
|
+
* 8) POST Connect /token (code + verifier) → access_token
|
|
136
|
+
*
|
|
137
|
+
* 실패 시 throw (호출부가 `✗ ${message}` 출력 + exit 1). 진행 로그는 stdout.
|
|
138
|
+
*/
|
|
139
|
+
export async function runOidcPkceFlow(opts: OidcFlowOptions): Promise<{ accessToken: string }> {
|
|
140
|
+
// 4. PKCE + state 생성
|
|
141
|
+
const codeVerifier = base64urlBuf(randomBytes(48));
|
|
142
|
+
const codeChallenge = base64urlBuf(createHash('sha256').update(codeVerifier).digest());
|
|
143
|
+
const state = base64urlBuf(randomBytes(16));
|
|
144
|
+
|
|
145
|
+
// 5. callback server start
|
|
146
|
+
let port: number;
|
|
147
|
+
try {
|
|
148
|
+
port = await findFreePort();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new Error(`cannot allocate callback port: ${(err as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
153
|
+
|
|
154
|
+
// 6. browser open
|
|
155
|
+
const params = new URLSearchParams({
|
|
156
|
+
client_id: opts.clientId,
|
|
157
|
+
response_type: 'code',
|
|
158
|
+
code_challenge: codeChallenge,
|
|
159
|
+
code_challenge_method: 'S256',
|
|
160
|
+
state,
|
|
161
|
+
redirect_uri: redirectUri,
|
|
162
|
+
scope: opts.scope,
|
|
163
|
+
});
|
|
164
|
+
const authUrl = `${opts.authorizeUrl}?${params.toString()}`;
|
|
165
|
+
console.log(`opening browser: ${authUrl.slice(0, 80)}...`);
|
|
166
|
+
console.log(`callback: ${redirectUri}`);
|
|
167
|
+
console.log('(complete login in browser — terminal will resume when callback received)\n');
|
|
168
|
+
openBrowser(authUrl);
|
|
169
|
+
|
|
170
|
+
// 7. wait for callback
|
|
171
|
+
let callback: CallbackResult;
|
|
172
|
+
try {
|
|
173
|
+
callback = await waitForCallback(state, port, opts.callbackTimeoutMs);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new Error(`callback failed: ${(err as Error).message}`);
|
|
176
|
+
}
|
|
177
|
+
console.log('✓ callback received, exchanging code...');
|
|
178
|
+
|
|
179
|
+
// 8. token exchange (Connect /token)
|
|
180
|
+
const tokenRes = await fetch(opts.tokenUrl, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
183
|
+
body: new URLSearchParams({
|
|
184
|
+
grant_type: 'authorization_code',
|
|
185
|
+
code: callback.code,
|
|
186
|
+
code_verifier: codeVerifier,
|
|
187
|
+
client_id: opts.clientId,
|
|
188
|
+
redirect_uri: redirectUri,
|
|
189
|
+
}).toString(),
|
|
190
|
+
});
|
|
191
|
+
if (!tokenRes.ok) {
|
|
192
|
+
throw new Error(`Connect token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
|
|
193
|
+
}
|
|
194
|
+
const tokenBody = (await tokenRes.json()) as { access_token?: string };
|
|
195
|
+
if (!tokenBody.access_token) {
|
|
196
|
+
throw new Error('Connect token response missing access_token');
|
|
197
|
+
}
|
|
198
|
+
console.log('✓ Connect access_token received');
|
|
199
|
+
return { accessToken: tokenBody.access_token };
|
|
200
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* org-rewrap.ts — Phase 4 Slice 6b. owner 가 org 공유 시크릿을 신규 멤버 public key 로 re-wrap.
|
|
3
|
+
*
|
|
4
|
+
* owner master pw 로 각 envelope 의 DEK 를 얻어(addMemberRecipient 가 master recipient 로 unwrap)
|
|
5
|
+
* 멤버 X25519 public key 로 ECDH-wrap → PUT. **멤버 master pw 불필요**(zero-knowledge 팀 공유의
|
|
6
|
+
* crux). idempotent — 이미 member recipient 있으면 skip. v1 envelope(단일 recipient)은 multi-
|
|
7
|
+
* recipient 미지원 → skip(먼저 `athsra migrate-envelopes`). 부분 실패는 failed[] 로 surface.
|
|
8
|
+
*/
|
|
9
|
+
import { fromBase64, type SecretEnvelopeV2 } from '@athsra/crypto';
|
|
10
|
+
import {
|
|
11
|
+
addMemberRecipient,
|
|
12
|
+
addMemberRecipientAsMember,
|
|
13
|
+
memberRecipientId,
|
|
14
|
+
memberRecipientUserIds,
|
|
15
|
+
type RemainingMember,
|
|
16
|
+
removeMemberRecipient,
|
|
17
|
+
rotateEnvelopeDek,
|
|
18
|
+
unwrapPrivateKey,
|
|
19
|
+
} from '@athsra/crypto/member';
|
|
20
|
+
import type { AthsraClient } from './client.ts';
|
|
21
|
+
|
|
22
|
+
export interface GrantResult {
|
|
23
|
+
granted: string[];
|
|
24
|
+
/** 이미 공유됨(member recipient 존재) — idempotent skip (replace 시엔 재포장). */
|
|
25
|
+
skipped: string[];
|
|
26
|
+
/** v1 envelope — multi-recipient 미지원, migrate 필요. */
|
|
27
|
+
legacyV1: string[];
|
|
28
|
+
failed: { project: string; error: string }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AdderIdentity {
|
|
32
|
+
userId: number;
|
|
33
|
+
privateKey: Uint8Array;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 현재 org 의 모든 (v2) 공유 시크릿에 memberUserId 를 recipient 로 추가/재포장.
|
|
38
|
+
* client 는 현재 org 컨텍스트(token.orgId)로 scoped. pubkey 부재(멤버 미 provisioning)면 throw.
|
|
39
|
+
*
|
|
40
|
+
* 호출자가 **owner**(master pw 로 master recipient unwrap)면 addMemberRecipient, **admin 멤버**
|
|
41
|
+
* (master 불가)면 본인 X25519 키로 addMemberRecipientAsMember (server recipient-continuity 가
|
|
42
|
+
* add=manager 강제). `replace`=true 면 기존 member recipient 를 제거 후 재포장(key reset 복구의
|
|
43
|
+
* stale wrap 교체 — recipient id set 불변).
|
|
44
|
+
*/
|
|
45
|
+
export async function grantOrgAccess(
|
|
46
|
+
client: AthsraClient,
|
|
47
|
+
masterPw: string,
|
|
48
|
+
memberUserId: number,
|
|
49
|
+
opts?: { replace?: boolean },
|
|
50
|
+
): Promise<GrantResult> {
|
|
51
|
+
const pub = await client.getPublicKey(memberUserId);
|
|
52
|
+
if (!pub) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`member #${memberUserId} 는 아직 identity key 가 없습니다 — 그가 먼저 \`athsra login\` 으로 keypair 를 provisioning 해야 합니다.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const memberPub = fromBase64(pub);
|
|
58
|
+
const memberId = memberRecipientId(memberUserId);
|
|
59
|
+
|
|
60
|
+
// adder identity(member 경로용) — owner 면 master 경로가 성공해 미사용. lazy fetch 1회.
|
|
61
|
+
let adderCache: AdderIdentity | null | undefined;
|
|
62
|
+
const getAdder = async (): Promise<AdderIdentity | null> => {
|
|
63
|
+
if (adderCache !== undefined) return adderCache;
|
|
64
|
+
const me = await client.whoami();
|
|
65
|
+
const keyRow = await client.getKeys();
|
|
66
|
+
if (me.userId === undefined || !keyRow) {
|
|
67
|
+
adderCache = null;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
adderCache = {
|
|
72
|
+
userId: me.userId,
|
|
73
|
+
privateKey: await unwrapPrivateKey(
|
|
74
|
+
{
|
|
75
|
+
wrappedPrivateKey: keyRow.wrapped_private_key,
|
|
76
|
+
keySalt: keyRow.key_salt,
|
|
77
|
+
wrapNonce: keyRow.wrap_nonce,
|
|
78
|
+
kdfParams: keyRow.kdf_params,
|
|
79
|
+
},
|
|
80
|
+
masterPw,
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
adderCache = null;
|
|
85
|
+
}
|
|
86
|
+
return adderCache;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const addRecipient = async (env: SecretEnvelopeV2): Promise<SecretEnvelopeV2> => {
|
|
90
|
+
try {
|
|
91
|
+
return await addMemberRecipient(env, masterPw, memberPub, memberUserId); // owner master 경로
|
|
92
|
+
} catch (masterErr) {
|
|
93
|
+
const adder = await getAdder();
|
|
94
|
+
if (!adder) throw masterErr;
|
|
95
|
+
return addMemberRecipientAsMember(
|
|
96
|
+
env,
|
|
97
|
+
adder.privateKey,
|
|
98
|
+
adder.userId,
|
|
99
|
+
memberPub,
|
|
100
|
+
memberUserId,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const projects = await client.listProjects();
|
|
106
|
+
const result: GrantResult = { granted: [], skipped: [], legacyV1: [], failed: [] };
|
|
107
|
+
for (const project of projects) {
|
|
108
|
+
try {
|
|
109
|
+
const env = await client.getEnvelope(project);
|
|
110
|
+
if (!env) {
|
|
111
|
+
result.skipped.push(project);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (env.version !== 2) {
|
|
115
|
+
result.legacyV1.push(project);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const has = env.recipients.some((r) => r.id === memberId);
|
|
119
|
+
if (has && !opts?.replace) {
|
|
120
|
+
result.skipped.push(project);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// replace: 기존(stale) recipient 제거 후 재포장. 신규: 그냥 추가.
|
|
124
|
+
const baseEnv = has ? removeMemberRecipient(env, memberUserId) : env;
|
|
125
|
+
const updated = await addRecipient(baseEnv);
|
|
126
|
+
await client.putEnvelope(project, updated);
|
|
127
|
+
result.granted.push(project);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
result.failed.push({ project, error: (err as Error).message });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RevokeRotateResult {
|
|
136
|
+
/** DEK 가 회전된 프로젝트. */
|
|
137
|
+
rotated: string[];
|
|
138
|
+
/** 제거 멤버가 recipient 아님(회전 불필요) 또는 envelope 부재 — 멱등 skip. */
|
|
139
|
+
skipped: string[];
|
|
140
|
+
/** v1 envelope — multi-recipient 모델 없음(제거 멤버가 recipient 일 수 없음). */
|
|
141
|
+
legacyV1: string[];
|
|
142
|
+
failed: { project: string; error: string }[];
|
|
143
|
+
/** DEK 회전으로 무효화된 service recipient (재발급 필요) — project 별. */
|
|
144
|
+
reissue: { project: string; serviceIds: string[] }[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Phase 4 Slice 6 후속 — real revocation 스윕. 멤버 제거 후, 제거 멤버가 recipient 인 모든 (v2)
|
|
149
|
+
* 공유 시크릿에서 **DEK 를 회전**(rotateEnvelopeDek)하고 master + 잔존 active 멤버에게만 재wrap →
|
|
150
|
+
* PUT. 제거 멤버가 DEK 를 탈취했더라도 회전 이후 버전을 복호하지 못한다.
|
|
151
|
+
*
|
|
152
|
+
* owner 전용: rotateEnvelopeDek 가 master recipient 를 master pw 로 재wrap 하므로 master pw 보유
|
|
153
|
+
* (=owner)가 필요하다(server recipient-continuity 도 recipient 제거를 owner-only 로 강제). 잔존
|
|
154
|
+
* 대상은 **현재 active 멤버 ∩ 그 envelope 의 member recipient** — 과거 제거-미회전으로 남은 stale
|
|
155
|
+
* recipient 도 자연 정리된다. service recipient 는 token secret 부재로 재wrap 불가 → drop + reissue
|
|
156
|
+
* 로 보고. **멱등** — 제거 멤버가 이미 recipient 아닌 envelope 는 skip(부분 실패 후 재실행 안전).
|
|
157
|
+
*/
|
|
158
|
+
export async function rotateAfterRemoval(
|
|
159
|
+
client: AthsraClient,
|
|
160
|
+
masterPw: string,
|
|
161
|
+
removedUserId: number,
|
|
162
|
+
): Promise<RevokeRotateResult> {
|
|
163
|
+
const removedId = memberRecipientId(removedUserId);
|
|
164
|
+
// 현재 active 멤버(제거 멤버는 이미 revoked 라 미포함) — 잔존 재wrap 대상의 allowlist.
|
|
165
|
+
const { members } = await client.listOrgMembers();
|
|
166
|
+
const activeIds = new Set(
|
|
167
|
+
members
|
|
168
|
+
.filter((m) => m.status === 'active' && m.user_id !== removedUserId)
|
|
169
|
+
.map((m) => m.user_id),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// 잔존 멤버 pubkey 캐시(여러 프로젝트 재사용). null = identity key 없음 → 그 project 회전 중단.
|
|
173
|
+
const pubCache = new Map<number, Uint8Array | null>();
|
|
174
|
+
const getPub = async (userId: number): Promise<Uint8Array | null> => {
|
|
175
|
+
const cached = pubCache.get(userId);
|
|
176
|
+
if (cached !== undefined) return cached;
|
|
177
|
+
const raw = await client.getPublicKey(userId);
|
|
178
|
+
const pub = raw ? fromBase64(raw) : null;
|
|
179
|
+
pubCache.set(userId, pub);
|
|
180
|
+
return pub;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const projects = await client.listProjects();
|
|
184
|
+
const result: RevokeRotateResult = {
|
|
185
|
+
rotated: [],
|
|
186
|
+
skipped: [],
|
|
187
|
+
legacyV1: [],
|
|
188
|
+
failed: [],
|
|
189
|
+
reissue: [],
|
|
190
|
+
};
|
|
191
|
+
for (const project of projects) {
|
|
192
|
+
try {
|
|
193
|
+
const env = await client.getEnvelope(project);
|
|
194
|
+
if (!env) {
|
|
195
|
+
result.skipped.push(project);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (env.version !== 2) {
|
|
199
|
+
result.legacyV1.push(project);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!env.recipients.some((r) => r.id === removedId)) {
|
|
203
|
+
result.skipped.push(project); // 제거 멤버 없음 → 회전 불필요(멱등)
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// 잔존 = 이 envelope 의 member recipient ∩ 현재 active 멤버 − 제거 멤버.
|
|
207
|
+
const remainingIds = memberRecipientUserIds(env).filter(
|
|
208
|
+
(id) => id !== removedUserId && activeIds.has(id),
|
|
209
|
+
);
|
|
210
|
+
const remaining: RemainingMember[] = [];
|
|
211
|
+
for (const id of remainingIds) {
|
|
212
|
+
const pub = await getPub(id);
|
|
213
|
+
if (!pub) {
|
|
214
|
+
// 잔존 멤버 키 부재 시 재wrap 불가 → 회전하면 그 멤버가 접근을 잃는다. 중단(fail).
|
|
215
|
+
throw new Error(`잔존 멤버 #${id} identity key 없음 — 접근 손실 방지로 회전 중단`);
|
|
216
|
+
}
|
|
217
|
+
remaining.push({ userId: id, publicKey: pub });
|
|
218
|
+
}
|
|
219
|
+
const { env: rotated, droppedServiceIds } = await rotateEnvelopeDek(env, masterPw, remaining);
|
|
220
|
+
await client.putEnvelope(project, rotated);
|
|
221
|
+
result.rotated.push(project);
|
|
222
|
+
if (droppedServiceIds.length) {
|
|
223
|
+
result.reissue.push({ project, serviceIds: droppedServiceIds });
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
result.failed.push({ project, error: (err as Error).message });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
@@ -153,10 +153,7 @@ export interface ApplyResult {
|
|
|
153
153
|
excluded: string[];
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
export function applyManifest(
|
|
157
|
-
manifest: SecretsManifest,
|
|
158
|
-
envelopeKeys: string[],
|
|
159
|
-
): ApplyResult {
|
|
156
|
+
export function applyManifest(manifest: SecretsManifest, envelopeKeys: string[]): ApplyResult {
|
|
160
157
|
const envSet = new Set(envelopeKeys);
|
|
161
158
|
const manSet = new Set(manifest.secrets);
|
|
162
159
|
const allowed: string[] = [];
|
package/src/lib/wrangler-scan.ts
CHANGED
|
@@ -192,10 +192,7 @@ export function scanWranglerConfig(workerCwd: string): WranglerIdentifiers | nul
|
|
|
192
192
|
* 사람 가독 conflict 보고 — `describeConflicts(scan, ['JWKS_KEYS', 'JWKS_URI'])`
|
|
193
193
|
* → "JWKS_KEYS (kv_namespaces), JWKS_URI (vars)"
|
|
194
194
|
*/
|
|
195
|
-
export function describeConflicts(
|
|
196
|
-
scan: WranglerIdentifiers,
|
|
197
|
-
conflicts: string[],
|
|
198
|
-
): string {
|
|
195
|
+
export function describeConflicts(scan: WranglerIdentifiers, conflicts: string[]): string {
|
|
199
196
|
return conflicts
|
|
200
197
|
.map((k) => {
|
|
201
198
|
const types = scan.byType[k];
|
|
@@ -209,10 +206,7 @@ export function describeConflicts(
|
|
|
209
206
|
* envelope/manifest key 목록에서 wrangler.jsonc 와 충돌하는 키만 반환 (alphabetical).
|
|
210
207
|
* 정공법 helper — caller 는 conflicts.length>0 로 분기 + 보고.
|
|
211
208
|
*/
|
|
212
|
-
export function findConflicts(
|
|
213
|
-
scan: WranglerIdentifiers,
|
|
214
|
-
keys: readonly string[],
|
|
215
|
-
): string[] {
|
|
209
|
+
export function findConflicts(scan: WranglerIdentifiers, keys: readonly string[]): string[] {
|
|
216
210
|
const all = new Set(scan.all);
|
|
217
211
|
const out: string[] = [];
|
|
218
212
|
for (const k of keys) {
|
package/src/lib/bip39.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { generateMnemonic, mnemonicToEntropy, validateMnemonic } from '@scure/bip39';
|
|
2
|
-
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* BIP-39 12-word mnemonic phrase 표준 (128-bit entropy, 영문 wordlist).
|
|
6
|
-
*
|
|
7
|
-
* athsra master pw 의 권장 형식. random byte string 보다:
|
|
8
|
-
* - paper backup 쉬움 (12 단어 영문)
|
|
9
|
-
* - checksum 자동 검증 (오타 detect)
|
|
10
|
-
* - hardware wallet 호환 (Ledger / Trezor 표준)
|
|
11
|
-
*
|
|
12
|
-
* 주: BIP-39 표준 master pw 강제 X. 사용자 자유 phrase 도 그대로 작동.
|
|
13
|
-
* `new-phrase` 명령으로 generate 후 `rotate-master` 또는 `login` 시 사용.
|
|
14
|
-
*/
|
|
15
|
-
export function generatePhrase(): string {
|
|
16
|
-
return generateMnemonic(wordlist, 128);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function isValidPhrase(phrase: string): boolean {
|
|
20
|
-
try {
|
|
21
|
-
return validateMnemonic(normalizePhrase(phrase), wordlist);
|
|
22
|
-
} catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 입력 normalize: trim + lowercase + 다중 공백 → single space.
|
|
29
|
-
* BIP-39 표준 비교 시 안정.
|
|
30
|
-
*/
|
|
31
|
-
export function normalizePhrase(phrase: string): string {
|
|
32
|
-
return phrase.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* BIP-39 phrase → 16 bytes entropy (validate + return). 향후 hardware wallet
|
|
37
|
-
* 통합 시 사용 (Phase 3+).
|
|
38
|
-
*/
|
|
39
|
-
export function phraseToEntropy(phrase: string): Uint8Array {
|
|
40
|
-
return mnemonicToEntropy(normalizePhrase(phrase), wordlist);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function wordCount(phrase: string): number {
|
|
44
|
-
return normalizePhrase(phrase).split(' ').filter(Boolean).length;
|
|
45
|
-
}
|