@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,120 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_KDF,
|
|
4
|
+
decrypt,
|
|
5
|
+
deriveKey,
|
|
6
|
+
encrypt,
|
|
7
|
+
fromBase64,
|
|
8
|
+
randomSalt,
|
|
9
|
+
type SecretEnvelope,
|
|
10
|
+
toBase64,
|
|
11
|
+
} from '@athsra/crypto';
|
|
12
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
13
|
+
import { parseEnv, serializeEnv } from '../lib/env-format.ts';
|
|
14
|
+
|
|
15
|
+
const USAGE = [
|
|
16
|
+
'usage: athsra set <project> KEY=value [KEY2=value2 ...]',
|
|
17
|
+
' or: athsra set <project> --from-file <path> (.env 형식)',
|
|
18
|
+
' or: athsra set <project> --stdin (.env 형식 stdin)',
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
async function readStdin(): Promise<string> {
|
|
22
|
+
const chunks: Buffer[] = [];
|
|
23
|
+
for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
}
|
|
26
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsePairsArg(pairs: string[]): Record<string, string> | null {
|
|
30
|
+
const out: Record<string, string> = {};
|
|
31
|
+
for (const pair of pairs) {
|
|
32
|
+
const eqIdx = pair.indexOf('=');
|
|
33
|
+
if (eqIdx < 0) {
|
|
34
|
+
console.error(`Invalid format (missing =): ${pair}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
out[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function setCmd(args: string[]): Promise<number> {
|
|
43
|
+
const project = args[0];
|
|
44
|
+
if (!project) {
|
|
45
|
+
console.error(USAGE);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const rest = args.slice(1);
|
|
49
|
+
|
|
50
|
+
let updates: Record<string, string>;
|
|
51
|
+
if (rest[0] === '--from-file') {
|
|
52
|
+
const path = rest[1];
|
|
53
|
+
if (!path) {
|
|
54
|
+
console.error('--from-file requires a path');
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
const text = readFileSync(path, 'utf-8');
|
|
58
|
+
updates = parseEnv(text);
|
|
59
|
+
} else if (rest[0] === '--stdin') {
|
|
60
|
+
const text = await readStdin();
|
|
61
|
+
updates = parseEnv(text);
|
|
62
|
+
} else {
|
|
63
|
+
if (rest.length === 0) {
|
|
64
|
+
console.error('No KEY=value pairs given');
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
const parsed = parsePairsArg(rest);
|
|
68
|
+
if (!parsed) return 2;
|
|
69
|
+
updates = parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const updateCount = Object.keys(updates).length;
|
|
73
|
+
if (updateCount === 0) {
|
|
74
|
+
console.error('No KEY=value pairs given (input was empty)');
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ctx = loadAuthContext();
|
|
79
|
+
if (!ctx) return 1;
|
|
80
|
+
const { masterPw, client } = ctx;
|
|
81
|
+
|
|
82
|
+
// 기존 envelope fetch + decrypt (read-modify-write)
|
|
83
|
+
let plain: Record<string, string> = {};
|
|
84
|
+
const existing = await client.getEnvelope(project);
|
|
85
|
+
if (existing) {
|
|
86
|
+
const key = deriveKey(masterPw, fromBase64(existing.salt));
|
|
87
|
+
const text = await decrypt(key, {
|
|
88
|
+
ciphertext: fromBase64(existing.ciphertext),
|
|
89
|
+
nonce: fromBase64(existing.nonce),
|
|
90
|
+
});
|
|
91
|
+
plain = parseEnv(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// merge updates
|
|
95
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
96
|
+
plain[k] = v;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 새 envelope (새 salt + nonce 매번)
|
|
100
|
+
const newSalt = randomSalt();
|
|
101
|
+
const newKey = deriveKey(masterPw, newSalt);
|
|
102
|
+
const blob = await encrypt(newKey, serializeEnv(plain));
|
|
103
|
+
const envelope: SecretEnvelope = {
|
|
104
|
+
version: 1,
|
|
105
|
+
alg: 'aes-256-gcm',
|
|
106
|
+
kdf: 'argon2id',
|
|
107
|
+
kdf_params: DEFAULT_KDF,
|
|
108
|
+
salt: toBase64(newSalt),
|
|
109
|
+
nonce: toBase64(blob.nonce),
|
|
110
|
+
ciphertext: toBase64(blob.ciphertext),
|
|
111
|
+
version_id: `v${Date.now()}`,
|
|
112
|
+
updated_at: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await client.putEnvelope(project, envelope);
|
|
116
|
+
console.log(
|
|
117
|
+
`✓ ${project}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
|
|
118
|
+
);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { parseEnv, serializeEnv } from '../lib/env-format.ts';
|
|
13
|
+
|
|
14
|
+
const USAGE =
|
|
15
|
+
'usage: athsra unset <project> KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)';
|
|
16
|
+
|
|
17
|
+
export async function unsetCmd(args: string[]): Promise<number> {
|
|
18
|
+
const project = args[0];
|
|
19
|
+
if (!project) {
|
|
20
|
+
console.error(USAGE);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
const keys = args.slice(1);
|
|
24
|
+
if (keys.length === 0) {
|
|
25
|
+
console.error('No keys to unset');
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ctx = loadAuthContext();
|
|
30
|
+
if (!ctx) return 1;
|
|
31
|
+
const { masterPw, client } = ctx;
|
|
32
|
+
|
|
33
|
+
const existing = await client.getEnvelope(project);
|
|
34
|
+
if (!existing) {
|
|
35
|
+
console.error(`project not found: ${project}`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
const oldKey = deriveKey(masterPw, fromBase64(existing.salt));
|
|
39
|
+
const text = await decrypt(oldKey, {
|
|
40
|
+
ciphertext: fromBase64(existing.ciphertext),
|
|
41
|
+
nonce: fromBase64(existing.nonce),
|
|
42
|
+
});
|
|
43
|
+
const plain = parseEnv(text);
|
|
44
|
+
|
|
45
|
+
const removed: string[] = [];
|
|
46
|
+
const notFound: string[] = [];
|
|
47
|
+
for (const k of keys) {
|
|
48
|
+
if (k in plain) {
|
|
49
|
+
delete plain[k];
|
|
50
|
+
removed.push(k);
|
|
51
|
+
} else {
|
|
52
|
+
notFound.push(k);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (notFound.length > 0) {
|
|
57
|
+
console.error(`✗ keys not found: ${notFound.join(', ')}`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// re-encrypt + PUT (새 salt + nonce)
|
|
62
|
+
const newSalt = randomSalt();
|
|
63
|
+
const newKey = deriveKey(masterPw, newSalt);
|
|
64
|
+
const blob = await encrypt(newKey, serializeEnv(plain));
|
|
65
|
+
const envelope: SecretEnvelope = {
|
|
66
|
+
version: 1,
|
|
67
|
+
alg: 'aes-256-gcm',
|
|
68
|
+
kdf: 'argon2id',
|
|
69
|
+
kdf_params: DEFAULT_KDF,
|
|
70
|
+
salt: toBase64(newSalt),
|
|
71
|
+
nonce: toBase64(blob.nonce),
|
|
72
|
+
ciphertext: toBase64(blob.ciphertext),
|
|
73
|
+
version_id: `v${Date.now()}`,
|
|
74
|
+
updated_at: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await client.putEnvelope(project, envelope);
|
|
78
|
+
console.log(
|
|
79
|
+
`✓ ${project}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
|
|
80
|
+
);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
2
|
+
|
|
3
|
+
const USAGE = 'usage: athsra versions <project>';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* athsra versions <project>
|
|
7
|
+
* - 모든 version + tombstone 상태 출력
|
|
8
|
+
* - current version 은 * 표시
|
|
9
|
+
* - tombstone 있으면 (deleted) 헤더로 표시
|
|
10
|
+
*/
|
|
11
|
+
export async function versionsCmd(args: string[]): Promise<number> {
|
|
12
|
+
const project = args[0];
|
|
13
|
+
if (!project) {
|
|
14
|
+
console.error(USAGE);
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
const ctx = loadAuthContext();
|
|
18
|
+
if (!ctx) return 1;
|
|
19
|
+
const { client } = ctx;
|
|
20
|
+
|
|
21
|
+
const data = await client.listVersions(project);
|
|
22
|
+
if (data.tombstone) {
|
|
23
|
+
console.log(
|
|
24
|
+
`(deleted ${data.tombstone.deleted_at} by ${data.tombstone.deleted_by} — ${data.versions.length} version(s) recoverable)`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
if (data.count === 0) {
|
|
28
|
+
console.log('(no versions — project never existed or hard-deleted)');
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
for (const v of data.versions) {
|
|
32
|
+
const marker = v.version_id === data.current_version ? '*' : ' ';
|
|
33
|
+
console.log(`${marker} ${v.version_id} ${v.updated_at} (${v.size}B)`);
|
|
34
|
+
}
|
|
35
|
+
console.log(`(${data.count} version${data.count > 1 ? 's' : ''})`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { deleteCmd } from './commands/delete.ts';
|
|
3
|
+
import { doctorCmd } from './commands/doctor.ts';
|
|
4
|
+
import { getCmd } from './commands/get.ts';
|
|
5
|
+
import { handoffCmd } from './commands/handoff.ts';
|
|
6
|
+
import { initCmd } from './commands/init.ts';
|
|
7
|
+
import { loginCmd } from './commands/login.ts';
|
|
8
|
+
import { lsCmd } from './commands/ls.ts';
|
|
9
|
+
import { newPhraseCmd } from './commands/new-phrase.ts';
|
|
10
|
+
import { purgeCmd } from './commands/purge.ts';
|
|
11
|
+
import { restoreCmd } from './commands/restore.ts';
|
|
12
|
+
import { revokeCmd } from './commands/revoke.ts';
|
|
13
|
+
import { rollbackCmd } from './commands/rollback.ts';
|
|
14
|
+
import { rotateMasterCmd } from './commands/rotate-master.ts';
|
|
15
|
+
import { runCmd } from './commands/run.ts';
|
|
16
|
+
import { setCmd } from './commands/set.ts';
|
|
17
|
+
import { unsetCmd } from './commands/unset.ts';
|
|
18
|
+
import { versionsCmd } from './commands/versions.ts';
|
|
19
|
+
|
|
20
|
+
const VERSION = '0.1.0';
|
|
21
|
+
|
|
22
|
+
const commands: Record<string, (args: string[]) => Promise<number>> = {
|
|
23
|
+
login: loginCmd,
|
|
24
|
+
init: initCmd,
|
|
25
|
+
set: setCmd,
|
|
26
|
+
unset: unsetCmd,
|
|
27
|
+
get: getCmd,
|
|
28
|
+
ls: lsCmd,
|
|
29
|
+
run: runCmd,
|
|
30
|
+
doctor: doctorCmd,
|
|
31
|
+
'rotate-master': rotateMasterCmd,
|
|
32
|
+
'new-phrase': newPhraseCmd,
|
|
33
|
+
handoff: handoffCmd,
|
|
34
|
+
revoke: revokeCmd,
|
|
35
|
+
versions: versionsCmd,
|
|
36
|
+
rollback: rollbackCmd,
|
|
37
|
+
delete: deleteCmd,
|
|
38
|
+
restore: restoreCmd,
|
|
39
|
+
purge: purgeCmd,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const args = Bun.argv.slice(2);
|
|
43
|
+
const cmd = args[0];
|
|
44
|
+
|
|
45
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
46
|
+
console.log(`athsra v${VERSION} — E2EE secret manager on Cloudflare edge
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
athsra login master pw 입력 + 머신 등록 (Bearer token 발급)
|
|
50
|
+
athsra init <project> 신규 project 안내
|
|
51
|
+
athsra set <project> KEY=value secret 추가/수정 (다건 가능)
|
|
52
|
+
athsra unset <project> KEY [...] 특정 key 제거 (envelope 유지)
|
|
53
|
+
athsra get <project> [KEY] 값 출력 (single 또는 dump)
|
|
54
|
+
athsra ls [project] [--all] project 또는 key 목록 (--all=deleted 포함)
|
|
55
|
+
athsra run <project> -- <cmd> env inject 후 명령 실행 (Doppler-style)
|
|
56
|
+
athsra doctor 환경 검증 (keyring/dbus/worker phase)
|
|
57
|
+
|
|
58
|
+
athsra versions <project> 모든 version + tombstone 상태
|
|
59
|
+
athsra rollback <project> <version_id> 특정 version 으로 current 복원
|
|
60
|
+
athsra delete <project> [--hard] soft-delete (default) 또는 hard-delete
|
|
61
|
+
athsra restore <project> tombstone 제거 + 최신 version 활성화
|
|
62
|
+
athsra purge <project> hard-delete (delete --hard 별칭, double-confirm)
|
|
63
|
+
|
|
64
|
+
athsra rotate-master master pw 변경 (모든 projects re-encrypt)
|
|
65
|
+
athsra new-phrase BIP-39 12-word phrase 생성 (master pw 권장)
|
|
66
|
+
athsra handoff [--accept] 새 머신 추가 (issue / accept)
|
|
67
|
+
athsra revoke [<atk_*>] self 또는 명시 token revoke
|
|
68
|
+
|
|
69
|
+
Files / Storage:
|
|
70
|
+
~/.athsra/config.json worker URL + machine_id
|
|
71
|
+
OS keyring (libsecret/Keychain/Cred Manager) master pw + Bearer token
|
|
72
|
+
|
|
73
|
+
Env vars (CI):
|
|
74
|
+
ATHSRA_MASTER_PW non-interactive login
|
|
75
|
+
ATHSRA_REVOKE_CONFIRMED=1 skip self-revoke confirm
|
|
76
|
+
ATHSRA_DELETE_CONFIRMED=1 skip delete confirm
|
|
77
|
+
ATHSRA_PURGE_CONFIRMED=1 skip purge double-confirm
|
|
78
|
+
ATHSRA_ROLLBACK_CONFIRMED=1 skip rollback confirm
|
|
79
|
+
|
|
80
|
+
Phase 1.x.1 = Bearer auth + keyring + soft-delete + version history.
|
|
81
|
+
docs: github.com/modfolio/athsra
|
|
82
|
+
`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
|
|
87
|
+
console.log(VERSION);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handler = commands[cmd];
|
|
92
|
+
if (!handler) {
|
|
93
|
+
console.error(`Unknown command: ${cmd}\nRun \`athsra --help\` for usage.`);
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handler(args.slice(1))
|
|
98
|
+
.then((code) => process.exit(code))
|
|
99
|
+
.catch((err: Error) => {
|
|
100
|
+
console.error(`error: ${err.message}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AthsraClient } from './client.ts';
|
|
2
|
+
import { type Config, loadConfig } from './config.ts';
|
|
3
|
+
import { getMasterPw, getToken } from './keyring.ts';
|
|
4
|
+
|
|
5
|
+
export interface AuthContext {
|
|
6
|
+
config: Config;
|
|
7
|
+
masterPw: string;
|
|
8
|
+
token: string;
|
|
9
|
+
client: AthsraClient;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 모든 인증 요구 commands (set/get/ls/run/doctor 부분) 의 공통 진입점.
|
|
14
|
+
*
|
|
15
|
+
* 실패 시 적절한 에러 메시지 출력 + null 반환. 호출자는 `return 1` 만.
|
|
16
|
+
*/
|
|
17
|
+
export function loadAuthContext(): AuthContext | null {
|
|
18
|
+
const config = loadConfig();
|
|
19
|
+
if (!config) {
|
|
20
|
+
console.error('Not logged in. Run `athsra login` first.');
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const masterPw = getMasterPw(config.machineId);
|
|
24
|
+
const token = getToken(config.machineId);
|
|
25
|
+
if (!masterPw || !token) {
|
|
26
|
+
console.error('keyring missing master pw / token. Run `athsra login` first.');
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
config,
|
|
31
|
+
masterPw,
|
|
32
|
+
token,
|
|
33
|
+
client: new AthsraClient(config.workerUrl, token),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/lib/bip39.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { SecretEnvelope } from '@athsra/crypto';
|
|
2
|
+
|
|
3
|
+
export interface WorkerInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
phase: number;
|
|
7
|
+
description?: string;
|
|
8
|
+
global_salt: string;
|
|
9
|
+
global_salt_version: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RegisterResponse {
|
|
13
|
+
token: string;
|
|
14
|
+
machineId: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WhoamiResponse {
|
|
19
|
+
machineId: string;
|
|
20
|
+
label: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
lastSeenAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Phase 1+: 모든 /v1/* + /auth/whoami + /auth/revoke 호출에
|
|
27
|
+
* Authorization: Bearer <token> 자동 첨부.
|
|
28
|
+
* /healthz, /, /auth/register 는 unauth public.
|
|
29
|
+
*/
|
|
30
|
+
export class AthsraClient {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly _workerUrl: string,
|
|
33
|
+
private readonly token: string | null = null,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
get workerUrl(): string {
|
|
37
|
+
return this._workerUrl;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private url(path: string): string {
|
|
41
|
+
return `${this._workerUrl.replace(/\/$/, '')}${path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private headers(extra?: Record<string, string>): Record<string, string> {
|
|
45
|
+
const h: Record<string, string> = { ...(extra ?? {}) };
|
|
46
|
+
if (this.token) h.Authorization = `Bearer ${this.token}`;
|
|
47
|
+
return h;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async health(): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(this.url('/healthz'));
|
|
53
|
+
return res.ok;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async info(): Promise<WorkerInfo> {
|
|
60
|
+
const res = await fetch(this.url('/'));
|
|
61
|
+
if (!res.ok) throw new Error(`info ${res.status}: ${await res.text()}`);
|
|
62
|
+
return (await res.json()) as WorkerInfo;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async register(masterPwProof: string, label: string): Promise<RegisterResponse> {
|
|
66
|
+
const res = await fetch(this.url('/auth/register'), {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ master_pw_proof: masterPwProof, label }),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw new Error(`register ${res.status}: ${await res.text()}`);
|
|
72
|
+
return (await res.json()) as RegisterResponse;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async whoami(): Promise<WhoamiResponse> {
|
|
76
|
+
const res = await fetch(this.url('/auth/whoami'), { headers: this.headers() });
|
|
77
|
+
if (!res.ok) throw new Error(`whoami ${res.status}: ${await res.text()}`);
|
|
78
|
+
return (await res.json()) as WhoamiResponse;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async revoke(targetToken?: string): Promise<{ ok: boolean; revoked: string; self?: boolean }> {
|
|
82
|
+
const res = await fetch(this.url('/auth/revoke'), {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
85
|
+
body: JSON.stringify(targetToken ? { token: targetToken } : {}),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw new Error(`revoke ${res.status}: ${await res.text()}`);
|
|
88
|
+
return (await res.json()) as { ok: boolean; revoked: string; self?: boolean };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async rotateMaster(
|
|
92
|
+
oldProof: string,
|
|
93
|
+
newProof: string,
|
|
94
|
+
): Promise<{ token: string; machineId: string; rotatedAt: string }> {
|
|
95
|
+
const res = await fetch(this.url('/auth/rotate-master'), {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
98
|
+
body: JSON.stringify({ old_proof: oldProof, new_proof: newProof }),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) throw new Error(`rotate-master ${res.status}: ${await res.text()}`);
|
|
101
|
+
return (await res.json()) as { token: string; machineId: string; rotatedAt: string };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async handoff(
|
|
105
|
+
masterPwProof: string,
|
|
106
|
+
label: string,
|
|
107
|
+
): Promise<{
|
|
108
|
+
token: string;
|
|
109
|
+
machineId: string;
|
|
110
|
+
createdAt: string;
|
|
111
|
+
expiresAt?: string;
|
|
112
|
+
ttlSeconds?: number;
|
|
113
|
+
}> {
|
|
114
|
+
const res = await fetch(this.url('/auth/handoff'), {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
117
|
+
body: JSON.stringify({ master_pw_proof: masterPwProof, label }),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) throw new Error(`handoff ${res.status}: ${await res.text()}`);
|
|
120
|
+
return (await res.json()) as {
|
|
121
|
+
token: string;
|
|
122
|
+
machineId: string;
|
|
123
|
+
createdAt: string;
|
|
124
|
+
expiresAt?: string;
|
|
125
|
+
ttlSeconds?: number;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getEnvelope(project: string): Promise<SecretEnvelope | null> {
|
|
130
|
+
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
|
|
131
|
+
headers: this.headers(),
|
|
132
|
+
});
|
|
133
|
+
if (res.status === 404) return null;
|
|
134
|
+
if (!res.ok) throw new Error(`fetch ${res.status}: ${await res.text()}`);
|
|
135
|
+
return (await res.json()) as SecretEnvelope;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async putEnvelope(project: string, envelope: SecretEnvelope): Promise<void> {
|
|
139
|
+
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
|
|
140
|
+
method: 'PUT',
|
|
141
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
142
|
+
body: JSON.stringify(envelope),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) throw new Error(`put ${res.status}: ${await res.text()}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async listProjects(): Promise<string[]> {
|
|
148
|
+
const res = await fetch(this.url('/v1/secrets'), { headers: this.headers() });
|
|
149
|
+
if (!res.ok) throw new Error(`list ${res.status}: ${await res.text()}`);
|
|
150
|
+
const data = (await res.json()) as { projects: string[] };
|
|
151
|
+
return data.projects;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async deleteProject(
|
|
155
|
+
project: string,
|
|
156
|
+
opts?: { hard?: boolean },
|
|
157
|
+
): Promise<{
|
|
158
|
+
soft?: boolean;
|
|
159
|
+
hard?: boolean;
|
|
160
|
+
recoverable_versions?: number;
|
|
161
|
+
removed_versions?: number;
|
|
162
|
+
}> {
|
|
163
|
+
const url =
|
|
164
|
+
this.url(`/v1/secrets/${encodeURIComponent(project)}`) + (opts?.hard ? '?hard=true' : '');
|
|
165
|
+
const res = await fetch(url, {
|
|
166
|
+
method: 'DELETE',
|
|
167
|
+
headers: this.headers(),
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) throw new Error(`delete ${res.status}: ${await res.text()}`);
|
|
170
|
+
return (await res.json()) as {
|
|
171
|
+
soft?: boolean;
|
|
172
|
+
hard?: boolean;
|
|
173
|
+
recoverable_versions?: number;
|
|
174
|
+
removed_versions?: number;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async listVersions(project: string): Promise<{
|
|
179
|
+
project: string;
|
|
180
|
+
current_version: string | null;
|
|
181
|
+
tombstone: { deleted_at: string; deleted_by: string } | null;
|
|
182
|
+
versions: { version_id: string; updated_at: string; size: number }[];
|
|
183
|
+
count: number;
|
|
184
|
+
}> {
|
|
185
|
+
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/versions`), {
|
|
186
|
+
headers: this.headers(),
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) throw new Error(`versions ${res.status}: ${await res.text()}`);
|
|
189
|
+
return (await res.json()) as {
|
|
190
|
+
project: string;
|
|
191
|
+
current_version: string | null;
|
|
192
|
+
tombstone: { deleted_at: string; deleted_by: string } | null;
|
|
193
|
+
versions: { version_id: string; updated_at: string; size: number }[];
|
|
194
|
+
count: number;
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async rollbackProject(
|
|
199
|
+
project: string,
|
|
200
|
+
versionId: string,
|
|
201
|
+
): Promise<{ ok: boolean; project: string; current_version: string }> {
|
|
202
|
+
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/rollback`), {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
205
|
+
body: JSON.stringify({ version_id: versionId }),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) throw new Error(`rollback ${res.status}: ${await res.text()}`);
|
|
208
|
+
return (await res.json()) as { ok: boolean; project: string; current_version: string };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async restoreProject(project: string): Promise<{
|
|
212
|
+
ok: boolean;
|
|
213
|
+
project: string;
|
|
214
|
+
restored_version: string;
|
|
215
|
+
deleted_at: string;
|
|
216
|
+
deleted_by: string;
|
|
217
|
+
}> {
|
|
218
|
+
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/restore`), {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: this.headers(),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) throw new Error(`restore ${res.status}: ${await res.text()}`);
|
|
223
|
+
return (await res.json()) as {
|
|
224
|
+
ok: boolean;
|
|
225
|
+
project: string;
|
|
226
|
+
restored_version: string;
|
|
227
|
+
deleted_at: string;
|
|
228
|
+
deleted_by: string;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async listProjectsExtended(opts?: {
|
|
233
|
+
includeDeleted?: boolean;
|
|
234
|
+
}): Promise<
|
|
235
|
+
| { projects: string[]; count: number }
|
|
236
|
+
| { projects: { project: string; active: boolean; deleted: boolean }[]; count: number }
|
|
237
|
+
> {
|
|
238
|
+
const url = this.url('/v1/secrets') + (opts?.includeDeleted ? '?include_deleted=true' : '');
|
|
239
|
+
const res = await fetch(url, { headers: this.headers() });
|
|
240
|
+
if (!res.ok) throw new Error(`list ${res.status}: ${await res.text()}`);
|
|
241
|
+
return (await res.json()) as
|
|
242
|
+
| { projects: string[]; count: number }
|
|
243
|
+
| {
|
|
244
|
+
projects: { project: string; active: boolean; deleted: boolean }[];
|
|
245
|
+
count: number;
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = join(homedir(), '.athsra');
|
|
6
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
export const SESSION_FILE = join(CONFIG_DIR, 'session');
|
|
8
|
+
|
|
9
|
+
export interface Config {
|
|
10
|
+
workerUrl: string;
|
|
11
|
+
machineId: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ensureConfigDir(): void {
|
|
16
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadConfig(): Config | null {
|
|
22
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
23
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as Config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveConfig(config: Config): void {
|
|
27
|
+
ensureConfigDir();
|
|
28
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
29
|
+
}
|