@sigma-auth/cli 0.0.1
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 +21 -0
- package/README.md +39 -0
- package/package.json +47 -0
- package/src/args.ts +81 -0
- package/src/commands.ts +593 -0
- package/src/config.ts +78 -0
- package/src/cookies.ts +170 -0
- package/src/error.ts +72 -0
- package/src/fsutil.ts +48 -0
- package/src/http.ts +90 -0
- package/src/identity.ts +155 -0
- package/src/index.ts +62 -0
- package/src/output.ts +50 -0
- package/src/password.ts +54 -0
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { ensureDir } from "./fsutil.ts";
|
|
4
|
+
|
|
5
|
+
export type Cookie = {
|
|
6
|
+
domain: string;
|
|
7
|
+
includeSubdomains: boolean;
|
|
8
|
+
path: string;
|
|
9
|
+
secure: boolean;
|
|
10
|
+
expires: number;
|
|
11
|
+
name: string;
|
|
12
|
+
value: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseSetCookie(header: string, requestUrl: string): Cookie | null {
|
|
16
|
+
const parts = header.split(";").map((part) => part.trim());
|
|
17
|
+
const nv = parts[0];
|
|
18
|
+
if (!nv) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const eq = nv.indexOf("=");
|
|
22
|
+
if (eq <= 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const name = nv.slice(0, eq);
|
|
26
|
+
const value = nv.slice(eq + 1);
|
|
27
|
+
const url = new URL(requestUrl);
|
|
28
|
+
let domain = url.hostname;
|
|
29
|
+
let path = "/";
|
|
30
|
+
let secure = false;
|
|
31
|
+
let expires = 0;
|
|
32
|
+
for (const part of parts.slice(1)) {
|
|
33
|
+
const [k, v] = part.split("=").map((item) => item.trim());
|
|
34
|
+
const key = k?.toLowerCase();
|
|
35
|
+
if (key === "domain" && v) {
|
|
36
|
+
domain = v.replace(/^\./, "");
|
|
37
|
+
} else if (key === "path" && v) {
|
|
38
|
+
path = v;
|
|
39
|
+
} else if (key === "secure") {
|
|
40
|
+
secure = true;
|
|
41
|
+
} else if (key === "max-age" && v) {
|
|
42
|
+
expires = Math.floor(Date.now() / 1000) + Number.parseInt(v, 10);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
domain,
|
|
47
|
+
includeSubdomains: domain !== "localhost",
|
|
48
|
+
path,
|
|
49
|
+
secure,
|
|
50
|
+
expires,
|
|
51
|
+
name,
|
|
52
|
+
value,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadJar(path: string): Cookie[] {
|
|
57
|
+
if (!existsSync(path)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const cookies: Cookie[] = [];
|
|
61
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
62
|
+
if (!line || line.startsWith("#")) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const cols = line.split("\t");
|
|
66
|
+
if (cols.length < 7) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const [domain, flag, path, secure, expires, name, value] = cols;
|
|
70
|
+
if (!domain || !path || !name || value === undefined) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
cookies.push({
|
|
74
|
+
domain: domain.replace(/^\./, ""),
|
|
75
|
+
includeSubdomains: flag === "TRUE",
|
|
76
|
+
path,
|
|
77
|
+
secure: secure === "TRUE",
|
|
78
|
+
expires: Number.parseInt(expires ?? "0", 10) || 0,
|
|
79
|
+
name,
|
|
80
|
+
value,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return cookies;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function saveJar(path: string, cookies: Cookie[]): void {
|
|
87
|
+
ensureDir(dirname(path));
|
|
88
|
+
const lines = [
|
|
89
|
+
"# Netscape HTTP Cookie File",
|
|
90
|
+
"# https://curl.se/docs/http-cookies.html",
|
|
91
|
+
"",
|
|
92
|
+
];
|
|
93
|
+
for (const cookie of cookies) {
|
|
94
|
+
const domain = cookie.includeSubdomains ? `.${cookie.domain}` : cookie.domain;
|
|
95
|
+
lines.push(
|
|
96
|
+
[
|
|
97
|
+
domain,
|
|
98
|
+
cookie.includeSubdomains ? "TRUE" : "FALSE",
|
|
99
|
+
cookie.path,
|
|
100
|
+
cookie.secure ? "TRUE" : "FALSE",
|
|
101
|
+
String(cookie.expires),
|
|
102
|
+
cookie.name,
|
|
103
|
+
cookie.value,
|
|
104
|
+
].join("\t")
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
108
|
+
chmodSync(path, 0o600);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function mergeSetCookies(
|
|
112
|
+
jarPath: string,
|
|
113
|
+
requestUrl: string,
|
|
114
|
+
headers: Headers
|
|
115
|
+
): Cookie[] {
|
|
116
|
+
const cookies = loadJar(jarPath);
|
|
117
|
+
const setCookies =
|
|
118
|
+
typeof headers.getSetCookie === "function"
|
|
119
|
+
? headers.getSetCookie()
|
|
120
|
+
: headers.get("set-cookie")
|
|
121
|
+
? [headers.get("set-cookie") as string]
|
|
122
|
+
: [];
|
|
123
|
+
for (const header of setCookies) {
|
|
124
|
+
const parsed = parseSetCookie(header, requestUrl);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const idx = cookies.findIndex(
|
|
129
|
+
(item) => item.name === parsed.name && item.domain === parsed.domain
|
|
130
|
+
);
|
|
131
|
+
if (idx >= 0) {
|
|
132
|
+
cookies[idx] = parsed;
|
|
133
|
+
} else {
|
|
134
|
+
cookies.push(parsed);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
saveJar(jarPath, cookies);
|
|
138
|
+
return cookies;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function cookieHeaderFor(url: string, cookies: Cookie[]): string {
|
|
142
|
+
const target = new URL(url);
|
|
143
|
+
const now = Math.floor(Date.now() / 1000);
|
|
144
|
+
return cookies
|
|
145
|
+
.filter((cookie) => {
|
|
146
|
+
if (cookie.expires > 0 && cookie.expires < now) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (cookie.secure && target.protocol !== "https:") {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const host = target.hostname;
|
|
153
|
+
const domainOk =
|
|
154
|
+
host === cookie.domain ||
|
|
155
|
+
(cookie.includeSubdomains && host.endsWith(`.${cookie.domain}`));
|
|
156
|
+
const pathOk =
|
|
157
|
+
target.pathname === cookie.path ||
|
|
158
|
+
target.pathname.startsWith(
|
|
159
|
+
cookie.path.endsWith("/") ? cookie.path : `${cookie.path}/`
|
|
160
|
+
) ||
|
|
161
|
+
cookie.path === "/";
|
|
162
|
+
return domainOk && pathOk;
|
|
163
|
+
})
|
|
164
|
+
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
165
|
+
.join("; ");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sessionCookieNames(cookies: Cookie[]): string[] {
|
|
169
|
+
return cookies.map((cookie) => cookie.name);
|
|
170
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
readonly exitCode: number;
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly status?: number;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
exitCode: number,
|
|
8
|
+
code: string,
|
|
9
|
+
message: string,
|
|
10
|
+
status?: number
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CliError";
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usage(message: string): never {
|
|
21
|
+
throw new CliError(1, "usage", message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cryptoFail(message: string): never {
|
|
25
|
+
throw new CliError(7, "crypto", message);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ioFail(message: string): never {
|
|
29
|
+
throw new CliError(7, "io", message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function envFail(message: string): never {
|
|
33
|
+
throw new CliError(1, "env", message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function exitForHttp(status: number): number {
|
|
37
|
+
if (status === 401 || status === 403) {
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
if (status === 404) {
|
|
41
|
+
return 3;
|
|
42
|
+
}
|
|
43
|
+
if (status === 409) {
|
|
44
|
+
return 4;
|
|
45
|
+
}
|
|
46
|
+
if (status === 429) {
|
|
47
|
+
return 5;
|
|
48
|
+
}
|
|
49
|
+
if (status >= 500) {
|
|
50
|
+
return 6;
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function codeForHttp(status: number): string {
|
|
56
|
+
if (status === 401 || status === 403) {
|
|
57
|
+
return "auth";
|
|
58
|
+
}
|
|
59
|
+
if (status === 404) {
|
|
60
|
+
return "not_found";
|
|
61
|
+
}
|
|
62
|
+
if (status === 409) {
|
|
63
|
+
return "conflict";
|
|
64
|
+
}
|
|
65
|
+
if (status === 429) {
|
|
66
|
+
return "rate_limit";
|
|
67
|
+
}
|
|
68
|
+
if (status >= 500) {
|
|
69
|
+
return "server";
|
|
70
|
+
}
|
|
71
|
+
return "usage";
|
|
72
|
+
}
|
package/src/fsutil.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { ioFail, usage } from "./error.ts";
|
|
11
|
+
|
|
12
|
+
export function ensureDir(path: string, mode = 0o700): void {
|
|
13
|
+
mkdirSync(path, { recursive: true, mode });
|
|
14
|
+
chmodSync(path, mode);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeSecretFile(
|
|
18
|
+
path: string,
|
|
19
|
+
contents: string,
|
|
20
|
+
force: boolean
|
|
21
|
+
): void {
|
|
22
|
+
if (existsSync(path) && !force) {
|
|
23
|
+
usage(`refusing to overwrite ${path} (pass --force)`);
|
|
24
|
+
}
|
|
25
|
+
ensureDir(dirname(path));
|
|
26
|
+
writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
|
|
27
|
+
chmodSync(path, 0o600);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readText(path: string): string {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(path, "utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
ioFail(`cannot read ${path}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fileMode(path: string): number {
|
|
39
|
+
try {
|
|
40
|
+
return statSync(path).mode & 0o777;
|
|
41
|
+
} catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function pathExists(path: string): boolean {
|
|
47
|
+
return existsSync(path);
|
|
48
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliError,
|
|
3
|
+
codeForHttp,
|
|
4
|
+
exitForHttp,
|
|
5
|
+
} from "./error.ts";
|
|
6
|
+
import {
|
|
7
|
+
cookieHeaderFor,
|
|
8
|
+
loadJar,
|
|
9
|
+
mergeSetCookies,
|
|
10
|
+
} from "./cookies.ts";
|
|
11
|
+
|
|
12
|
+
export type HttpClient = {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
cookieJar: string;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
fetchImpl: typeof fetch;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createHttp(opts: {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
cookieJar: string;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}): HttpClient {
|
|
25
|
+
return {
|
|
26
|
+
baseUrl: opts.baseUrl,
|
|
27
|
+
cookieJar: opts.cookieJar,
|
|
28
|
+
timeoutMs: opts.timeoutMs,
|
|
29
|
+
fetchImpl: opts.fetchImpl ?? fetch,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function requestJson(
|
|
34
|
+
client: HttpClient,
|
|
35
|
+
method: string,
|
|
36
|
+
path: string,
|
|
37
|
+
opts?: {
|
|
38
|
+
body?: unknown;
|
|
39
|
+
headers?: Record<string, string>;
|
|
40
|
+
withCookies?: boolean;
|
|
41
|
+
saveCookies?: boolean;
|
|
42
|
+
}
|
|
43
|
+
): Promise<{ status: number; headers: Headers; json: unknown; text: string }> {
|
|
44
|
+
const url = `${client.baseUrl}${path}`;
|
|
45
|
+
const headers = new Headers(opts?.headers);
|
|
46
|
+
if (opts?.body !== undefined && !headers.has("content-type")) {
|
|
47
|
+
headers.set("content-type", "application/json");
|
|
48
|
+
}
|
|
49
|
+
if (opts?.withCookies) {
|
|
50
|
+
const cookie = cookieHeaderFor(url, loadJar(client.cookieJar));
|
|
51
|
+
if (cookie) {
|
|
52
|
+
headers.set("cookie", cookie);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const response = await client.fetchImpl(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers,
|
|
58
|
+
body:
|
|
59
|
+
opts?.body === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: typeof opts.body === "string"
|
|
62
|
+
? opts.body
|
|
63
|
+
: JSON.stringify(opts.body),
|
|
64
|
+
signal: AbortSignal.timeout(client.timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
if (opts?.saveCookies) {
|
|
67
|
+
mergeSetCookies(client.cookieJar, url, response.headers);
|
|
68
|
+
}
|
|
69
|
+
const text = await response.text();
|
|
70
|
+
let json: unknown = null;
|
|
71
|
+
if (text) {
|
|
72
|
+
try {
|
|
73
|
+
json = JSON.parse(text);
|
|
74
|
+
} catch {
|
|
75
|
+
json = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { status: response.status, headers: response.headers, json, text };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function throwHttp(path: string, status: number, json: unknown, text: string): never {
|
|
82
|
+
const record = json && typeof json === "object" ? (json as Record<string, unknown>) : {};
|
|
83
|
+
const message =
|
|
84
|
+
(typeof record.error_description === "string" && record.error_description) ||
|
|
85
|
+
(typeof record.message === "string" && record.message) ||
|
|
86
|
+
(typeof record.error === "string" && record.error) ||
|
|
87
|
+
text ||
|
|
88
|
+
`${path} failed with ${status}`;
|
|
89
|
+
throw new CliError(exitForHttp(status), codeForHttp(status), message, status);
|
|
90
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { HD, Mnemonic, PrivateKey } from "@bsv/sdk";
|
|
2
|
+
import {
|
|
3
|
+
type BapMasterBackup,
|
|
4
|
+
decryptBackup,
|
|
5
|
+
encryptBackup,
|
|
6
|
+
isLegacyBackup,
|
|
7
|
+
isType42Backup,
|
|
8
|
+
} from "bitcoin-backup";
|
|
9
|
+
import { BAP } from "bsv-bap";
|
|
10
|
+
import { cryptoFail } from "./error.ts";
|
|
11
|
+
|
|
12
|
+
export type PublicIdentity = {
|
|
13
|
+
bapId: string;
|
|
14
|
+
pubkey: string;
|
|
15
|
+
address: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
ids: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function createMasterBackup(label: string): {
|
|
22
|
+
mnemonic: string;
|
|
23
|
+
backup: BapMasterBackup;
|
|
24
|
+
bapId: string;
|
|
25
|
+
pubkey: string;
|
|
26
|
+
address: string;
|
|
27
|
+
} {
|
|
28
|
+
const mnemonic = Mnemonic.fromRandom();
|
|
29
|
+
const hdKey = HD.fromSeed(mnemonic.toSeed());
|
|
30
|
+
const rootKey = hdKey.derive("m/0'/0");
|
|
31
|
+
const rootPk = rootKey.privKey?.toWif();
|
|
32
|
+
if (!rootPk) {
|
|
33
|
+
cryptoFail("Failed to derive wallet root private key");
|
|
34
|
+
}
|
|
35
|
+
const bap = new BAP({ rootPk });
|
|
36
|
+
const first = bap.newId();
|
|
37
|
+
const backup: BapMasterBackup = {
|
|
38
|
+
rootPk,
|
|
39
|
+
ids: bap.exportIds(),
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
label,
|
|
42
|
+
};
|
|
43
|
+
const member = first.getAccountKey();
|
|
44
|
+
return {
|
|
45
|
+
mnemonic: mnemonic.toString(),
|
|
46
|
+
backup,
|
|
47
|
+
bapId: first.bapId,
|
|
48
|
+
pubkey: member.toPublicKey().toString(),
|
|
49
|
+
address: member.toAddress().toString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bapFromBackup(backup: BapMasterBackup): InstanceType<typeof BAP> {
|
|
54
|
+
let bap: InstanceType<typeof BAP>;
|
|
55
|
+
if (isLegacyBackup(backup)) {
|
|
56
|
+
bap = new BAP(backup.xprv);
|
|
57
|
+
} else if (isType42Backup(backup)) {
|
|
58
|
+
bap = new BAP({ rootPk: backup.rootPk });
|
|
59
|
+
} else {
|
|
60
|
+
cryptoFail("backup is not a Type42 or legacy master backup");
|
|
61
|
+
}
|
|
62
|
+
if (backup.ids && backup.ids.length > 0) {
|
|
63
|
+
bap.importIds(backup.ids);
|
|
64
|
+
}
|
|
65
|
+
return bap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function publicFields(
|
|
69
|
+
backup: BapMasterBackup,
|
|
70
|
+
bapId?: string
|
|
71
|
+
): PublicIdentity {
|
|
72
|
+
const bap = bapFromBackup(backup);
|
|
73
|
+
const ids = bap.listIds();
|
|
74
|
+
if (ids.length === 0) {
|
|
75
|
+
cryptoFail("backup has no identities; run identity create");
|
|
76
|
+
}
|
|
77
|
+
const selected = bapId ?? ids[0];
|
|
78
|
+
if (!selected || !ids.includes(selected)) {
|
|
79
|
+
cryptoFail(`BAP id not found in backup${bapId ? `: ${bapId}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
const member = bap.getId(selected);
|
|
82
|
+
if (!member) {
|
|
83
|
+
cryptoFail(`BAP id not found: ${selected}`);
|
|
84
|
+
}
|
|
85
|
+
const key = member.getAccountKey();
|
|
86
|
+
return {
|
|
87
|
+
bapId: selected,
|
|
88
|
+
pubkey: key.toPublicKey().toString(),
|
|
89
|
+
address: key.toAddress().toString(),
|
|
90
|
+
label: "label" in backup ? backup.label : undefined,
|
|
91
|
+
createdAt: "createdAt" in backup ? backup.createdAt : undefined,
|
|
92
|
+
ids,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function memberWif(backup: BapMasterBackup, bapId?: string): {
|
|
97
|
+
wif: string;
|
|
98
|
+
bapId: string;
|
|
99
|
+
pubkey: string;
|
|
100
|
+
address: string;
|
|
101
|
+
} {
|
|
102
|
+
const fields = publicFields(backup, bapId);
|
|
103
|
+
const bap = bapFromBackup(backup);
|
|
104
|
+
const member = bap.getId(fields.bapId);
|
|
105
|
+
if (!member) {
|
|
106
|
+
cryptoFail(`BAP id not found: ${fields.bapId}`);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
wif: member.getAccountKey().toWif(),
|
|
110
|
+
bapId: fields.bapId,
|
|
111
|
+
pubkey: fields.pubkey,
|
|
112
|
+
address: fields.address,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rootPubkey(backup: BapMasterBackup): string | null {
|
|
117
|
+
if (isType42Backup(backup)) {
|
|
118
|
+
return PrivateKey.fromWif(backup.rootPk).toPublicKey().toString();
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function encryptMaster(
|
|
124
|
+
backup: BapMasterBackup,
|
|
125
|
+
password: string
|
|
126
|
+
): Promise<string> {
|
|
127
|
+
return encryptBackup(backup, password);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function decryptMaster(ciphertext: string, password: string): Promise<BapMasterBackup> {
|
|
131
|
+
try {
|
|
132
|
+
const decrypted = await decryptBackup(ciphertext, password);
|
|
133
|
+
if (isType42Backup(decrypted) || isLegacyBackup(decrypted)) {
|
|
134
|
+
return decrypted;
|
|
135
|
+
}
|
|
136
|
+
cryptoFail("decrypted backup is not a master backup");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : "decrypt failed";
|
|
139
|
+
cryptoFail(message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function looksLikePlaintextBackup(text: string): boolean {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
146
|
+
return (
|
|
147
|
+
typeof parsed.rootPk === "string" ||
|
|
148
|
+
typeof parsed.xprv === "string" ||
|
|
149
|
+
typeof parsed.wif === "string" ||
|
|
150
|
+
typeof parsed.mnemonic === "string"
|
|
151
|
+
);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { boolFlag, parseArgs } from "./args.ts";
|
|
4
|
+
import {
|
|
5
|
+
authSignIn,
|
|
6
|
+
backupEncrypt,
|
|
7
|
+
backupPush,
|
|
8
|
+
doctor,
|
|
9
|
+
HELP,
|
|
10
|
+
identityCreate,
|
|
11
|
+
identityInfo,
|
|
12
|
+
oauthRegister,
|
|
13
|
+
} from "./commands.ts";
|
|
14
|
+
import { loadConfig } from "./config.ts";
|
|
15
|
+
import { usage } from "./error.ts";
|
|
16
|
+
import { reportError } from "./output.ts";
|
|
17
|
+
|
|
18
|
+
export async function run(argv: string[]): Promise<number> {
|
|
19
|
+
const json = argv.includes("--json");
|
|
20
|
+
const quiet = argv.includes("--quiet");
|
|
21
|
+
try {
|
|
22
|
+
const args = parseArgs(argv);
|
|
23
|
+
const cfg = loadConfig(args);
|
|
24
|
+
if (boolFlag(args, "help") || args.positional[0] === "help") {
|
|
25
|
+
process.stdout.write(HELP);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const [group, command] = args.positional;
|
|
29
|
+
if (group === "identity" && command === "create") {
|
|
30
|
+
return await identityCreate(args, cfg);
|
|
31
|
+
}
|
|
32
|
+
if (group === "identity" && command === "info") {
|
|
33
|
+
return await identityInfo(args, cfg);
|
|
34
|
+
}
|
|
35
|
+
if (group === "backup" && command === "encrypt") {
|
|
36
|
+
return await backupEncrypt(args, cfg);
|
|
37
|
+
}
|
|
38
|
+
if (group === "backup" && command === "push") {
|
|
39
|
+
return await backupPush(args, cfg);
|
|
40
|
+
}
|
|
41
|
+
if (group === "auth" && command === "sign-in") {
|
|
42
|
+
return await authSignIn(args, cfg);
|
|
43
|
+
}
|
|
44
|
+
if (group === "oauth" && command === "register") {
|
|
45
|
+
return await oauthRegister(args, cfg);
|
|
46
|
+
}
|
|
47
|
+
if (group === "doctor") {
|
|
48
|
+
return await doctor(args, cfg);
|
|
49
|
+
}
|
|
50
|
+
if (!group) {
|
|
51
|
+
process.stdout.write(HELP);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
usage(`unknown command: ${args.positional.join(" ")}`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return reportError({ json, quiet }, error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (import.meta.main) {
|
|
61
|
+
process.exit(await run(process.argv.slice(2)));
|
|
62
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CliError } from "./error.ts";
|
|
2
|
+
|
|
3
|
+
export type OutputMode = {
|
|
4
|
+
json: boolean;
|
|
5
|
+
quiet: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function printJson(ok: boolean, data: unknown, error?: unknown): void {
|
|
9
|
+
if (ok) {
|
|
10
|
+
process.stdout.write(`${JSON.stringify({ ok: true, data })}\n`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error })}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function printHuman(mode: OutputMode, text: string): void {
|
|
17
|
+
if (mode.quiet || mode.json) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
process.stdout.write(`${text}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function printWarn(mode: OutputMode, text: string): void {
|
|
24
|
+
if (mode.json) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
process.stderr.write(`${text}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reportError(mode: OutputMode, err: unknown): number {
|
|
31
|
+
if (err instanceof CliError) {
|
|
32
|
+
if (mode.json) {
|
|
33
|
+
printJson(false, undefined, {
|
|
34
|
+
code: err.code,
|
|
35
|
+
message: err.message,
|
|
36
|
+
status: err.status,
|
|
37
|
+
});
|
|
38
|
+
} else if (!mode.quiet) {
|
|
39
|
+
process.stderr.write(`${err.message}\n`);
|
|
40
|
+
}
|
|
41
|
+
return err.exitCode;
|
|
42
|
+
}
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
if (mode.json) {
|
|
45
|
+
printJson(false, undefined, { code: "io", message });
|
|
46
|
+
} else {
|
|
47
|
+
process.stderr.write(`${message}\n`);
|
|
48
|
+
}
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
package/src/password.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { ParsedArgs } from "./args.ts";
|
|
3
|
+
import { boolFlag, flag } from "./args.ts";
|
|
4
|
+
import { MIN_PASSWORD_LENGTH } from "./config.ts";
|
|
5
|
+
import { cryptoFail, usage } from "./error.ts";
|
|
6
|
+
|
|
7
|
+
function envPassword(): string | undefined {
|
|
8
|
+
if (!("SIGMA_BACKUP_PASSWORD" in process.env)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const value = process.env.SIGMA_BACKUP_PASSWORD;
|
|
12
|
+
if (value === undefined || value === "") {
|
|
13
|
+
usage("SIGMA_BACKUP_PASSWORD is set but empty");
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolvePassword(
|
|
19
|
+
args: ParsedArgs,
|
|
20
|
+
required: boolean
|
|
21
|
+
): Promise<string | undefined> {
|
|
22
|
+
const fromFile = flag(args, "password-file");
|
|
23
|
+
const fromStdin = boolFlag(args, "password-stdin");
|
|
24
|
+
const fromEnv = envPassword();
|
|
25
|
+
const sources = [fromFile, fromStdin ? "stdin" : undefined, fromEnv].filter(
|
|
26
|
+
(item) => item !== undefined
|
|
27
|
+
);
|
|
28
|
+
if (sources.length > 1) {
|
|
29
|
+
usage("use exactly one of --password-file, --password-stdin, or SIGMA_BACKUP_PASSWORD");
|
|
30
|
+
}
|
|
31
|
+
if (sources.length === 0) {
|
|
32
|
+
if (required) {
|
|
33
|
+
usage("password required: --password-file, --password-stdin, or SIGMA_BACKUP_PASSWORD");
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let password: string;
|
|
39
|
+
if (fromFile) {
|
|
40
|
+
password = readFileSync(fromFile, "utf8").trim();
|
|
41
|
+
} else if (fromStdin) {
|
|
42
|
+
password = (await Bun.stdin.text()).trim();
|
|
43
|
+
} else {
|
|
44
|
+
password = fromEnv as string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (password === "") {
|
|
48
|
+
usage("password is empty");
|
|
49
|
+
}
|
|
50
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
51
|
+
cryptoFail(`password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
52
|
+
}
|
|
53
|
+
return password;
|
|
54
|
+
}
|