@debros/network-ts-sdk 0.6.2 → 0.7.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 +191 -0
- package/dist/index.d.ts +455 -3
- package/dist/index.js +776 -2
- package/dist/index.js.map +1 -1
- package/package.json +9 -2
- package/src/index.ts +66 -0
- package/src/vault/auth.ts +98 -0
- package/src/vault/client.ts +197 -0
- package/src/vault/crypto/aes.ts +271 -0
- package/src/vault/crypto/hkdf.ts +42 -0
- package/src/vault/crypto/index.ts +27 -0
- package/src/vault/crypto/shamir.ts +173 -0
- package/src/vault/index.ts +65 -0
- package/src/vault/quorum.ts +16 -0
- package/src/vault/transport/fanout.ts +94 -0
- package/src/vault/transport/guardian.ts +285 -0
- package/src/vault/transport/index.ts +19 -0
- package/src/vault/transport/types.ts +101 -0
- package/src/vault/types.ts +62 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HKDF Key Derivation
|
|
3
|
+
*
|
|
4
|
+
* Derives deterministic sub-keys from a master secret using HKDF-SHA256 (RFC 5869).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
8
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
9
|
+
|
|
10
|
+
/** Default output length in bytes (256 bits) */
|
|
11
|
+
const DEFAULT_KEY_LENGTH = 32;
|
|
12
|
+
|
|
13
|
+
/** Maximum allowed output length (255 * SHA-256 output = 8160 bytes) */
|
|
14
|
+
const MAX_KEY_LENGTH = 255 * 32;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Derives a sub-key from input key material using HKDF-SHA256.
|
|
18
|
+
*
|
|
19
|
+
* @param ikm - Input key material (e.g., wallet private key). MUST be high-entropy.
|
|
20
|
+
* @param salt - Domain separation salt. Can be a string or bytes.
|
|
21
|
+
* @param info - Context-specific info. Can be a string or bytes.
|
|
22
|
+
* @param length - Output key length in bytes (default: 32).
|
|
23
|
+
* @returns Derived key as Uint8Array. Caller MUST zero this after use.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveKeyHKDF(
|
|
26
|
+
ikm: Uint8Array,
|
|
27
|
+
salt: string | Uint8Array,
|
|
28
|
+
info: string | Uint8Array,
|
|
29
|
+
length: number = DEFAULT_KEY_LENGTH,
|
|
30
|
+
): Uint8Array {
|
|
31
|
+
if (!ikm || ikm.length === 0) {
|
|
32
|
+
throw new Error('HKDF: input key material must not be empty');
|
|
33
|
+
}
|
|
34
|
+
if (length <= 0 || length > MAX_KEY_LENGTH) {
|
|
35
|
+
throw new Error(`HKDF: output length must be between 1 and ${MAX_KEY_LENGTH}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt;
|
|
39
|
+
const infoBytes = typeof info === 'string' ? new TextEncoder().encode(info) : info;
|
|
40
|
+
|
|
41
|
+
return hkdf(sha256, ikm, saltBytes, infoBytes, length);
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export {
|
|
2
|
+
encrypt,
|
|
3
|
+
decrypt,
|
|
4
|
+
encryptString,
|
|
5
|
+
decryptString,
|
|
6
|
+
serialize,
|
|
7
|
+
deserialize,
|
|
8
|
+
encryptAndSerialize,
|
|
9
|
+
deserializeAndDecrypt,
|
|
10
|
+
toHex,
|
|
11
|
+
fromHex,
|
|
12
|
+
toBase64,
|
|
13
|
+
fromBase64,
|
|
14
|
+
generateKey,
|
|
15
|
+
generateNonce,
|
|
16
|
+
clearKey,
|
|
17
|
+
isValidEncryptedData,
|
|
18
|
+
KEY_SIZE,
|
|
19
|
+
NONCE_SIZE,
|
|
20
|
+
TAG_SIZE,
|
|
21
|
+
} from './aes';
|
|
22
|
+
export type { EncryptedData, SerializedEncryptedData } from './aes';
|
|
23
|
+
|
|
24
|
+
export { deriveKeyHKDF } from './hkdf';
|
|
25
|
+
|
|
26
|
+
export { split as shamirSplit, combine as shamirCombine } from './shamir';
|
|
27
|
+
export type { Share as ShamirShare } from './shamir';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shamir's Secret Sharing over GF(2^8)
|
|
3
|
+
*
|
|
4
|
+
* Information-theoretic secret splitting: any K shares reconstruct the secret,
|
|
5
|
+
* K-1 shares reveal zero information.
|
|
6
|
+
*
|
|
7
|
+
* Uses GF(2^8) with irreducible polynomial x^8 + x^4 + x^3 + x + 1 (0x11B),
|
|
8
|
+
* same as AES. This is the standard choice for byte-level SSS.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from '@noble/ciphers/webcrypto';
|
|
12
|
+
|
|
13
|
+
// ── GF(2^8) Arithmetic ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const IRREDUCIBLE = 0x11b;
|
|
16
|
+
|
|
17
|
+
/** Exponential table: exp[log[a] + log[b]] = a * b */
|
|
18
|
+
const EXP_TABLE = new Uint8Array(512);
|
|
19
|
+
|
|
20
|
+
/** Logarithm table: log[a] for a in 1..255 (log[0] is undefined) */
|
|
21
|
+
const LOG_TABLE = new Uint8Array(256);
|
|
22
|
+
|
|
23
|
+
// Build log/exp tables using generator 3
|
|
24
|
+
(function buildTables() {
|
|
25
|
+
let x = 1;
|
|
26
|
+
for (let i = 0; i < 255; i++) {
|
|
27
|
+
EXP_TABLE[i] = x;
|
|
28
|
+
LOG_TABLE[x] = i;
|
|
29
|
+
x = x ^ (x << 1); // multiply by generator (3 is primitive in this field)
|
|
30
|
+
if (x >= 256) x ^= IRREDUCIBLE;
|
|
31
|
+
}
|
|
32
|
+
// Extend exp table for easy modular arithmetic (avoid mod 255)
|
|
33
|
+
for (let i = 255; i < 512; i++) {
|
|
34
|
+
EXP_TABLE[i] = EXP_TABLE[i - 255]!;
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
/** GF(2^8) addition: XOR */
|
|
39
|
+
function gfAdd(a: number, b: number): number {
|
|
40
|
+
return a ^ b;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GF(2^8) multiplication via log/exp tables */
|
|
44
|
+
function gfMul(a: number, b: number): number {
|
|
45
|
+
if (a === 0 || b === 0) return 0;
|
|
46
|
+
return EXP_TABLE[LOG_TABLE[a]! + LOG_TABLE[b]!]!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GF(2^8) multiplicative inverse */
|
|
50
|
+
function gfInv(a: number): number {
|
|
51
|
+
if (a === 0) throw new Error('GF(2^8): division by zero');
|
|
52
|
+
return EXP_TABLE[255 - LOG_TABLE[a]!]!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** GF(2^8) division: a / b */
|
|
56
|
+
function gfDiv(a: number, b: number): number {
|
|
57
|
+
if (b === 0) throw new Error('GF(2^8): division by zero');
|
|
58
|
+
if (a === 0) return 0;
|
|
59
|
+
return EXP_TABLE[(LOG_TABLE[a]! - LOG_TABLE[b]! + 255) % 255]!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Share Type ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** A single Shamir share */
|
|
65
|
+
export interface Share {
|
|
66
|
+
/** Share index (1..N, never 0) */
|
|
67
|
+
x: number;
|
|
68
|
+
/** Share data (same length as secret) */
|
|
69
|
+
y: Uint8Array;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Split ───────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Splits a secret into N shares with threshold K.
|
|
76
|
+
*
|
|
77
|
+
* @param secret - Secret bytes to split (any length)
|
|
78
|
+
* @param n - Total number of shares to create (2..255)
|
|
79
|
+
* @param k - Minimum shares needed for reconstruction (2..n)
|
|
80
|
+
* @returns Array of N shares
|
|
81
|
+
*/
|
|
82
|
+
export function split(secret: Uint8Array, n: number, k: number): Share[] {
|
|
83
|
+
if (k < 2) throw new Error('Threshold K must be at least 2');
|
|
84
|
+
if (n < k) throw new Error('Share count N must be >= threshold K');
|
|
85
|
+
if (n > 255) throw new Error('Maximum 255 shares (GF(2^8) limit)');
|
|
86
|
+
if (secret.length === 0) throw new Error('Secret must not be empty');
|
|
87
|
+
|
|
88
|
+
const coefficients = new Array<Uint8Array>(secret.length);
|
|
89
|
+
for (let i = 0; i < secret.length; i++) {
|
|
90
|
+
const poly = new Uint8Array(k);
|
|
91
|
+
poly[0] = secret[i]!;
|
|
92
|
+
const rand = randomBytes(k - 1);
|
|
93
|
+
poly.set(rand, 1);
|
|
94
|
+
coefficients[i] = poly;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const shares: Share[] = [];
|
|
98
|
+
for (let xi = 1; xi <= n; xi++) {
|
|
99
|
+
const y = new Uint8Array(secret.length);
|
|
100
|
+
for (let byteIdx = 0; byteIdx < secret.length; byteIdx++) {
|
|
101
|
+
y[byteIdx] = evaluatePolynomial(coefficients[byteIdx]!, xi);
|
|
102
|
+
}
|
|
103
|
+
shares.push({ x: xi, y });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const poly of coefficients) {
|
|
107
|
+
poly.fill(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return shares;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function evaluatePolynomial(coeffs: Uint8Array, x: number): number {
|
|
114
|
+
let result = 0;
|
|
115
|
+
for (let i = coeffs.length - 1; i >= 0; i--) {
|
|
116
|
+
result = gfAdd(gfMul(result, x), coeffs[i]!);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Combine ─────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reconstructs a secret from K or more shares using Lagrange interpolation.
|
|
125
|
+
*
|
|
126
|
+
* @param shares - Array of K or more shares (must all have same y.length)
|
|
127
|
+
* @returns Reconstructed secret
|
|
128
|
+
*/
|
|
129
|
+
export function combine(shares: Share[]): Uint8Array {
|
|
130
|
+
if (shares.length < 2) throw new Error('Need at least 2 shares');
|
|
131
|
+
|
|
132
|
+
const secretLength = shares[0]!.y.length;
|
|
133
|
+
for (const share of shares) {
|
|
134
|
+
if (share.y.length !== secretLength) {
|
|
135
|
+
throw new Error('All shares must have the same data length');
|
|
136
|
+
}
|
|
137
|
+
if (share.x === 0) {
|
|
138
|
+
throw new Error('Share index must not be 0');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const xValues = new Set(shares.map(s => s.x));
|
|
143
|
+
if (xValues.size !== shares.length) {
|
|
144
|
+
throw new Error('Duplicate share indices');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const secret = new Uint8Array(secretLength);
|
|
148
|
+
|
|
149
|
+
for (let byteIdx = 0; byteIdx < secretLength; byteIdx++) {
|
|
150
|
+
let value = 0;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < shares.length; i++) {
|
|
153
|
+
const xi = shares[i]!.x;
|
|
154
|
+
const yi = shares[i]!.y[byteIdx]!;
|
|
155
|
+
|
|
156
|
+
let basis = 1;
|
|
157
|
+
for (let j = 0; j < shares.length; j++) {
|
|
158
|
+
if (i === j) continue;
|
|
159
|
+
const xj = shares[j]!.x;
|
|
160
|
+
basis = gfMul(basis, gfDiv(xj, gfAdd(xi, xj)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
value = gfAdd(value, gfMul(yi, basis));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
secret[byteIdx] = value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return secret;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @internal Exported for cross-platform test vector validation */
|
|
173
|
+
export const _gf = { add: gfAdd, mul: gfMul, inv: gfInv, div: gfDiv, EXP_TABLE, LOG_TABLE } as const;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// High-level vault client
|
|
2
|
+
export { VaultClient } from './client';
|
|
3
|
+
export { adaptiveThreshold, writeQuorum } from './quorum';
|
|
4
|
+
export type {
|
|
5
|
+
VaultConfig,
|
|
6
|
+
SecretMeta,
|
|
7
|
+
StoreResult,
|
|
8
|
+
RetrieveResult,
|
|
9
|
+
ListResult,
|
|
10
|
+
DeleteResult,
|
|
11
|
+
GuardianResult,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
// Vault auth (renamed to avoid collision with top-level AuthClient)
|
|
15
|
+
export { AuthClient as VaultAuthClient } from './auth';
|
|
16
|
+
|
|
17
|
+
// Transport (guardian communication)
|
|
18
|
+
export { GuardianClient, GuardianError } from './transport';
|
|
19
|
+
export { fanOut, fanOutIndexed, withTimeout, withRetry } from './transport';
|
|
20
|
+
export type {
|
|
21
|
+
GuardianEndpoint,
|
|
22
|
+
GuardianErrorCode,
|
|
23
|
+
GuardianInfo,
|
|
24
|
+
HealthResponse as GuardianHealthResponse,
|
|
25
|
+
StatusResponse as GuardianStatusResponse,
|
|
26
|
+
PushResponse,
|
|
27
|
+
PullResponse,
|
|
28
|
+
StoreSecretResponse,
|
|
29
|
+
GetSecretResponse,
|
|
30
|
+
DeleteSecretResponse,
|
|
31
|
+
ListSecretsResponse,
|
|
32
|
+
SecretEntry,
|
|
33
|
+
ChallengeResponse as GuardianChallengeResponse,
|
|
34
|
+
SessionResponse as GuardianSessionResponse,
|
|
35
|
+
FanOutResult,
|
|
36
|
+
} from './transport';
|
|
37
|
+
|
|
38
|
+
// Crypto primitives
|
|
39
|
+
export {
|
|
40
|
+
encrypt,
|
|
41
|
+
decrypt,
|
|
42
|
+
encryptString,
|
|
43
|
+
decryptString,
|
|
44
|
+
serialize as serializeEncrypted,
|
|
45
|
+
deserialize as deserializeEncrypted,
|
|
46
|
+
encryptAndSerialize,
|
|
47
|
+
deserializeAndDecrypt,
|
|
48
|
+
toHex as encryptedToHex,
|
|
49
|
+
fromHex as encryptedFromHex,
|
|
50
|
+
toBase64 as encryptedToBase64,
|
|
51
|
+
fromBase64 as encryptedFromBase64,
|
|
52
|
+
generateKey,
|
|
53
|
+
generateNonce,
|
|
54
|
+
clearKey,
|
|
55
|
+
isValidEncryptedData,
|
|
56
|
+
KEY_SIZE,
|
|
57
|
+
NONCE_SIZE,
|
|
58
|
+
TAG_SIZE,
|
|
59
|
+
} from './crypto';
|
|
60
|
+
export type { EncryptedData, SerializedEncryptedData } from './crypto';
|
|
61
|
+
|
|
62
|
+
export { deriveKeyHKDF } from './crypto';
|
|
63
|
+
|
|
64
|
+
export { shamirSplit, shamirCombine } from './crypto';
|
|
65
|
+
export type { ShamirShare } from './crypto';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quorum calculations for distributed vault operations.
|
|
3
|
+
* Must match orama-vault (Zig side).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Adaptive Shamir threshold: max(3, floor(N/3)). */
|
|
7
|
+
export function adaptiveThreshold(n: number): number {
|
|
8
|
+
return Math.max(3, Math.floor(n / 3));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Write quorum: ceil(2N/3). Requires majority for consistency. */
|
|
12
|
+
export function writeQuorum(n: number): number {
|
|
13
|
+
if (n === 0) return 0;
|
|
14
|
+
if (n <= 2) return n;
|
|
15
|
+
return Math.ceil((2 * n) / 3);
|
|
16
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { GuardianClient, GuardianError } from './guardian';
|
|
2
|
+
import type { GuardianEndpoint, GuardianErrorCode, FanOutResult } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fan out an operation to multiple guardians in parallel.
|
|
6
|
+
* Returns results from all guardians (both successes and failures).
|
|
7
|
+
*/
|
|
8
|
+
export async function fanOut<T>(
|
|
9
|
+
guardians: GuardianEndpoint[],
|
|
10
|
+
operation: (client: GuardianClient) => Promise<T>,
|
|
11
|
+
): Promise<FanOutResult<T>[]> {
|
|
12
|
+
const results = await Promise.allSettled(
|
|
13
|
+
guardians.map(async (endpoint) => {
|
|
14
|
+
const client = new GuardianClient(endpoint);
|
|
15
|
+
const result = await operation(client);
|
|
16
|
+
return { endpoint, result, error: null } as FanOutResult<T>;
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return results.map((r, i) => {
|
|
21
|
+
if (r.status === 'fulfilled') return r.value;
|
|
22
|
+
const reason = r.reason as Error;
|
|
23
|
+
const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined;
|
|
24
|
+
return {
|
|
25
|
+
endpoint: guardians[i]!,
|
|
26
|
+
result: null,
|
|
27
|
+
error: reason.message,
|
|
28
|
+
errorCode,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fan out an indexed operation to multiple guardians in parallel.
|
|
35
|
+
* The operation receives the index so each guardian can get a different share.
|
|
36
|
+
*/
|
|
37
|
+
export async function fanOutIndexed<T>(
|
|
38
|
+
guardians: GuardianEndpoint[],
|
|
39
|
+
operation: (client: GuardianClient, index: number) => Promise<T>,
|
|
40
|
+
): Promise<FanOutResult<T>[]> {
|
|
41
|
+
const results = await Promise.allSettled(
|
|
42
|
+
guardians.map(async (endpoint, i) => {
|
|
43
|
+
const client = new GuardianClient(endpoint);
|
|
44
|
+
const result = await operation(client, i);
|
|
45
|
+
return { endpoint, result, error: null } as FanOutResult<T>;
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return results.map((r, i) => {
|
|
50
|
+
if (r.status === 'fulfilled') return r.value;
|
|
51
|
+
const reason = r.reason as Error;
|
|
52
|
+
const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined;
|
|
53
|
+
return {
|
|
54
|
+
endpoint: guardians[i]!,
|
|
55
|
+
result: null,
|
|
56
|
+
error: reason.message,
|
|
57
|
+
errorCode,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Race a promise against a timeout.
|
|
64
|
+
*/
|
|
65
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
66
|
+
return Promise.race([
|
|
67
|
+
promise,
|
|
68
|
+
new Promise<never>((_, reject) =>
|
|
69
|
+
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms),
|
|
70
|
+
),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Retry a function with exponential backoff.
|
|
76
|
+
* Does not retry auth or not-found errors.
|
|
77
|
+
*/
|
|
78
|
+
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
|
79
|
+
let lastError: Error | undefined;
|
|
80
|
+
for (let i = 0; i < attempts; i++) {
|
|
81
|
+
try {
|
|
82
|
+
return await fn();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
lastError = err as Error;
|
|
85
|
+
if (err instanceof GuardianError && (err.code === 'AUTH' || err.code === 'NOT_FOUND')) {
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
if (i < attempts - 1) {
|
|
89
|
+
await new Promise((r) => setTimeout(r, 200 * Math.pow(2, i)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw lastError!;
|
|
94
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GuardianEndpoint,
|
|
3
|
+
GuardianErrorCode,
|
|
4
|
+
GuardianErrorBody,
|
|
5
|
+
HealthResponse,
|
|
6
|
+
StatusResponse,
|
|
7
|
+
GuardianInfo,
|
|
8
|
+
PushResponse,
|
|
9
|
+
PullResponse,
|
|
10
|
+
StoreSecretResponse,
|
|
11
|
+
GetSecretResponse,
|
|
12
|
+
DeleteSecretResponse,
|
|
13
|
+
ListSecretsResponse,
|
|
14
|
+
ChallengeResponse,
|
|
15
|
+
SessionResponse,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
export class GuardianError extends Error {
|
|
19
|
+
constructor(public readonly code: GuardianErrorCode, message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'GuardianError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HTTP client for a single orama-vault guardian node.
|
|
29
|
+
* Supports V1 (push/pull) and V2 (CRUD secrets) endpoints.
|
|
30
|
+
*/
|
|
31
|
+
export class GuardianClient {
|
|
32
|
+
private baseUrl: string;
|
|
33
|
+
private timeoutMs: number;
|
|
34
|
+
private sessionToken: string | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(endpoint: GuardianEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
37
|
+
this.baseUrl = `http://${endpoint.address}:${endpoint.port}`;
|
|
38
|
+
this.timeoutMs = timeoutMs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Set a session token for authenticated V2 requests. */
|
|
42
|
+
setSessionToken(token: string): void {
|
|
43
|
+
this.sessionToken = token;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get the current session token. */
|
|
47
|
+
getSessionToken(): string | null {
|
|
48
|
+
return this.sessionToken;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Clear the session token. */
|
|
52
|
+
clearSessionToken(): void {
|
|
53
|
+
this.sessionToken = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── V1 endpoints ────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** GET /v1/vault/health */
|
|
59
|
+
async health(): Promise<HealthResponse> {
|
|
60
|
+
return this.get<HealthResponse>('/v1/vault/health');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** GET /v1/vault/status */
|
|
64
|
+
async status(): Promise<StatusResponse> {
|
|
65
|
+
return this.get<StatusResponse>('/v1/vault/status');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** GET /v1/vault/guardians */
|
|
69
|
+
async guardians(): Promise<GuardianInfo> {
|
|
70
|
+
return this.get<GuardianInfo>('/v1/vault/guardians');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** POST /v1/vault/push — store a share (V1). */
|
|
74
|
+
async push(identity: string, share: Uint8Array): Promise<PushResponse> {
|
|
75
|
+
return this.post<PushResponse>('/v1/vault/push', {
|
|
76
|
+
identity,
|
|
77
|
+
share: uint8ToBase64(share),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** POST /v1/vault/pull — retrieve a share (V1). */
|
|
82
|
+
async pull(identity: string): Promise<Uint8Array> {
|
|
83
|
+
const resp = await this.post<PullResponse>('/v1/vault/pull', { identity });
|
|
84
|
+
return base64ToUint8(resp.share);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Check if this guardian is reachable. */
|
|
88
|
+
async isReachable(): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
await this.health();
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── V2 auth endpoints ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** POST /v2/vault/auth/challenge — request an auth challenge. */
|
|
100
|
+
async requestChallenge(identity: string): Promise<ChallengeResponse> {
|
|
101
|
+
return this.post<ChallengeResponse>('/v2/vault/auth/challenge', { identity });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** POST /v2/vault/auth/session — exchange challenge for session token. */
|
|
105
|
+
async createSession(identity: string, nonce: string, created_ns: number, tag: string): Promise<SessionResponse> {
|
|
106
|
+
return this.post<SessionResponse>('/v2/vault/auth/session', {
|
|
107
|
+
identity,
|
|
108
|
+
nonce,
|
|
109
|
+
created_ns,
|
|
110
|
+
tag,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── V2 secrets CRUD ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** PUT /v2/vault/secrets/{name} — store a secret. Requires session token. */
|
|
117
|
+
async putSecret(name: string, share: Uint8Array, version: number): Promise<StoreSecretResponse> {
|
|
118
|
+
return this.authedRequest<StoreSecretResponse>('PUT', `/v2/vault/secrets/${encodeURIComponent(name)}`, {
|
|
119
|
+
share: uint8ToBase64(share),
|
|
120
|
+
version,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GET /v2/vault/secrets/{name} — retrieve a secret. Requires session token. */
|
|
125
|
+
async getSecret(name: string): Promise<{ share: Uint8Array; name: string; version: number; created_ns: number; updated_ns: number }> {
|
|
126
|
+
const resp = await this.authedRequest<GetSecretResponse>('GET', `/v2/vault/secrets/${encodeURIComponent(name)}`);
|
|
127
|
+
return {
|
|
128
|
+
share: base64ToUint8(resp.share),
|
|
129
|
+
name: resp.name,
|
|
130
|
+
version: resp.version,
|
|
131
|
+
created_ns: resp.created_ns,
|
|
132
|
+
updated_ns: resp.updated_ns,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** DELETE /v2/vault/secrets/{name} — delete a secret. Requires session token. */
|
|
137
|
+
async deleteSecret(name: string): Promise<DeleteSecretResponse> {
|
|
138
|
+
return this.authedRequest<DeleteSecretResponse>('DELETE', `/v2/vault/secrets/${encodeURIComponent(name)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** GET /v2/vault/secrets — list all secrets. Requires session token. */
|
|
142
|
+
async listSecrets(): Promise<ListSecretsResponse> {
|
|
143
|
+
return this.authedRequest<ListSecretsResponse>('GET', '/v2/vault/secrets');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Internal HTTP methods ───────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
private async authedRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
149
|
+
if (!this.sessionToken) {
|
|
150
|
+
throw new GuardianError('AUTH', 'No session token set. Call authenticate() first.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const headers: Record<string, string> = {
|
|
158
|
+
'X-Session-Token': this.sessionToken,
|
|
159
|
+
};
|
|
160
|
+
const init: RequestInit = {
|
|
161
|
+
method,
|
|
162
|
+
headers,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (body !== undefined) {
|
|
167
|
+
headers['Content-Type'] = 'application/json';
|
|
168
|
+
init.body = JSON.stringify(body);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const resp = await fetch(`${this.baseUrl}${path}`, init);
|
|
172
|
+
|
|
173
|
+
if (!resp.ok) {
|
|
174
|
+
const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
|
|
175
|
+
const msg = errBody.error || `HTTP ${resp.status}`;
|
|
176
|
+
throw new GuardianError(classifyHttpStatus(resp.status), msg);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (await resp.json()) as T;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
throw classifyError(err);
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async get<T>(path: string): Promise<T> {
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
193
|
+
method: 'GET',
|
|
194
|
+
signal: controller.signal,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!resp.ok) {
|
|
198
|
+
const body = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
|
|
199
|
+
const msg = body.error || `HTTP ${resp.status}`;
|
|
200
|
+
throw new GuardianError(classifyHttpStatus(resp.status), msg);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (await resp.json()) as T;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw classifyError(err);
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async post<T>(path: string, body: unknown): Promise<T> {
|
|
212
|
+
const controller = new AbortController();
|
|
213
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
219
|
+
body: JSON.stringify(body),
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!resp.ok) {
|
|
224
|
+
const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
|
|
225
|
+
const msg = errBody.error || `HTTP ${resp.status}`;
|
|
226
|
+
throw new GuardianError(classifyHttpStatus(resp.status), msg);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (await resp.json()) as T;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw classifyError(err);
|
|
232
|
+
} finally {
|
|
233
|
+
clearTimeout(timeout);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Error classification ──────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function classifyHttpStatus(status: number): GuardianErrorCode {
|
|
241
|
+
if (status === 404) return 'NOT_FOUND';
|
|
242
|
+
if (status === 401 || status === 403) return 'AUTH';
|
|
243
|
+
if (status === 409) return 'CONFLICT';
|
|
244
|
+
if (status >= 500) return 'SERVER_ERROR';
|
|
245
|
+
return 'NETWORK';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function classifyError(err: unknown): GuardianError {
|
|
249
|
+
if (err instanceof GuardianError) return err;
|
|
250
|
+
if (err instanceof Error) {
|
|
251
|
+
if (err.name === 'AbortError') {
|
|
252
|
+
return new GuardianError('TIMEOUT', `Request timed out: ${err.message}`);
|
|
253
|
+
}
|
|
254
|
+
if (err.name === 'TypeError' || err.message.includes('fetch')) {
|
|
255
|
+
return new GuardianError('NETWORK', `Network error: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
return new GuardianError('NETWORK', err.message);
|
|
258
|
+
}
|
|
259
|
+
return new GuardianError('NETWORK', String(err));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Base64 helpers ────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function uint8ToBase64(bytes: Uint8Array): string {
|
|
265
|
+
if (typeof Buffer !== 'undefined') {
|
|
266
|
+
return Buffer.from(bytes).toString('base64');
|
|
267
|
+
}
|
|
268
|
+
let binary = '';
|
|
269
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
270
|
+
binary += String.fromCharCode(bytes[i]!);
|
|
271
|
+
}
|
|
272
|
+
return btoa(binary);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function base64ToUint8(b64: string): Uint8Array {
|
|
276
|
+
if (typeof Buffer !== 'undefined') {
|
|
277
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
278
|
+
}
|
|
279
|
+
const binary = atob(b64);
|
|
280
|
+
const bytes = new Uint8Array(binary.length);
|
|
281
|
+
for (let i = 0; i < binary.length; i++) {
|
|
282
|
+
bytes[i] = binary.charCodeAt(i);
|
|
283
|
+
}
|
|
284
|
+
return bytes;
|
|
285
|
+
}
|