@athsra/cli 0.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/LICENSE +29 -0
- package/README.md +155 -0
- package/package.json +52 -0
- package/src/commands/delete.ts +48 -0
- package/src/commands/doctor.ts +76 -0
- package/src/commands/get.ts +40 -0
- package/src/commands/handoff.ts +138 -0
- package/src/commands/init.ts +11 -0
- package/src/commands/login.ts +142 -0
- package/src/commands/ls.ts +57 -0
- package/src/commands/new-phrase.ts +62 -0
- package/src/commands/purge.ts +44 -0
- package/src/commands/restore.ts +27 -0
- package/src/commands/revoke.ts +38 -0
- package/src/commands/rollback.ts +35 -0
- package/src/commands/rotate-master.ts +146 -0
- package/src/commands/run.ts +51 -0
- package/src/commands/set.ts +120 -0
- package/src/commands/unset.ts +82 -0
- package/src/commands/versions.ts +37 -0
- package/src/index.ts +102 -0
- package/src/lib/auth-context.ts +35 -0
- package/src/lib/bip39.ts +45 -0
- package/src/lib/client.ts +248 -0
- package/src/lib/config.ts +29 -0
- package/src/lib/env-format.ts +31 -0
- package/src/lib/keyring.ts +65 -0
- package/src/lib/legacy-session.ts +37 -0
- package/src/lib/prompt.ts +33 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
|
|
3
|
+
import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
|
|
4
|
+
import { AthsraClient } from '../lib/client.ts';
|
|
5
|
+
import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
|
|
6
|
+
import { probeKeyring, setMasterPw, setToken } from '../lib/keyring.ts';
|
|
7
|
+
import { consumeLegacySession } from '../lib/legacy-session.ts';
|
|
8
|
+
import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
|
|
9
|
+
|
|
10
|
+
export async function loginCmd(_args: string[]): Promise<number> {
|
|
11
|
+
console.log('athsra login\n');
|
|
12
|
+
|
|
13
|
+
// 1. keyring backend probe (정공법: fallback 없음)
|
|
14
|
+
const probe = probeKeyring();
|
|
15
|
+
if (!probe.ok) {
|
|
16
|
+
console.error(`✗ keyring backend unavailable: ${probe.error ?? 'unknown'}`);
|
|
17
|
+
console.error(' Run `athsra doctor` for setup instructions (apt install + dbus).');
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. legacy ~/.athsra/session 발견 시 1회 migration
|
|
22
|
+
const legacy = consumeLegacySession();
|
|
23
|
+
if (legacy) {
|
|
24
|
+
console.log('• Detected legacy ~/.athsra/session — will migrate to keyring + remove file.\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 3. config — workerUrl + machineId ($ATHSRA_WORKER_URL 우선)
|
|
28
|
+
const existing = loadConfig();
|
|
29
|
+
const envUrl = process.env.ATHSRA_WORKER_URL;
|
|
30
|
+
const workerUrl =
|
|
31
|
+
existing?.workerUrl ??
|
|
32
|
+
envUrl ??
|
|
33
|
+
(await promptText('Worker URL', 'https://athsra-worker.winterermod.workers.dev'));
|
|
34
|
+
const machineId = existing?.machineId ?? `${hostname()}-${Date.now().toString(36)}`;
|
|
35
|
+
|
|
36
|
+
// 4. master pw
|
|
37
|
+
// 우선순위: legacy session > $ATHSRA_MASTER_PW (non-interactive) > prompt
|
|
38
|
+
let masterPw: string;
|
|
39
|
+
const envPw = process.env.ATHSRA_MASTER_PW;
|
|
40
|
+
if (legacy) {
|
|
41
|
+
masterPw = legacy.masterPw;
|
|
42
|
+
console.log('• Using master password from legacy session.');
|
|
43
|
+
} else if (envPw && envPw.length >= 8) {
|
|
44
|
+
masterPw = envPw;
|
|
45
|
+
console.log('• Master password from $ATHSRA_MASTER_PW (non-interactive mode).');
|
|
46
|
+
} else {
|
|
47
|
+
masterPw = await promptPassword('Master password (8+ chars)');
|
|
48
|
+
if (masterPw.length < 8) {
|
|
49
|
+
console.error('Master password must be at least 8 characters');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const confirm = await promptPassword('Confirm');
|
|
53
|
+
if (masterPw !== confirm) {
|
|
54
|
+
console.error('Passwords do not match');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 5. paper-backup confirm + BIP-39 phrase detect (legacy 는 skip)
|
|
60
|
+
// $ATHSRA_PAPER_BACKUP_CONFIRMED=1 명시 시 prompt 우회 (호출자 명시 ack)
|
|
61
|
+
if (!legacy) {
|
|
62
|
+
if (isValidPhrase(masterPw)) {
|
|
63
|
+
const wc = wordCount(masterPw);
|
|
64
|
+
console.log(
|
|
65
|
+
`\n• Master password is a valid BIP-39 ${wc}-word phrase ✓ (recommended, checksum verified).`,
|
|
66
|
+
);
|
|
67
|
+
// BIP-39 phrase 는 normalize 후 사용 (소문자 + single space)
|
|
68
|
+
masterPw = normalizePhrase(masterPw);
|
|
69
|
+
} else {
|
|
70
|
+
console.log(
|
|
71
|
+
'\n• Tip: `athsra new-phrase` 로 BIP-39 12-word phrase 생성 가능 (분실 risk 완화).',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(
|
|
76
|
+
'\n⚠ CRITICAL: master password 분실 = 모든 secret 영구 loss (Phase 1 = recovery 없음).',
|
|
77
|
+
);
|
|
78
|
+
console.log(' 반드시 종이에 적어 안전한 곳에 보관하세요.');
|
|
79
|
+
if (process.env.ATHSRA_PAPER_BACKUP_CONFIRMED === '1') {
|
|
80
|
+
console.log('• $ATHSRA_PAPER_BACKUP_CONFIRMED=1 — paper-backup ack 자동 적용.');
|
|
81
|
+
} else {
|
|
82
|
+
const acked = await promptConfirm(
|
|
83
|
+
'I have written down my master password on paper and stored it safely',
|
|
84
|
+
);
|
|
85
|
+
if (!acked) {
|
|
86
|
+
console.error('Aborted — please backup your master password before continuing.');
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 6. worker reachability + global_salt fetch
|
|
93
|
+
const tempClient = new AthsraClient(workerUrl);
|
|
94
|
+
const reachable = await tempClient.health();
|
|
95
|
+
if (!reachable) {
|
|
96
|
+
console.error(`✗ worker unreachable: ${workerUrl}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const info = await tempClient.info();
|
|
100
|
+
if (info.phase < 1) {
|
|
101
|
+
console.error(`✗ worker phase=${info.phase} — Pre-A 인증 미배포. wrangler deploy 후 재시도.`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 7. master_pw_proof = Argon2id(pw + GLOBAL_SALT)
|
|
106
|
+
const proofBytes = deriveKey(masterPw, fromBase64(info.global_salt));
|
|
107
|
+
const proofBase64 = toBase64(proofBytes);
|
|
108
|
+
|
|
109
|
+
// 8. register → token
|
|
110
|
+
let reg: Awaited<ReturnType<typeof tempClient.register>>;
|
|
111
|
+
try {
|
|
112
|
+
reg = await tempClient.register(proofBase64, machineId);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const msg = (err as Error).message;
|
|
115
|
+
if (msg.includes('401')) {
|
|
116
|
+
console.error('✗ master password mismatch — 이미 등록된 master pw 와 다릅니다.');
|
|
117
|
+
console.error(' 올바른 master pw 입력 또는 기존 머신에서 revoke 후 재시도.');
|
|
118
|
+
} else {
|
|
119
|
+
console.error(`✗ register failed: ${msg}`);
|
|
120
|
+
}
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 9. config + keyring 저장
|
|
125
|
+
const config: Config = {
|
|
126
|
+
workerUrl,
|
|
127
|
+
machineId,
|
|
128
|
+
createdAt: existing?.createdAt ?? reg.createdAt,
|
|
129
|
+
};
|
|
130
|
+
saveConfig(config);
|
|
131
|
+
setMasterPw(machineId, masterPw);
|
|
132
|
+
setToken(machineId, reg.token);
|
|
133
|
+
|
|
134
|
+
console.log(`\n✓ logged in (machine: ${machineId})`);
|
|
135
|
+
console.log(` worker: ${workerUrl}`);
|
|
136
|
+
console.log(` config: ~/.athsra/config.json`);
|
|
137
|
+
console.log(' keyring: master-pw + token saved (OS keyring, 무기한)');
|
|
138
|
+
if (legacy) {
|
|
139
|
+
console.log(' legacy: ~/.athsra/session migrated and removed.');
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { decrypt, deriveKey, fromBase64 } from '@athsra/crypto';
|
|
2
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
+
import { parseEnv } from '../lib/env-format.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* athsra ls — active projects only
|
|
7
|
+
* athsra ls --all — active + soft-deleted (deleted 표시)
|
|
8
|
+
* athsra ls --include-deleted — alias of --all
|
|
9
|
+
* athsra ls <project> — keys of project (decrypt 필요)
|
|
10
|
+
*/
|
|
11
|
+
export async function lsCmd(args: string[]): Promise<number> {
|
|
12
|
+
const ctx = loadAuthContext();
|
|
13
|
+
if (!ctx) return 1;
|
|
14
|
+
const { masterPw, client } = ctx;
|
|
15
|
+
|
|
16
|
+
const positional = args.filter((a) => !a.startsWith('-'));
|
|
17
|
+
const includeDeleted = args.includes('--all') || args.includes('--include-deleted');
|
|
18
|
+
|
|
19
|
+
// ls — project 목록
|
|
20
|
+
if (positional.length === 0) {
|
|
21
|
+
const result = await client.listProjectsExtended({ includeDeleted });
|
|
22
|
+
if (result.count === 0) {
|
|
23
|
+
console.log('(no projects yet — run `athsra set <project> KEY=value`)');
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(result.projects) && typeof result.projects[0] === 'string') {
|
|
27
|
+
// active-only response
|
|
28
|
+
for (const p of result.projects as string[]) console.log(p);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
// include_deleted response — { project, active, deleted }
|
|
32
|
+
const items = result.projects as { project: string; active: boolean; deleted: boolean }[];
|
|
33
|
+
for (const it of items) {
|
|
34
|
+
const tag = it.deleted ? ' (deleted)' : '';
|
|
35
|
+
console.log(`${it.project}${tag}`);
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ls <project> — key 목록 (값 없음, decrypt 후 key 만)
|
|
41
|
+
const project = positional[0];
|
|
42
|
+
if (!project) return 0;
|
|
43
|
+
|
|
44
|
+
const envelope = await client.getEnvelope(project);
|
|
45
|
+
if (!envelope) {
|
|
46
|
+
console.error(`project not found: ${project}`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const derived = deriveKey(masterPw, fromBase64(envelope.salt));
|
|
50
|
+
const text = await decrypt(derived, {
|
|
51
|
+
ciphertext: fromBase64(envelope.ciphertext),
|
|
52
|
+
nonce: fromBase64(envelope.nonce),
|
|
53
|
+
});
|
|
54
|
+
const plain = parseEnv(text);
|
|
55
|
+
for (const k of Object.keys(plain).sort()) console.log(k);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { generatePhrase, wordCount } from '../lib/bip39.ts';
|
|
2
|
+
import { promptConfirm } from '../lib/prompt.ts';
|
|
3
|
+
|
|
4
|
+
const USAGE = [
|
|
5
|
+
'usage: athsra new-phrase # random BIP-39 12-word phrase 생성',
|
|
6
|
+
'',
|
|
7
|
+
'Phase 1+ 권장: master password 를 BIP-39 12-word phrase 로 사용.',
|
|
8
|
+
' - 128-bit entropy + checksum 검증',
|
|
9
|
+
' - 종이 backup 쉬움 (영문 12 단어)',
|
|
10
|
+
' - Ledger/Trezor 호환 표준 (Phase 3+ hardware wallet 통합 가능)',
|
|
11
|
+
'',
|
|
12
|
+
'생성 후 흐름:',
|
|
13
|
+
' - 첫 머신: athsra login # master pw 입력 시 phrase 사용',
|
|
14
|
+
' - 변경: athsra rotate-master # 기존 pw 를 phrase 로 교체',
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
export async function newPhraseCmd(args: string[]): Promise<number> {
|
|
18
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
19
|
+
console.log(USAGE);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const phrase = generatePhrase();
|
|
24
|
+
const count = wordCount(phrase);
|
|
25
|
+
|
|
26
|
+
console.log('\n=== BIP-39 12-word recovery phrase (master password 후보) ===\n');
|
|
27
|
+
// 4 단어씩 줄바꿈 (paper backup 쓰기 쉽게)
|
|
28
|
+
const words = phrase.split(' ');
|
|
29
|
+
for (let i = 0; i < words.length; i += 4) {
|
|
30
|
+
const line = words
|
|
31
|
+
.slice(i, i + 4)
|
|
32
|
+
.map((w, idx) => `${(i + idx + 1).toString().padStart(2, ' ')}. ${w.padEnd(10, ' ')}`)
|
|
33
|
+
.join(' ');
|
|
34
|
+
console.log(` ${line}`);
|
|
35
|
+
}
|
|
36
|
+
console.log(`\n (${count} words, 128-bit entropy, BIP-39 checksum verified)\n`);
|
|
37
|
+
console.log('============================================================\n');
|
|
38
|
+
|
|
39
|
+
console.log('⚠ CRITICAL — 종이에 정확히 적어 안전한 곳에 보관하세요.');
|
|
40
|
+
console.log(' - phrase 자체가 master password 입니다.');
|
|
41
|
+
console.log(' - 분실 시 모든 secret 영구 loss (Phase 1 = recovery 없음).');
|
|
42
|
+
console.log(' - 다른 사람과 공유 X. 사진 / 클라우드 backup 권장 X.\n');
|
|
43
|
+
|
|
44
|
+
if (process.env.ATHSRA_PAPER_BACKUP_CONFIRMED === '1') {
|
|
45
|
+
console.log('• $ATHSRA_PAPER_BACKUP_CONFIRMED=1 — ack 자동 적용.\n');
|
|
46
|
+
} else {
|
|
47
|
+
const acked = await promptConfirm(
|
|
48
|
+
'I have written down the BIP-39 phrase on paper exactly and stored it safely',
|
|
49
|
+
);
|
|
50
|
+
if (!acked) {
|
|
51
|
+
console.error('Aborted — phrase 는 메모리에서 폐기됩니다 (재생성하려면 다시 실행).');
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('다음 단계:');
|
|
57
|
+
console.log(
|
|
58
|
+
' athsra login # 첫 머신 (PROOF bootstrap, 위 phrase 를 master pw 로 입력)',
|
|
59
|
+
);
|
|
60
|
+
console.log(' athsra rotate-master # 기존 master pw 가 있다면 위 phrase 로 변경');
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { promptConfirm } from '../lib/prompt.ts';
|
|
3
|
+
|
|
4
|
+
const USAGE = [
|
|
5
|
+
'usage: athsra purge <project> # hard-delete (모든 versions 영구 제거)',
|
|
6
|
+
' or: athsra purge <project> --yes # confirm 우회 (CI 용)',
|
|
7
|
+
'',
|
|
8
|
+
'note: athsra delete <project> --hard 와 동일. 명시적 별칭.',
|
|
9
|
+
].join('\n');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* athsra purge <project>
|
|
13
|
+
* - hard-delete alias. 모든 versions + tombstone 영구 제거. 복원 불가능.
|
|
14
|
+
* - delete --hard 와 동일 동작이지만 명시적 명령 — 위험성을 더 분명히 드러냄.
|
|
15
|
+
* - 무조건 double confirm (--yes 또는 ATHSRA_PURGE_CONFIRMED=1 로 우회)
|
|
16
|
+
*/
|
|
17
|
+
export async function purgeCmd(args: string[]): Promise<number> {
|
|
18
|
+
const project = args[0];
|
|
19
|
+
if (!project) {
|
|
20
|
+
console.error(USAGE);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
24
|
+
|
|
25
|
+
const ctx = loadAuthContext();
|
|
26
|
+
if (!ctx) return 1;
|
|
27
|
+
const { client } = ctx;
|
|
28
|
+
|
|
29
|
+
if (!yes && process.env.ATHSRA_PURGE_CONFIRMED !== '1') {
|
|
30
|
+
const ok = await promptConfirm(
|
|
31
|
+
`PURGE ${project}? All version history permanently removed. NOT RECOVERABLE.`,
|
|
32
|
+
false,
|
|
33
|
+
);
|
|
34
|
+
if (!ok) return 0;
|
|
35
|
+
const ok2 = await promptConfirm(`Type confirmation: really purge ${project}?`, false);
|
|
36
|
+
if (!ok2) return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = await client.deleteProject(project, { hard: true });
|
|
40
|
+
console.log(
|
|
41
|
+
`✓ ${project}: purged (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} permanently removed)`,
|
|
42
|
+
);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
|
|
3
|
+
const USAGE = 'usage: athsra restore <project> # tombstone 제거 + 최신 version 활성화';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* athsra restore <project>
|
|
7
|
+
* - soft-deleted project 복원 (tombstone 제거 + 가장 최신 version 으로 current 복원)
|
|
8
|
+
* - tombstone 없으면 400 — 이미 활성
|
|
9
|
+
* - hard-delete 후엔 versions 가 없어 복원 불가
|
|
10
|
+
*/
|
|
11
|
+
export async function restoreCmd(args: string[]): Promise<number> {
|
|
12
|
+
const project = args[0];
|
|
13
|
+
if (!project) {
|
|
14
|
+
console.error(USAGE);
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ctx = loadAuthContext();
|
|
19
|
+
if (!ctx) return 1;
|
|
20
|
+
const { client } = ctx;
|
|
21
|
+
|
|
22
|
+
const result = await client.restoreProject(project);
|
|
23
|
+
console.log(
|
|
24
|
+
`✓ ${project}: restored to ${result.restored_version} (was deleted ${result.deleted_at} by ${result.deleted_by})`,
|
|
25
|
+
);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { clearMasterPw, clearToken } from '../lib/keyring.ts';
|
|
3
|
+
import { promptConfirm } from '../lib/prompt.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* athsra revoke — 자기 token revoke (현재 머신)
|
|
7
|
+
* athsra revoke <atk_*> — 명시 token revoke (다른 머신 정리)
|
|
8
|
+
*
|
|
9
|
+
* self-revoke 시 keyring 의 master-pw + token 도 함께 clear (다음 set/get 시 login 필요).
|
|
10
|
+
*/
|
|
11
|
+
export async function revokeCmd(args: string[]): Promise<number> {
|
|
12
|
+
const target = args[0];
|
|
13
|
+
const ctx = loadAuthContext();
|
|
14
|
+
if (!ctx) return 1;
|
|
15
|
+
const { client, config } = ctx;
|
|
16
|
+
|
|
17
|
+
if (!target) {
|
|
18
|
+
if (process.env.ATHSRA_REVOKE_CONFIRMED !== '1') {
|
|
19
|
+
const ok = await promptConfirm(
|
|
20
|
+
'Revoke this machine token? Next set/get will require athsra login.',
|
|
21
|
+
);
|
|
22
|
+
if (!ok) return 0;
|
|
23
|
+
}
|
|
24
|
+
const result = await client.revoke();
|
|
25
|
+
clearMasterPw(config.machineId);
|
|
26
|
+
clearToken(config.machineId);
|
|
27
|
+
console.log(`✓ self-revoke: ${result.revoked} (keyring cleared)`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!target.startsWith('atk_')) {
|
|
32
|
+
console.error('Token must start with atk_*');
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
const result = await client.revoke(target);
|
|
36
|
+
console.log(`✓ revoke: ${result.revoked}`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
import { promptConfirm } from '../lib/prompt.ts';
|
|
3
|
+
|
|
4
|
+
const USAGE = 'usage: athsra rollback <project> <version_id>';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* athsra rollback <project> <version_id>
|
|
8
|
+
* - 특정 version 을 current 로 복원
|
|
9
|
+
* - tombstone 있으면 자동 제거 (rollback 은 항상 active 상태로)
|
|
10
|
+
* - confirm 필요 (--yes 로 우회 가능)
|
|
11
|
+
*/
|
|
12
|
+
export async function rollbackCmd(args: string[]): Promise<number> {
|
|
13
|
+
const project = args[0];
|
|
14
|
+
const versionId = args[1];
|
|
15
|
+
if (!project || !versionId) {
|
|
16
|
+
console.error(USAGE);
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
20
|
+
|
|
21
|
+
const ctx = loadAuthContext();
|
|
22
|
+
if (!ctx) return 1;
|
|
23
|
+
const { client } = ctx;
|
|
24
|
+
|
|
25
|
+
if (!yes && process.env.ATHSRA_ROLLBACK_CONFIRMED !== '1') {
|
|
26
|
+
const ok = await promptConfirm(
|
|
27
|
+
`Rollback ${project} to ${versionId}? Current version becomes inactive (still in history).`,
|
|
28
|
+
);
|
|
29
|
+
if (!ok) return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = await client.rollbackProject(project, versionId);
|
|
33
|
+
console.log(`✓ ${project}: rolled back to ${result.current_version}`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_KDF,
|
|
3
|
+
decrypt,
|
|
4
|
+
deriveKey,
|
|
5
|
+
encrypt,
|
|
6
|
+
fromBase64,
|
|
7
|
+
randomSalt,
|
|
8
|
+
type SecretEnvelope,
|
|
9
|
+
toBase64,
|
|
10
|
+
} from '@athsra/crypto';
|
|
11
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
12
|
+
import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
|
|
13
|
+
import { AthsraClient } from '../lib/client.ts';
|
|
14
|
+
import { setMasterPw, setToken } from '../lib/keyring.ts';
|
|
15
|
+
import { promptConfirm, promptPassword } from '../lib/prompt.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* athsra rotate-master — master password 변경 (모든 projects re-encrypt).
|
|
19
|
+
*
|
|
20
|
+
* 흐름:
|
|
21
|
+
* 1. 옛 master pw (keyring) 로 모든 envelope decrypt
|
|
22
|
+
* 2. POST /auth/rotate-master — 옛 proof 검증 + 새 proof 저장 + 모든 token 삭제 + 새 token 발급
|
|
23
|
+
* 3. 새 master pw + 새 token 으로 모든 envelope re-encrypt + PUT
|
|
24
|
+
* 4. keyring 갱신
|
|
25
|
+
*
|
|
26
|
+
* 비-원자적 (step 3 도중 실패 시 일부 envelope 만 새 master pw 로). 다만 step 2
|
|
27
|
+
* 이후에는 옛 master pw 와 옛 token 모두 invalid — 사용자 안내로 rotate-master 재시도 권장.
|
|
28
|
+
*
|
|
29
|
+
* envvar:
|
|
30
|
+
* ATHSRA_NEW_MASTER_PW — 새 master pw (non-interactive)
|
|
31
|
+
* ATHSRA_PAPER_BACKUP_CONFIRMED=1 — paper-backup ack 자동
|
|
32
|
+
*/
|
|
33
|
+
export async function rotateMasterCmd(_args: string[]): Promise<number> {
|
|
34
|
+
console.log('athsra rotate-master\n');
|
|
35
|
+
|
|
36
|
+
const ctx = loadAuthContext();
|
|
37
|
+
if (!ctx) return 1;
|
|
38
|
+
const { masterPw: oldPw, client, config } = ctx;
|
|
39
|
+
|
|
40
|
+
// 새 master pw 입력
|
|
41
|
+
let newPw: string;
|
|
42
|
+
const envPw = process.env.ATHSRA_NEW_MASTER_PW;
|
|
43
|
+
if (envPw && envPw.length >= 8) {
|
|
44
|
+
newPw = envPw;
|
|
45
|
+
console.log('• New master pw from $ATHSRA_NEW_MASTER_PW (non-interactive).');
|
|
46
|
+
} else {
|
|
47
|
+
newPw = await promptPassword('New master password (8+ chars)');
|
|
48
|
+
if (newPw.length < 8) {
|
|
49
|
+
console.error('Master password must be at least 8 characters');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const confirm = await promptPassword('Confirm');
|
|
53
|
+
if (newPw !== confirm) {
|
|
54
|
+
console.error('Passwords do not match');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (newPw === oldPw) {
|
|
60
|
+
console.error('새 master pw 가 기존과 동일합니다.');
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// BIP-39 phrase detect + normalize
|
|
65
|
+
if (isValidPhrase(newPw)) {
|
|
66
|
+
const wc = wordCount(newPw);
|
|
67
|
+
console.log(`• New master pw is a valid BIP-39 ${wc}-word phrase ✓ (recommended).`);
|
|
68
|
+
newPw = normalizePhrase(newPw);
|
|
69
|
+
} else {
|
|
70
|
+
console.log('• Tip: `athsra new-phrase` 로 BIP-39 12-word phrase 생성 가능.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// paper-backup confirm
|
|
74
|
+
console.log('\n⚠ CRITICAL: 새 master password 분실 = 모든 secret 영구 loss.');
|
|
75
|
+
console.log(' 반드시 종이에 적어 안전한 곳에 보관하세요.');
|
|
76
|
+
if (process.env.ATHSRA_PAPER_BACKUP_CONFIRMED === '1') {
|
|
77
|
+
console.log('• $ATHSRA_PAPER_BACKUP_CONFIRMED=1 — ack 자동.');
|
|
78
|
+
} else {
|
|
79
|
+
const acked = await promptConfirm(
|
|
80
|
+
'I have written down the NEW master password on paper and stored it safely',
|
|
81
|
+
);
|
|
82
|
+
if (!acked) {
|
|
83
|
+
console.error('Aborted.');
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 1. 모든 projects fetch + decrypt (옛 master pw)
|
|
89
|
+
const projects = await client.listProjects();
|
|
90
|
+
console.log(`\n• Decrypting ${projects.length} projects with old master pw...`);
|
|
91
|
+
const plaintexts: Record<string, string> = {};
|
|
92
|
+
for (const p of projects) {
|
|
93
|
+
const env = await client.getEnvelope(p);
|
|
94
|
+
if (!env) continue;
|
|
95
|
+
const key = deriveKey(oldPw, fromBase64(env.salt));
|
|
96
|
+
const text = await decrypt(key, {
|
|
97
|
+
ciphertext: fromBase64(env.ciphertext),
|
|
98
|
+
nonce: fromBase64(env.nonce),
|
|
99
|
+
});
|
|
100
|
+
plaintexts[p] = text;
|
|
101
|
+
console.log(` ✓ ${p} (${text.split('\n').filter((l) => l.includes('=')).length} keys)`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. POST /auth/rotate-master
|
|
105
|
+
console.log('\n• Rotating server-side proof + invalidating all tokens...');
|
|
106
|
+
const info = await client.info();
|
|
107
|
+
const globalSalt = fromBase64(info.global_salt);
|
|
108
|
+
const oldProof = toBase64(deriveKey(oldPw, globalSalt));
|
|
109
|
+
const newProof = toBase64(deriveKey(newPw, globalSalt));
|
|
110
|
+
const rot = await client.rotateMaster(oldProof, newProof);
|
|
111
|
+
console.log(` ✓ new token issued (machineId=${rot.machineId})`);
|
|
112
|
+
|
|
113
|
+
// 3. keyring 갱신 + 새 client
|
|
114
|
+
setMasterPw(config.machineId, newPw);
|
|
115
|
+
setToken(config.machineId, rot.token);
|
|
116
|
+
const newClient = new AthsraClient(config.workerUrl, rot.token);
|
|
117
|
+
|
|
118
|
+
// 4. re-encrypt + PUT
|
|
119
|
+
console.log(`\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw...`);
|
|
120
|
+
for (const [p, text] of Object.entries(plaintexts)) {
|
|
121
|
+
const newSalt = randomSalt();
|
|
122
|
+
const newKey = deriveKey(newPw, newSalt);
|
|
123
|
+
const blob = await encrypt(newKey, text);
|
|
124
|
+
const envelope: SecretEnvelope = {
|
|
125
|
+
version: 1,
|
|
126
|
+
alg: 'aes-256-gcm',
|
|
127
|
+
kdf: 'argon2id',
|
|
128
|
+
kdf_params: DEFAULT_KDF,
|
|
129
|
+
salt: toBase64(newSalt),
|
|
130
|
+
nonce: toBase64(blob.nonce),
|
|
131
|
+
ciphertext: toBase64(blob.ciphertext),
|
|
132
|
+
version_id: `v${Date.now()}`,
|
|
133
|
+
updated_at: new Date().toISOString(),
|
|
134
|
+
};
|
|
135
|
+
await newClient.putEnvelope(p, envelope);
|
|
136
|
+
console.log(` ✓ ${p}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(
|
|
140
|
+
`\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted.`,
|
|
141
|
+
);
|
|
142
|
+
console.log(
|
|
143
|
+
' 다른 머신은 모두 token invalidated — 각 머신에서 athsra login (handoff) 재실행 필요.',
|
|
144
|
+
);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { decrypt, deriveKey, fromBase64 } from '@athsra/crypto';
|
|
3
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
4
|
+
import { parseEnv } from '../lib/env-format.ts';
|
|
5
|
+
|
|
6
|
+
export async function runCmd(args: string[]): Promise<number> {
|
|
7
|
+
const project = args[0];
|
|
8
|
+
const sepIdx = args.indexOf('--');
|
|
9
|
+
if (!project || sepIdx < 1) {
|
|
10
|
+
console.error('usage: athsra run <project> -- <command> [args...]');
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
13
|
+
const cmd = args.slice(sepIdx + 1);
|
|
14
|
+
if (cmd.length === 0) {
|
|
15
|
+
console.error('No command after --');
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ctx = loadAuthContext();
|
|
20
|
+
if (!ctx) return 1;
|
|
21
|
+
const { masterPw, client } = ctx;
|
|
22
|
+
const envelope = await client.getEnvelope(project);
|
|
23
|
+
if (!envelope) {
|
|
24
|
+
console.error(`project not found: ${project}`);
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const derived = deriveKey(masterPw, fromBase64(envelope.salt));
|
|
29
|
+
const text = await decrypt(derived, {
|
|
30
|
+
ciphertext: fromBase64(envelope.ciphertext),
|
|
31
|
+
nonce: fromBase64(envelope.nonce),
|
|
32
|
+
});
|
|
33
|
+
const plain = parseEnv(text);
|
|
34
|
+
|
|
35
|
+
return await new Promise<number>((resolve) => {
|
|
36
|
+
const cmdName = cmd[0];
|
|
37
|
+
if (!cmdName) {
|
|
38
|
+
resolve(2);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const child = spawn(cmdName, cmd.slice(1), {
|
|
42
|
+
env: { ...process.env, ...plain },
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
});
|
|
45
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
46
|
+
child.on('error', (err) => {
|
|
47
|
+
console.error(`spawn error: ${err.message}`);
|
|
48
|
+
resolve(1);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|