@absol-labs/agent 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/dist/gateway/caller-auth-gateway.d.ts +103 -2
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
- package/dist/gateway/caller-auth-gateway.js +176 -19
- package/dist/gateway/caller-auth-gateway.js.map +1 -1
- package/dist/gateway/http-server.d.ts +12 -0
- package/dist/gateway/http-server.d.ts.map +1 -1
- package/dist/gateway/http-server.js +45 -1
- package/dist/gateway/http-server.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/wallet/autonomous-wallet-store.d.ts +123 -0
- package/dist/wallet/autonomous-wallet-store.d.ts.map +1 -0
- package/dist/wallet/autonomous-wallet-store.js +318 -0
- package/dist/wallet/autonomous-wallet-store.js.map +1 -0
- package/dist/wallet/autonomous-wallet.d.ts +14 -39
- package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
- package/dist/wallet/autonomous-wallet.js +12 -145
- package/dist/wallet/autonomous-wallet.js.map +1 -1
- package/dist/wallet/encrypted-file-credential-store.d.ts +41 -0
- package/dist/wallet/encrypted-file-credential-store.d.ts.map +1 -0
- package/dist/wallet/encrypted-file-credential-store.js +221 -0
- package/dist/wallet/encrypted-file-credential-store.js.map +1 -0
- package/dist/wallet/provider.d.ts.map +1 -1
- package/dist/wallet/provider.js +22 -2
- package/dist/wallet/provider.js.map +1 -1
- package/dist/wallet/secret-service-probe.d.ts +56 -0
- package/dist/wallet/secret-service-probe.d.ts.map +1 -0
- package/dist/wallet/secret-service-probe.js +407 -0
- package/dist/wallet/secret-service-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/gateway/caller-auth-gateway.ts +281 -15
- package/src/gateway/http-server.ts +64 -0
- package/src/index.ts +19 -0
- package/src/wallet/autonomous-wallet-store.ts +487 -0
- package/src/wallet/autonomous-wallet.ts +57 -224
- package/src/wallet/encrypted-file-credential-store.ts +341 -0
- package/src/wallet/provider.ts +23 -1
- package/src/wallet/secret-service-probe.ts +487 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
generateAuthorizationSignature,
|
|
5
3
|
generateP256KeyPair,
|
|
@@ -19,6 +17,16 @@ import {
|
|
|
19
17
|
type AutonomousWalletProof,
|
|
20
18
|
type PreparedPrivyRequest,
|
|
21
19
|
} from "./autonomous-wallet-protocol.js";
|
|
20
|
+
import {
|
|
21
|
+
AutonomousWalletError,
|
|
22
|
+
assertDurableCredentialStore,
|
|
23
|
+
createDefaultAutonomousWalletStore,
|
|
24
|
+
decodeBase64,
|
|
25
|
+
isRecord,
|
|
26
|
+
validateStoredRecord,
|
|
27
|
+
type AutonomousWalletCredentialStore,
|
|
28
|
+
type AutonomousWalletRecord,
|
|
29
|
+
} from "./autonomous-wallet-store.js";
|
|
22
30
|
|
|
23
31
|
export {
|
|
24
32
|
AUTONOMOUS_WALLET_CAIP2,
|
|
@@ -29,44 +37,39 @@ export {
|
|
|
29
37
|
type AutonomousWalletProof,
|
|
30
38
|
type PreparedPrivyRequest,
|
|
31
39
|
} from "./autonomous-wallet-protocol.js";
|
|
32
|
-
const KEYCHAIN_SERVICE = "com.absol-labs.metrik.autonomous-wallet";
|
|
33
|
-
export const METRIK_AUTONOMOUS_WALLET_BROKER_URL =
|
|
34
|
-
"https://wallet.137.23.50.249.sslip.io";
|
|
35
40
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
export {
|
|
42
|
+
AUTONOMOUS_WALLET_KEYCHAIN_SERVICE,
|
|
43
|
+
AutonomousWalletError,
|
|
44
|
+
InMemoryAutonomousWalletStore,
|
|
45
|
+
OsCredentialStore,
|
|
46
|
+
assertDurableCredentialStore,
|
|
47
|
+
createDefaultAutonomousWalletStore,
|
|
48
|
+
type AssertDurableCredentialStoreOptions,
|
|
49
|
+
type AutonomousWalletCredentialStore,
|
|
50
|
+
type AutonomousWalletRecord,
|
|
51
|
+
type CredentialStoreDurability,
|
|
52
|
+
type CredentialStoreDurabilityReport,
|
|
53
|
+
type OsCredentialStoreOptions,
|
|
54
|
+
type SecretEntryFactory,
|
|
55
|
+
type SecretEntryLike,
|
|
56
|
+
} from "./autonomous-wallet-store.js";
|
|
49
57
|
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
export {
|
|
59
|
+
EncryptedFileCredentialStore,
|
|
60
|
+
METRIK_WALLET_ENCRYPTION_KEY_ENV,
|
|
61
|
+
METRIK_WALLET_STORE_DIR_ENV,
|
|
62
|
+
type EncryptedFileCredentialStoreOptions,
|
|
63
|
+
} from "./encrypted-file-credential-store.js";
|
|
54
64
|
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
message: string,
|
|
64
|
-
options?: { readonly cause?: unknown },
|
|
65
|
-
) {
|
|
66
|
-
super(message, options);
|
|
67
|
-
this.name = "AutonomousWalletError";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
65
|
+
export {
|
|
66
|
+
probeSecretServiceAvailable,
|
|
67
|
+
type ProbeSecretServiceOptions,
|
|
68
|
+
type SecretServiceProbeResult,
|
|
69
|
+
} from "./secret-service-probe.js";
|
|
70
|
+
|
|
71
|
+
export const METRIK_AUTONOMOUS_WALLET_BROKER_URL =
|
|
72
|
+
"https://wallet.137.23.50.249.sslip.io";
|
|
70
73
|
|
|
71
74
|
/**
|
|
72
75
|
* Wire adapter for the broker HTTP contract. The only credential sent by this
|
|
@@ -166,6 +169,13 @@ export interface ProvisionAutonomousWalletOptions {
|
|
|
166
169
|
readonly store?: AutonomousWalletCredentialStore;
|
|
167
170
|
readonly nowSeconds?: number;
|
|
168
171
|
readonly nonce?: string;
|
|
172
|
+
/**
|
|
173
|
+
* Opt in to a credential store that does NOT survive a host reboot (process
|
|
174
|
+
* memory, the Linux kernel keyring). Off by default: provisioning refuses a
|
|
175
|
+
* volatile store because the owner key — and therefore any funds the wallet
|
|
176
|
+
* holds — is lost on reboot. Only set this for genuinely ephemeral runs.
|
|
177
|
+
*/
|
|
178
|
+
readonly allowVolatileStore?: boolean;
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
export interface ProvisionMetrikAutonomousWalletOptions {
|
|
@@ -173,6 +183,8 @@ export interface ProvisionMetrikAutonomousWalletOptions {
|
|
|
173
183
|
readonly brokerUrl?: string;
|
|
174
184
|
readonly store?: AutonomousWalletCredentialStore;
|
|
175
185
|
readonly fetchImpl?: typeof fetch;
|
|
186
|
+
/** See {@link ProvisionAutonomousWalletOptions.allowVolatileStore}. */
|
|
187
|
+
readonly allowVolatileStore?: boolean;
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
/**
|
|
@@ -223,6 +235,9 @@ export async function provisionMetrikAutonomousWallet(
|
|
|
223
235
|
storeKey: options.storeKey,
|
|
224
236
|
broker,
|
|
225
237
|
...(options.store === undefined ? {} : { store: options.store }),
|
|
238
|
+
...(options.allowVolatileStore === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { allowVolatileStore: options.allowVolatileStore }),
|
|
226
241
|
});
|
|
227
242
|
}
|
|
228
243
|
|
|
@@ -237,6 +252,11 @@ export async function provisionAutonomousWallet(
|
|
|
237
252
|
): Promise<AutonomousWalletRecord> {
|
|
238
253
|
validateProvisionOptions(options);
|
|
239
254
|
const store = options.store ?? createDefaultAutonomousWalletStore();
|
|
255
|
+
// Fail closed BEFORE a key is generated or a stored key is handed back: a
|
|
256
|
+
// volatile backend silently loses the owner key (and any funds) on reboot.
|
|
257
|
+
await assertDurableCredentialStore(store, {
|
|
258
|
+
allowVolatileStore: options.allowVolatileStore === true,
|
|
259
|
+
});
|
|
240
260
|
const existingRaw = await loadRecord(store, options.storeKey);
|
|
241
261
|
if (existingRaw !== null) {
|
|
242
262
|
const existing = validateStoredRecord(existingRaw);
|
|
@@ -461,72 +481,6 @@ export async function createAutonomousWalletAccount(
|
|
|
461
481
|
return createPrivyEmbeddedAccount(config);
|
|
462
482
|
}
|
|
463
483
|
|
|
464
|
-
export class InMemoryAutonomousWalletStore implements AutonomousWalletCredentialStore {
|
|
465
|
-
private readonly records = new Map<string, AutonomousWalletRecord>();
|
|
466
|
-
|
|
467
|
-
async load(key: string): Promise<AutonomousWalletRecord | null> {
|
|
468
|
-
return this.records.get(key) ?? null;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
async save(key: string, record: AutonomousWalletRecord): Promise<void> {
|
|
472
|
-
this.records.set(key, record);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Uses the host OS credential manager. There is intentionally no plaintext
|
|
478
|
-
* file fallback: unsupported hosts receive an actionable error instead.
|
|
479
|
-
*/
|
|
480
|
-
export class OsCredentialStore implements AutonomousWalletCredentialStore {
|
|
481
|
-
async load(key: string): Promise<AutonomousWalletRecord | null> {
|
|
482
|
-
const raw = await this.readSecret(key);
|
|
483
|
-
if (raw === null) return null;
|
|
484
|
-
try {
|
|
485
|
-
return validateStoredRecord(JSON.parse(raw));
|
|
486
|
-
} catch (error) {
|
|
487
|
-
throw new AutonomousWalletError(
|
|
488
|
-
"store-failure",
|
|
489
|
-
"OS credential contains invalid wallet data",
|
|
490
|
-
{ cause: error },
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async save(key: string, record: AutonomousWalletRecord): Promise<void> {
|
|
496
|
-
const raw = JSON.stringify(record);
|
|
497
|
-
try {
|
|
498
|
-
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
499
|
-
await new AsyncEntry(KEYCHAIN_SERVICE, key).setPassword(raw);
|
|
500
|
-
return;
|
|
501
|
-
} catch (error) {
|
|
502
|
-
throw new AutonomousWalletError(
|
|
503
|
-
"store-failure",
|
|
504
|
-
"OS credential store rejected the autonomous wallet record; no plaintext fallback is allowed",
|
|
505
|
-
{ cause: error },
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
private async readSecret(key: string): Promise<string | null> {
|
|
511
|
-
try {
|
|
512
|
-
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
513
|
-
return (
|
|
514
|
-
(await new AsyncEntry(KEYCHAIN_SERVICE, key).getPassword()) ?? null
|
|
515
|
-
);
|
|
516
|
-
} catch (error) {
|
|
517
|
-
throw new AutonomousWalletError(
|
|
518
|
-
"store-failure",
|
|
519
|
-
"OS credential store could not read the autonomous wallet record",
|
|
520
|
-
{ cause: error },
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
export function createDefaultAutonomousWalletStore(): AutonomousWalletCredentialStore {
|
|
527
|
-
return new OsCredentialStore();
|
|
528
|
-
}
|
|
529
|
-
|
|
530
484
|
async function loadRecord(
|
|
531
485
|
store: AutonomousWalletCredentialStore,
|
|
532
486
|
key: string,
|
|
@@ -612,80 +566,6 @@ function validateExistingRecord(
|
|
|
612
566
|
}
|
|
613
567
|
}
|
|
614
568
|
|
|
615
|
-
function validateStoredRecord(value: unknown): AutonomousWalletRecord {
|
|
616
|
-
if (
|
|
617
|
-
!isRecord(value) ||
|
|
618
|
-
value.version !== AUTONOMOUS_WALLET_VERSION ||
|
|
619
|
-
typeof value.appId !== "string" ||
|
|
620
|
-
value.appId.trim() === "" ||
|
|
621
|
-
typeof value.brokerUrl !== "string" ||
|
|
622
|
-
typeof value.publicKey !== "string" ||
|
|
623
|
-
value.publicKey.trim() === "" ||
|
|
624
|
-
typeof value.privateKey !== "string" ||
|
|
625
|
-
value.privateKey.trim() === "" ||
|
|
626
|
-
typeof value.provisioningNonce !== "string" ||
|
|
627
|
-
value.provisioningNonce.trim() === "" ||
|
|
628
|
-
typeof value.createdAt !== "number" ||
|
|
629
|
-
!Number.isSafeInteger(value.createdAt)
|
|
630
|
-
) {
|
|
631
|
-
throw new AutonomousWalletError(
|
|
632
|
-
"store-failure",
|
|
633
|
-
"OS credential contains an invalid autonomous wallet record",
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
if (
|
|
637
|
-
value.walletId !== undefined &&
|
|
638
|
-
(typeof value.walletId !== "string" || value.walletId.trim() === "")
|
|
639
|
-
) {
|
|
640
|
-
throw new AutonomousWalletError(
|
|
641
|
-
"store-failure",
|
|
642
|
-
"OS credential contains an invalid wallet id",
|
|
643
|
-
);
|
|
644
|
-
}
|
|
645
|
-
if (
|
|
646
|
-
value.policyId !== undefined &&
|
|
647
|
-
(typeof value.policyId !== "string" || value.policyId.trim() === "")
|
|
648
|
-
) {
|
|
649
|
-
throw new AutonomousWalletError(
|
|
650
|
-
"store-failure",
|
|
651
|
-
"OS credential contains an invalid policy id",
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
if (
|
|
655
|
-
value.address !== undefined &&
|
|
656
|
-
(typeof value.address !== "string" ||
|
|
657
|
-
!/^0x[0-9a-fA-F]{40}$/.test(value.address))
|
|
658
|
-
) {
|
|
659
|
-
throw new AutonomousWalletError(
|
|
660
|
-
"store-failure",
|
|
661
|
-
"OS credential contains an invalid wallet address",
|
|
662
|
-
);
|
|
663
|
-
}
|
|
664
|
-
try {
|
|
665
|
-
validateP256KeyPair(value.publicKey, value.privateKey);
|
|
666
|
-
} catch (error) {
|
|
667
|
-
throw new AutonomousWalletError(
|
|
668
|
-
"store-failure",
|
|
669
|
-
"OS credential contains invalid P-256 key material",
|
|
670
|
-
{ cause: error },
|
|
671
|
-
);
|
|
672
|
-
}
|
|
673
|
-
return {
|
|
674
|
-
version: AUTONOMOUS_WALLET_VERSION,
|
|
675
|
-
appId: value.appId,
|
|
676
|
-
brokerUrl: value.brokerUrl,
|
|
677
|
-
publicKey: value.publicKey,
|
|
678
|
-
privateKey: value.privateKey,
|
|
679
|
-
provisioningNonce: value.provisioningNonce,
|
|
680
|
-
...(value.walletId === undefined ? {} : { walletId: value.walletId }),
|
|
681
|
-
...(value.address === undefined
|
|
682
|
-
? {}
|
|
683
|
-
: { address: getAddress(value.address) }),
|
|
684
|
-
...(value.policyId === undefined ? {} : { policyId: value.policyId }),
|
|
685
|
-
createdAt: value.createdAt,
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
569
|
function normalizeBrokerUrl(value: string): string {
|
|
690
570
|
const url = new URL(value);
|
|
691
571
|
url.search = "";
|
|
@@ -699,49 +579,6 @@ function cryptoRandomNonce(): string {
|
|
|
699
579
|
return Buffer.from(bytes).toString("base64url");
|
|
700
580
|
}
|
|
701
581
|
|
|
702
|
-
function decodeBase64(value: string): Uint8Array {
|
|
703
|
-
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 === 1) {
|
|
704
|
-
throw new AutonomousWalletError(
|
|
705
|
-
"invalid-response",
|
|
706
|
-
"broker returned invalid proof payload encoding",
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
const decoded = Buffer.from(value, "base64");
|
|
710
|
-
if (decoded.length === 0 || decoded.toString("base64") !== value) {
|
|
711
|
-
throw new AutonomousWalletError(
|
|
712
|
-
"invalid-response",
|
|
713
|
-
"broker returned an empty proof payload",
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
return new Uint8Array(decoded);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function validateP256KeyPair(publicKey: string, privateKey: string): void {
|
|
720
|
-
const publicObject = createPublicKey({
|
|
721
|
-
key: Buffer.from(decodeBase64(publicKey)),
|
|
722
|
-
format: "der",
|
|
723
|
-
type: "spki",
|
|
724
|
-
});
|
|
725
|
-
const privateObject = createPrivateKey({
|
|
726
|
-
key: Buffer.from(decodeBase64(privateKey)),
|
|
727
|
-
format: "der",
|
|
728
|
-
type: "pkcs8",
|
|
729
|
-
});
|
|
730
|
-
if (
|
|
731
|
-
publicObject.asymmetricKeyType !== "ec" ||
|
|
732
|
-
publicObject.asymmetricKeyDetails?.namedCurve !== "prime256v1" ||
|
|
733
|
-
privateObject.asymmetricKeyType !== "ec" ||
|
|
734
|
-
privateObject.asymmetricKeyDetails?.namedCurve !== "prime256v1"
|
|
735
|
-
) {
|
|
736
|
-
throw new Error("key material is not P-256");
|
|
737
|
-
}
|
|
738
|
-
const derived = createPublicKey(privateObject)
|
|
739
|
-
.export({ format: "der", type: "spki" })
|
|
740
|
-
.toString("base64");
|
|
741
|
-
if (derived !== publicKey)
|
|
742
|
-
throw new Error("public key does not match private key");
|
|
743
|
-
}
|
|
744
|
-
|
|
745
582
|
function validateProvisionResponse(value: unknown): {
|
|
746
583
|
readonly walletId: string;
|
|
747
584
|
readonly address: Address;
|
|
@@ -804,10 +641,6 @@ function validatePreparedResponse(value: unknown): PreparedPrivyRequest {
|
|
|
804
641
|
};
|
|
805
642
|
}
|
|
806
643
|
|
|
807
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
808
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
644
|
function bigintJson(_key: string, value: unknown): unknown {
|
|
812
645
|
return typeof value === "bigint" ? `0x${value.toString(16)}` : value;
|
|
813
646
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
scrypt,
|
|
7
|
+
} from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
chmod,
|
|
10
|
+
mkdir,
|
|
11
|
+
readFile,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
AutonomousWalletError,
|
|
22
|
+
isRecord,
|
|
23
|
+
validateStoredRecord,
|
|
24
|
+
type AutonomousWalletCredentialStore,
|
|
25
|
+
type AutonomousWalletRecord,
|
|
26
|
+
type CredentialStoreDurabilityReport,
|
|
27
|
+
} from "./autonomous-wallet-store.js";
|
|
28
|
+
|
|
29
|
+
const scryptAsync = promisify(scrypt) as (
|
|
30
|
+
secret: string | Buffer,
|
|
31
|
+
salt: Buffer,
|
|
32
|
+
keylen: number,
|
|
33
|
+
options: { readonly N: number; readonly r: number; readonly p: number },
|
|
34
|
+
) => Promise<Buffer>;
|
|
35
|
+
|
|
36
|
+
/** Environment variable holding the externally-managed encryption secret. */
|
|
37
|
+
export const METRIK_WALLET_ENCRYPTION_KEY_ENV = "METRIK_WALLET_ENCRYPTION_KEY";
|
|
38
|
+
/** Environment variable overriding where encrypted wallet files are written. */
|
|
39
|
+
export const METRIK_WALLET_STORE_DIR_ENV = "METRIK_WALLET_STORE_DIR";
|
|
40
|
+
|
|
41
|
+
const ENVELOPE_VERSION = 1;
|
|
42
|
+
const CIPHER = "aes-256-gcm";
|
|
43
|
+
const KEY_LENGTH = 32;
|
|
44
|
+
const IV_LENGTH = 12;
|
|
45
|
+
const SALT_LENGTH = 16;
|
|
46
|
+
const SCRYPT_PARAMETERS = Object.freeze({ N: 16_384, r: 8, p: 1 });
|
|
47
|
+
const MINIMUM_SECRET_LENGTH = 16;
|
|
48
|
+
const FILE_MODE = 0o600;
|
|
49
|
+
const DIRECTORY_MODE = 0o700;
|
|
50
|
+
|
|
51
|
+
export interface EncryptedFileCredentialStoreOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Directory holding one encrypted file per store key. Defaults to
|
|
54
|
+
* `$METRIK_WALLET_STORE_DIR`, else `~/.metrik/autonomous-wallets`.
|
|
55
|
+
*/
|
|
56
|
+
readonly directory?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Encryption secret. Defaults to `$METRIK_WALLET_ENCRYPTION_KEY`. Supply it
|
|
59
|
+
* from a platform secret manager (AWS/GCP Secrets Manager, Vault, a
|
|
60
|
+
* Docker/Kubernetes secret) — never commit it next to the encrypted file.
|
|
61
|
+
*/
|
|
62
|
+
readonly encryptionKey?: string;
|
|
63
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Envelope {
|
|
67
|
+
readonly v: number;
|
|
68
|
+
readonly cipher: string;
|
|
69
|
+
readonly kdf: {
|
|
70
|
+
readonly name: "scrypt";
|
|
71
|
+
readonly N: number;
|
|
72
|
+
readonly r: number;
|
|
73
|
+
readonly p: number;
|
|
74
|
+
readonly saltBase64: string;
|
|
75
|
+
};
|
|
76
|
+
readonly ivBase64: string;
|
|
77
|
+
readonly tagBase64: string;
|
|
78
|
+
readonly ciphertextBase64: string;
|
|
79
|
+
readonly keyDigest: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Durable credential store for headless hosts (servers, containers, VMs, CI
|
|
84
|
+
* runners) that have no OS credential manager.
|
|
85
|
+
*
|
|
86
|
+
* The wallet record is encrypted at rest with AES-256-GCM under a key derived
|
|
87
|
+
* with scrypt from an externally-supplied secret and a fresh random salt per
|
|
88
|
+
* write; the IV is fresh per write and the ciphertext is authenticated against
|
|
89
|
+
* the store key. The secret is never written to disk, so the "no plaintext key
|
|
90
|
+
* on disk" guarantee still holds — but unlike the Linux kernel keyring the file
|
|
91
|
+
* survives a reboot, which is what a fund-controlling key requires.
|
|
92
|
+
*/
|
|
93
|
+
export class EncryptedFileCredentialStore implements AutonomousWalletCredentialStore {
|
|
94
|
+
private readonly directory: string;
|
|
95
|
+
private readonly secret: string;
|
|
96
|
+
|
|
97
|
+
constructor(options: EncryptedFileCredentialStoreOptions = {}) {
|
|
98
|
+
const env = options.env ?? process.env;
|
|
99
|
+
const secret =
|
|
100
|
+
options.encryptionKey ?? env[METRIK_WALLET_ENCRYPTION_KEY_ENV];
|
|
101
|
+
if (typeof secret !== "string" || secret.trim() === "") {
|
|
102
|
+
throw new AutonomousWalletError(
|
|
103
|
+
"invalid-config",
|
|
104
|
+
`EncryptedFileCredentialStore requires an encryption secret: set ${METRIK_WALLET_ENCRYPTION_KEY_ENV} (from your secret manager) or pass encryptionKey. Without it the stored wallet key cannot be decrypted.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (secret.trim().length < MINIMUM_SECRET_LENGTH) {
|
|
108
|
+
throw new AutonomousWalletError(
|
|
109
|
+
"invalid-config",
|
|
110
|
+
`the autonomous wallet encryption secret must be at least ${MINIMUM_SECRET_LENGTH} characters; generate one with "openssl rand -base64 32"`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
this.secret = secret.trim();
|
|
114
|
+
this.directory =
|
|
115
|
+
options.directory ??
|
|
116
|
+
env[METRIK_WALLET_STORE_DIR_ENV] ??
|
|
117
|
+
join(homedir(), ".metrik", "autonomous-wallets");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async load(key: string): Promise<AutonomousWalletRecord | null> {
|
|
121
|
+
const path = this.pathFor(key);
|
|
122
|
+
let raw: string;
|
|
123
|
+
try {
|
|
124
|
+
raw = await readFile(path, "utf8");
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (isNotFound(error)) return null;
|
|
127
|
+
throw new AutonomousWalletError(
|
|
128
|
+
"store-failure",
|
|
129
|
+
`encrypted wallet file at ${path} could not be read`,
|
|
130
|
+
{ cause: error },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const envelope = parseEnvelope(raw, path);
|
|
134
|
+
const digest = digestOf(key);
|
|
135
|
+
if (envelope.keyDigest !== digest) {
|
|
136
|
+
throw new AutonomousWalletError(
|
|
137
|
+
"store-failure",
|
|
138
|
+
`encrypted wallet file at ${path} belongs to a different store key`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const derived = await scryptAsync(
|
|
142
|
+
this.secret,
|
|
143
|
+
Buffer.from(envelope.kdf.saltBase64, "base64"),
|
|
144
|
+
KEY_LENGTH,
|
|
145
|
+
{ N: envelope.kdf.N, r: envelope.kdf.r, p: envelope.kdf.p },
|
|
146
|
+
);
|
|
147
|
+
let plaintext: string;
|
|
148
|
+
try {
|
|
149
|
+
const decipher = createDecipheriv(
|
|
150
|
+
CIPHER,
|
|
151
|
+
derived,
|
|
152
|
+
Buffer.from(envelope.ivBase64, "base64"),
|
|
153
|
+
);
|
|
154
|
+
decipher.setAAD(additionalData(digest));
|
|
155
|
+
decipher.setAuthTag(Buffer.from(envelope.tagBase64, "base64"));
|
|
156
|
+
plaintext = Buffer.concat([
|
|
157
|
+
decipher.update(Buffer.from(envelope.ciphertextBase64, "base64")),
|
|
158
|
+
decipher.final(),
|
|
159
|
+
]).toString("utf8");
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new AutonomousWalletError(
|
|
162
|
+
"store-failure",
|
|
163
|
+
`encrypted wallet file at ${path} failed authenticated decryption: the ${METRIK_WALLET_ENCRYPTION_KEY_ENV} secret does not match the one used to write it, or the file was modified. Restore the original secret; the wallet key cannot be recovered without it.`,
|
|
164
|
+
{ cause: error },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return validateStoredRecord(JSON.parse(plaintext));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof AutonomousWalletError) throw error;
|
|
171
|
+
throw new AutonomousWalletError(
|
|
172
|
+
"store-failure",
|
|
173
|
+
`encrypted wallet file at ${path} contains invalid wallet data`,
|
|
174
|
+
{ cause: error },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async save(key: string, record: AutonomousWalletRecord): Promise<void> {
|
|
180
|
+
const path = this.pathFor(key);
|
|
181
|
+
const digest = digestOf(key);
|
|
182
|
+
try {
|
|
183
|
+
await mkdir(this.directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
184
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
185
|
+
const iv = randomBytes(IV_LENGTH);
|
|
186
|
+
const derived = await scryptAsync(
|
|
187
|
+
this.secret,
|
|
188
|
+
salt,
|
|
189
|
+
KEY_LENGTH,
|
|
190
|
+
SCRYPT_PARAMETERS,
|
|
191
|
+
);
|
|
192
|
+
const cipher = createCipheriv(CIPHER, derived, iv);
|
|
193
|
+
cipher.setAAD(additionalData(digest));
|
|
194
|
+
const ciphertext = Buffer.concat([
|
|
195
|
+
cipher.update(Buffer.from(JSON.stringify(record), "utf8")),
|
|
196
|
+
cipher.final(),
|
|
197
|
+
]);
|
|
198
|
+
const envelope: Envelope = {
|
|
199
|
+
v: ENVELOPE_VERSION,
|
|
200
|
+
cipher: CIPHER,
|
|
201
|
+
kdf: {
|
|
202
|
+
name: "scrypt",
|
|
203
|
+
...SCRYPT_PARAMETERS,
|
|
204
|
+
saltBase64: salt.toString("base64"),
|
|
205
|
+
},
|
|
206
|
+
ivBase64: iv.toString("base64"),
|
|
207
|
+
tagBase64: cipher.getAuthTag().toString("base64"),
|
|
208
|
+
ciphertextBase64: ciphertext.toString("base64"),
|
|
209
|
+
keyDigest: digest,
|
|
210
|
+
};
|
|
211
|
+
const temporaryPath = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
212
|
+
await writeFile(temporaryPath, JSON.stringify(envelope), {
|
|
213
|
+
mode: FILE_MODE,
|
|
214
|
+
});
|
|
215
|
+
// writeFile's mode is subject to the process umask; force 0600.
|
|
216
|
+
await chmod(temporaryPath, FILE_MODE);
|
|
217
|
+
await rename(temporaryPath, path);
|
|
218
|
+
await chmod(path, FILE_MODE);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw new AutonomousWalletError(
|
|
221
|
+
"store-failure",
|
|
222
|
+
`encrypted wallet file at ${path} could not be written`,
|
|
223
|
+
{ cause: error },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async probeDurability(): Promise<CredentialStoreDurabilityReport> {
|
|
229
|
+
const probePath = join(
|
|
230
|
+
this.directory,
|
|
231
|
+
`.durability-probe-${randomBytes(6).toString("hex")}`,
|
|
232
|
+
);
|
|
233
|
+
try {
|
|
234
|
+
await mkdir(this.directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
235
|
+
await writeFile(probePath, "metrik-durability-probe", {
|
|
236
|
+
mode: FILE_MODE,
|
|
237
|
+
});
|
|
238
|
+
const observed = await readFile(probePath, "utf8");
|
|
239
|
+
if (observed !== "metrik-durability-probe") {
|
|
240
|
+
return {
|
|
241
|
+
durability: "volatile",
|
|
242
|
+
backend: "encrypted-file",
|
|
243
|
+
detail: `the probe file at ${this.directory} did not read back with the value written`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
durability: "durable",
|
|
248
|
+
backend: "encrypted-file",
|
|
249
|
+
detail: `AES-256-GCM encrypted files under ${this.directory} persist across reboots; the key is derived from ${METRIK_WALLET_ENCRYPTION_KEY_ENV}, which you must keep in your secret manager`,
|
|
250
|
+
};
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return {
|
|
253
|
+
durability: "volatile",
|
|
254
|
+
backend: "encrypted-file",
|
|
255
|
+
detail: `${this.directory} is not writable: ${error instanceof Error ? error.message : String(error)}`,
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
await rm(probePath, { force: true }).catch(() => undefined);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Absolute path of the encrypted file backing `key`. */
|
|
263
|
+
pathFor(key: string): string {
|
|
264
|
+
if (key.trim() === "") {
|
|
265
|
+
throw new AutonomousWalletError(
|
|
266
|
+
"invalid-config",
|
|
267
|
+
"secure store key is required",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
// Hash the key so an arbitrary store key can never traverse the directory.
|
|
271
|
+
return join(this.directory, `${digestOf(key)}.metrik-wallet.json`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function additionalData(keyDigest: string): Buffer {
|
|
276
|
+
return Buffer.from(
|
|
277
|
+
`metrik-autonomous-wallet-v${ENVELOPE_VERSION}:${keyDigest}`,
|
|
278
|
+
"utf8",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function digestOf(key: string): string {
|
|
283
|
+
return createHash("sha256").update(key, "utf8").digest("hex");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNotFound(error: unknown): boolean {
|
|
287
|
+
return (
|
|
288
|
+
typeof error === "object" &&
|
|
289
|
+
error !== null &&
|
|
290
|
+
(error as { readonly code?: unknown }).code === "ENOENT"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseEnvelope(raw: string, path: string): Envelope {
|
|
295
|
+
let parsed: unknown;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(raw);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
throw new AutonomousWalletError(
|
|
300
|
+
"store-failure",
|
|
301
|
+
`encrypted wallet file at ${path} is not valid JSON`,
|
|
302
|
+
{ cause: error },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const kdf = isRecord(parsed) ? parsed.kdf : undefined;
|
|
306
|
+
if (
|
|
307
|
+
!isRecord(parsed) ||
|
|
308
|
+
parsed.v !== ENVELOPE_VERSION ||
|
|
309
|
+
parsed.cipher !== CIPHER ||
|
|
310
|
+
typeof parsed.ivBase64 !== "string" ||
|
|
311
|
+
typeof parsed.tagBase64 !== "string" ||
|
|
312
|
+
typeof parsed.ciphertextBase64 !== "string" ||
|
|
313
|
+
typeof parsed.keyDigest !== "string" ||
|
|
314
|
+
!isRecord(kdf) ||
|
|
315
|
+
kdf.name !== "scrypt" ||
|
|
316
|
+
typeof kdf.N !== "number" ||
|
|
317
|
+
typeof kdf.r !== "number" ||
|
|
318
|
+
typeof kdf.p !== "number" ||
|
|
319
|
+
typeof kdf.saltBase64 !== "string"
|
|
320
|
+
) {
|
|
321
|
+
throw new AutonomousWalletError(
|
|
322
|
+
"store-failure",
|
|
323
|
+
`encrypted wallet file at ${path} has an unsupported envelope`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
v: ENVELOPE_VERSION,
|
|
328
|
+
cipher: CIPHER,
|
|
329
|
+
kdf: {
|
|
330
|
+
name: "scrypt",
|
|
331
|
+
N: kdf.N,
|
|
332
|
+
r: kdf.r,
|
|
333
|
+
p: kdf.p,
|
|
334
|
+
saltBase64: kdf.saltBase64,
|
|
335
|
+
},
|
|
336
|
+
ivBase64: parsed.ivBase64,
|
|
337
|
+
tagBase64: parsed.tagBase64,
|
|
338
|
+
ciphertextBase64: parsed.ciphertextBase64,
|
|
339
|
+
keyDigest: parsed.keyDigest,
|
|
340
|
+
};
|
|
341
|
+
}
|