Added vaults

This commit is contained in:
anonpenguin23 2026-03-25 18:25:31 +02:00
parent 3108ccd908
commit 195607809e
26 changed files with 2134 additions and 9 deletions

View File

@ -1,21 +1,21 @@
# Quick Start Guide for @network/sdk # Quick Start Guide for @debros/network-ts-sdk
## 5-Minute Setup ## 5-Minute Setup
### 1. Install ### 1. Install
```bash ```bash
npm install @network/sdk npm install @debros/network-ts-sdk
``` ```
### 2. Create a Client ### 2. Create a Client
```typescript ```typescript
import { createClient } from "@network/sdk"; import { createClient } from "@debros/network-ts-sdk";
const client = createClient({ const client = createClient({
baseURL: "http://localhost:6001", baseURL: "http://localhost:6001",
apiKey: "ak_your_api_key:default", // Get from gateway apiKey: "ak_your_api_key:namespace", // Get from gateway
}); });
``` ```
@ -62,7 +62,7 @@ make run-gateway
# Terminal 3: Run E2E tests # Terminal 3: Run E2E tests
cd ../network-ts-sdk cd ../network-ts-sdk
export GATEWAY_BASE_URL=http://localhost:6001 export GATEWAY_BASE_URL=http://localhost:6001
export GATEWAY_API_KEY=ak_RsJJXoENynk_5jTJEeM4wJKx:default export GATEWAY_API_KEY=ak_your_api_key:default
pnpm run test:e2e pnpm run test:e2e
``` ```
@ -124,7 +124,7 @@ await client.db.transaction([
### Error Handling ### Error Handling
```typescript ```typescript
import { SDKError } from "@network/sdk"; import { SDKError } from "@debros/network-ts-sdk";
try { try {
await client.db.query("SELECT * FROM invalid_table"); await client.db.query("SELECT * FROM invalid_table");
@ -148,7 +148,7 @@ const msg: Message = await subscription.onMessage((m) => m);
1. Read the full [README.md](./README.md) 1. Read the full [README.md](./README.md)
2. Explore [tests/e2e/](./tests/e2e/) for examples 2. Explore [tests/e2e/](./tests/e2e/) for examples
3. Check [IMPLEMENTATION_SUMMARY.md](./IMPLEMENTATION_SUMMARY.md) for architecture 3. Explore [examples/](./examples/) for runnable code samples
## Troubleshooting ## Troubleshooting

191
README.md
View File

@ -388,6 +388,197 @@ const client = createClient({
}); });
``` ```
### Cache Operations
The SDK provides a distributed cache client backed by Olric. Data is organized into distributed maps (dmaps).
#### Put a Value
```typescript
// Put with optional TTL
await client.cache.put("sessions", "user:alice", { role: "admin" }, "1h");
```
#### Get a Value
```typescript
// Returns null on cache miss (not an error)
const result = await client.cache.get("sessions", "user:alice");
if (result) {
console.log(result.value); // { role: "admin" }
}
```
#### Delete a Value
```typescript
await client.cache.delete("sessions", "user:alice");
```
#### Multi-Get
```typescript
const results = await client.cache.multiGet("sessions", [
"user:alice",
"user:bob",
]);
// Returns Map<string, any | null> — null for misses
results.forEach((value, key) => {
console.log(key, value);
});
```
#### Scan Keys
```typescript
// Scan all keys in a dmap, optionally matching a regex
const scan = await client.cache.scan("sessions", "user:.*");
console.log(scan.keys); // ["user:alice", "user:bob"]
console.log(scan.count); // 2
```
#### Health Check
```typescript
const health = await client.cache.health();
console.log(health.status); // "ok"
```
### Storage (IPFS)
Upload, pin, and retrieve files from decentralized IPFS storage.
#### Upload a File
```typescript
// Browser
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
const result = await client.storage.upload(file, file.name);
console.log(result.cid); // "Qm..."
// Node.js
import { readFileSync } from "fs";
const buffer = readFileSync("image.jpg");
const result = await client.storage.upload(buffer, "image.jpg", { pin: true });
```
#### Retrieve Content
```typescript
// Get as ReadableStream
const stream = await client.storage.get(cid);
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process chunk
}
// Get full Response (for headers like content-length)
const response = await client.storage.getBinary(cid);
const contentLength = response.headers.get("content-length");
```
#### Pin / Unpin / Status
```typescript
// Pin an existing CID
await client.storage.pin("QmExampleCid", "my-file");
// Check pin status
const status = await client.storage.status("QmExampleCid");
console.log(status.status); // "pinned", "pinning", "queued", "unpinned", "error"
// Unpin
await client.storage.unpin("QmExampleCid");
```
### Serverless Functions (WASM)
Invoke WebAssembly serverless functions deployed on the network.
```typescript
// Configure functions namespace
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_key:namespace",
functionsConfig: {
namespace: "my-namespace",
},
});
// Invoke a function with typed input/output
interface PushInput {
token: string;
message: string;
}
interface PushOutput {
success: boolean;
messageId: string;
}
const result = await client.functions.invoke<PushInput, PushOutput>(
"send-push",
{ token: "device-token", message: "Hello!" }
);
console.log(result.messageId);
```
### Vault (Distributed Secrets)
The vault client provides Shamir-split secret storage across guardian nodes. Secrets are split into shares, distributed to guardians, and reconstructed only when enough shares are collected (quorum).
```typescript
const client = createClient({
baseURL: "http://localhost:6001",
apiKey: "ak_your_key:namespace",
vaultConfig: {
guardians: [
{ address: "10.0.0.1", port: 8443 },
{ address: "10.0.0.2", port: 8443 },
{ address: "10.0.0.3", port: 8443 },
],
identityHex: "your-identity-hex",
},
});
// Store a secret (Shamir-split across guardians)
const data = new TextEncoder().encode("my-secret-data");
const storeResult = await client.vault.store("api-key", data, 1);
console.log(storeResult.quorumMet); // true if enough guardians ACKed
// Retrieve and reconstruct a secret
const retrieved = await client.vault.retrieve("api-key");
console.log(new TextDecoder().decode(retrieved.data)); // "my-secret-data"
// List all secrets for this identity
const secrets = await client.vault.list();
console.log(secrets.secrets);
// Delete a secret from all guardians
await client.vault.delete("api-key");
```
### Wallet-Based Authentication
For wallet-based auth (challenge-response flow):
```typescript
// 1. Request a challenge
const challenge = await client.auth.challenge();
// 2. Sign the challenge with your wallet (external)
const signature = await wallet.signMessage(challenge.message);
// 3. Verify signature and get JWT
const session = await client.auth.verify(challenge.id, signature);
console.log(session.token);
// 4. Get an API key for long-lived access
const apiKey = await client.auth.getApiKey();
```
## Error Handling ## Error Handling
The SDK throws `SDKError` for all errors: The SDK throws `SDKError` for all errors:

View File

@ -1,6 +1,6 @@
{ {
"name": "@debros/network-ts-sdk", "name": "@debros/network-ts-sdk",
"version": "0.6.2", "version": "0.7.0",
"description": "TypeScript SDK for DeBros Network Gateway - Database, PubSub, Cache, Storage, and more", "description": "TypeScript SDK for DeBros Network Gateway - Database, PubSub, Cache, Storage, and more",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@ -23,7 +23,12 @@
"wasm", "wasm",
"serverless", "serverless",
"distributed", "distributed",
"gateway" "gateway",
"vault",
"secrets",
"shamir",
"encryption",
"guardian"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
@ -53,6 +58,8 @@
"release:gh": "npm publish --registry=https://npm.pkg.github.com" "release:gh": "npm publish --registry=https://npm.pkg.github.com"
}, },
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.3",
"@noble/hashes": "^1.4.0",
"isomorphic-ws": "^5.0.0" "isomorphic-ws": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {

17
pnpm-lock.yaml generated
View File

@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@noble/ciphers':
specifier: ^0.5.3
version: 0.5.3
'@noble/hashes':
specifier: ^1.4.0
version: 1.8.0
isomorphic-ws: isomorphic-ws:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(ws@8.18.3) version: 5.0.0(ws@8.18.3)
@ -419,6 +425,13 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1848,6 +1861,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@noble/ciphers@0.5.3': {}
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5

View File

@ -6,12 +6,14 @@ import { NetworkClient } from "./network/client";
import { CacheClient } from "./cache/client"; import { CacheClient } from "./cache/client";
import { StorageClient } from "./storage/client"; import { StorageClient } from "./storage/client";
import { FunctionsClient, FunctionsClientConfig } from "./functions/client"; import { FunctionsClient, FunctionsClientConfig } from "./functions/client";
import { VaultClient } from "./vault/client";
import { WSClientConfig } from "./core/ws"; import { WSClientConfig } from "./core/ws";
import { import {
StorageAdapter, StorageAdapter,
MemoryStorage, MemoryStorage,
LocalStorageAdapter, LocalStorageAdapter,
} from "./auth/types"; } from "./auth/types";
import type { VaultConfig } from "./vault/types";
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> { export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
apiKey?: string; apiKey?: string;
@ -25,6 +27,8 @@ export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
* Use this to trigger gateway failover at the application layer. * Use this to trigger gateway failover at the application layer.
*/ */
onNetworkError?: NetworkErrorCallback; onNetworkError?: NetworkErrorCallback;
/** Configuration for the vault (distributed secrets store). */
vaultConfig?: VaultConfig;
} }
export interface Client { export interface Client {
@ -35,6 +39,7 @@ export interface Client {
cache: CacheClient; cache: CacheClient;
storage: StorageClient; storage: StorageClient;
functions: FunctionsClient; functions: FunctionsClient;
vault: VaultClient | null;
} }
export function createClient(config: ClientConfig): Client { export function createClient(config: ClientConfig): Client {
@ -68,6 +73,9 @@ export function createClient(config: ClientConfig): Client {
const cache = new CacheClient(httpClient); const cache = new CacheClient(httpClient);
const storage = new StorageClient(httpClient); const storage = new StorageClient(httpClient);
const functions = new FunctionsClient(httpClient, config.functionsConfig); const functions = new FunctionsClient(httpClient, config.functionsConfig);
const vault = config.vaultConfig
? new VaultClient(config.vaultConfig)
: null;
return { return {
auth, auth,
@ -77,6 +85,7 @@ export function createClient(config: ClientConfig): Client {
cache, cache,
storage, storage,
functions, functions,
vault,
}; };
} }
@ -133,3 +142,60 @@ export type {
} from "./storage/client"; } from "./storage/client";
export type { FunctionsClientConfig } from "./functions/client"; export type { FunctionsClientConfig } from "./functions/client";
export type * from "./functions/types"; export type * from "./functions/types";
// Vault module
export { VaultClient } from "./vault/client";
export { AuthClient as VaultAuthClient } from "./vault/auth";
export { GuardianClient, GuardianError } from "./vault/transport";
export { fanOut, fanOutIndexed, withTimeout, withRetry } from "./vault/transport";
export { adaptiveThreshold, writeQuorum } from "./vault/quorum";
export {
encrypt,
decrypt,
encryptString,
decryptString,
serializeEncrypted,
deserializeEncrypted,
encryptAndSerialize,
deserializeAndDecrypt,
encryptedToHex,
encryptedFromHex,
encryptedToBase64,
encryptedFromBase64,
generateKey,
generateNonce,
clearKey,
isValidEncryptedData,
KEY_SIZE,
NONCE_SIZE,
TAG_SIZE,
deriveKeyHKDF,
shamirSplit,
shamirCombine,
} from "./vault";
export type {
VaultConfig,
SecretMeta,
StoreResult,
RetrieveResult,
ListResult,
DeleteResult,
GuardianResult as VaultGuardianResult,
EncryptedData,
SerializedEncryptedData,
ShamirShare,
GuardianEndpoint,
GuardianErrorCode,
GuardianInfo,
GuardianHealthResponse,
GuardianStatusResponse,
PushResponse,
PullResponse,
StoreSecretResponse,
GetSecretResponse,
DeleteSecretResponse,
ListSecretsResponse,
SecretEntry,
GuardianChallengeResponse,
GuardianSessionResponse,
FanOutResult,
} from "./vault";

98
src/vault/auth.ts Normal file
View File

@ -0,0 +1,98 @@
import { GuardianClient } from './transport/guardian';
import type { GuardianEndpoint } from './transport/types';
/**
* Handles challenge-response authentication with guardian nodes.
* Caches session tokens per guardian endpoint.
*
* Auth flow:
* 1. POST /v2/vault/auth/challenge with identity get {nonce, created_ns, tag}
* 2. POST /v2/vault/auth/session with identity + challenge fields get session token
* 3. Use session token as X-Session-Token header for V2 requests
*
* The session token format is: `<identity_hex>:<expiry_ns>:<tag_hex>`
*/
export class AuthClient {
private sessions = new Map<string, { token: string; expiryNs: number }>();
private identityHex: string;
private timeoutMs: number;
constructor(identityHex: string, timeoutMs = 10_000) {
this.identityHex = identityHex;
this.timeoutMs = timeoutMs;
}
/**
* Authenticate with a guardian and cache the session token.
* Returns a GuardianClient with the session token set.
*/
async authenticate(endpoint: GuardianEndpoint): Promise<GuardianClient> {
const key = `${endpoint.address}:${endpoint.port}`;
const cached = this.sessions.get(key);
// Check if we have a valid cached session (with 30s safety margin)
if (cached) {
const nowNs = Date.now() * 1_000_000;
if (cached.expiryNs > nowNs + 30_000_000_000) {
const client = new GuardianClient(endpoint, this.timeoutMs);
client.setSessionToken(cached.token);
return client;
}
// Expired, remove
this.sessions.delete(key);
}
const client = new GuardianClient(endpoint, this.timeoutMs);
// Step 1: Request challenge
const challenge = await client.requestChallenge(this.identityHex);
// Step 2: Exchange for session
const session = await client.createSession(
this.identityHex,
challenge.nonce,
challenge.created_ns,
challenge.tag,
);
// Build token string: identity:expiry_ns:tag
const token = `${session.identity}:${session.expiry_ns}:${session.tag}`;
client.setSessionToken(token);
// Cache
this.sessions.set(key, { token, expiryNs: session.expiry_ns });
return client;
}
/**
* Authenticate with multiple guardians in parallel.
* Returns authenticated GuardianClients for all that succeed.
*/
async authenticateAll(endpoints: GuardianEndpoint[]): Promise<{ client: GuardianClient; endpoint: GuardianEndpoint }[]> {
const results = await Promise.allSettled(
endpoints.map(async (ep) => {
const client = await this.authenticate(ep);
return { client, endpoint: ep };
}),
);
const authenticated: { client: GuardianClient; endpoint: GuardianEndpoint }[] = [];
for (const r of results) {
if (r.status === 'fulfilled') {
authenticated.push(r.value);
}
}
return authenticated;
}
/** Clear all cached sessions. */
clearSessions(): void {
this.sessions.clear();
}
/** Get the identity hex string. */
getIdentityHex(): string {
return this.identityHex;
}
}

197
src/vault/client.ts Normal file
View File

@ -0,0 +1,197 @@
import { AuthClient } from './auth';
import type { GuardianClient } from './transport/guardian';
import { withTimeout, withRetry } from './transport/fanout';
import { split, combine } from './crypto/shamir';
import type { Share } from './crypto/shamir';
import { adaptiveThreshold, writeQuorum } from './quorum';
import type {
VaultConfig,
StoreResult,
RetrieveResult,
ListResult,
DeleteResult,
GuardianResult,
} from './types';
const PULL_TIMEOUT_MS = 10_000;
/**
* High-level client for the orama-vault distributed secrets store.
*
* Handles:
* - Authentication with guardian nodes
* - Shamir split/combine for data distribution
* - Quorum-based writes and reads
* - V2 CRUD operations (store, retrieve, list, delete)
*/
export class VaultClient {
private config: VaultConfig;
private auth: AuthClient;
constructor(config: VaultConfig) {
this.config = config;
this.auth = new AuthClient(config.identityHex, config.timeoutMs);
}
/**
* Store a secret across guardian nodes using Shamir splitting.
*
* @param name - Secret name (alphanumeric, _, -, max 128 chars)
* @param data - Secret data to store
* @param version - Monotonic version number (must be > previous)
*/
async store(name: string, data: Uint8Array, version: number): Promise<StoreResult> {
const guardians = this.config.guardians;
const n = guardians.length;
const k = adaptiveThreshold(n);
// Shamir split the data
const shares = split(data, n, k);
// Authenticate and push to all guardians
const authed = await this.auth.authenticateAll(guardians);
const results = await Promise.allSettled(
authed.map(async ({ client, endpoint }, _i) => {
// Find the share for this guardian's index
const guardianIdx = guardians.indexOf(endpoint);
const share = shares[guardianIdx];
if (!share) throw new Error('share index out of bounds');
// Encode share as [x:1byte][y:rest]
const shareBytes = new Uint8Array(1 + share.y.length);
shareBytes[0] = share.x;
shareBytes.set(share.y, 1);
return withRetry(() => client.putSecret(name, shareBytes, version));
}),
);
// Wipe shares
for (const share of shares) {
share.y.fill(0);
}
const guardianResults: GuardianResult[] = authed.map(({ endpoint }, i) => {
const ep = `${endpoint.address}:${endpoint.port}`;
const r = results[i]!;
if (r.status === 'fulfilled') {
return { endpoint: ep, success: true };
}
return { endpoint: ep, success: false, error: (r.reason as Error).message };
});
const ackCount = results.filter((r) => r.status === 'fulfilled').length;
const failCount = results.filter((r) => r.status === 'rejected').length;
const w = writeQuorum(n);
return {
ackCount,
totalContacted: authed.length,
failCount,
quorumMet: ackCount >= w,
guardianResults,
};
}
/**
* Retrieve and reconstruct a secret from guardian nodes.
*
* @param name - Secret name
*/
async retrieve(name: string): Promise<RetrieveResult> {
const guardians = this.config.guardians;
const n = guardians.length;
const k = adaptiveThreshold(n);
// Authenticate and pull from all guardians
const authed = await this.auth.authenticateAll(guardians);
const pullResults = await Promise.allSettled(
authed.map(async ({ client }) => {
const resp = await withTimeout(client.getSecret(name), PULL_TIMEOUT_MS);
const shareBytes = resp.share;
if (shareBytes.length < 2) throw new Error('Share too short');
return {
x: shareBytes[0]!,
y: shareBytes.slice(1),
} as Share;
}),
);
const shares: Share[] = [];
for (const r of pullResults) {
if (r.status === 'fulfilled') {
shares.push(r.value);
}
}
if (shares.length < k) {
throw new Error(
`Not enough shares: collected ${shares.length} of ${k} required (contacted ${authed.length} guardians)`,
);
}
// Reconstruct
const data = combine(shares);
// Wipe collected shares
for (const share of shares) {
share.y.fill(0);
}
return {
data,
sharesCollected: shares.length,
};
}
/**
* List all secrets for this identity.
* Queries the first reachable guardian (metadata is replicated).
*/
async list(): Promise<ListResult> {
const guardians = this.config.guardians;
const authed = await this.auth.authenticateAll(guardians);
if (authed.length === 0) {
throw new Error('No guardians reachable');
}
// Query first authenticated guardian
const resp = await authed[0]!.client.listSecrets();
return { secrets: resp.secrets };
}
/**
* Delete a secret from all guardian nodes.
*
* @param name - Secret name to delete
*/
async delete(name: string): Promise<DeleteResult> {
const guardians = this.config.guardians;
const n = guardians.length;
const authed = await this.auth.authenticateAll(guardians);
const results = await Promise.allSettled(
authed.map(async ({ client }) => {
return withRetry(() => client.deleteSecret(name));
}),
);
const ackCount = results.filter((r) => r.status === 'fulfilled').length;
const w = writeQuorum(n);
return {
ackCount,
totalContacted: authed.length,
quorumMet: ackCount >= w,
};
}
/** Clear all cached auth sessions. */
clearSessions(): void {
this.auth.clearSessions();
}
}

271
src/vault/crypto/aes.ts Normal file
View File

@ -0,0 +1,271 @@
/**
* AES-256-GCM Encryption
*
* Implements authenticated encryption using AES-256 in Galois/Counter Mode.
* Uses @noble/ciphers for platform-agnostic, audited cryptographic operations.
*
* Features:
* - Authenticated encryption (confidentiality + integrity)
* - 256-bit keys for strong security
* - 96-bit nonces (randomly generated)
* - 128-bit authentication tags
*
* Security considerations:
* - Never reuse a nonce with the same key
* - Nonces are randomly generated and prepended to ciphertext
* - Authentication tags are verified before decryption
*/
import { gcm } from '@noble/ciphers/aes';
import { randomBytes } from '@noble/ciphers/webcrypto';
import { bytesToHex, hexToBytes, concatBytes } from '@noble/hashes/utils';
/**
* Size constants
*/
export const KEY_SIZE = 32; // 256 bits
export const NONCE_SIZE = 12; // 96 bits (recommended for GCM)
export const TAG_SIZE = 16; // 128 bits
/**
* Encrypted data structure
*/
export interface EncryptedData {
/** Ciphertext including authentication tag */
ciphertext: Uint8Array;
/** Nonce used for encryption */
nonce: Uint8Array;
/** Additional authenticated data (optional) */
aad?: Uint8Array;
}
/**
* Serialized encrypted data (nonce prepended to ciphertext)
*/
export interface SerializedEncryptedData {
/** Combined nonce + ciphertext + tag */
data: Uint8Array;
/** Additional authenticated data (optional) */
aad?: Uint8Array;
}
/**
* Encrypts data using AES-256-GCM
*/
export function encrypt(
plaintext: Uint8Array,
key: Uint8Array,
aad?: Uint8Array
): EncryptedData {
validateKey(key);
const nonce = randomBytes(NONCE_SIZE);
const cipher = gcm(key, nonce, aad);
const ciphertext = cipher.encrypt(plaintext);
return {
ciphertext,
nonce,
aad,
};
}
/**
* Decrypts data using AES-256-GCM
*/
export function decrypt(encryptedData: EncryptedData, key: Uint8Array): Uint8Array {
validateKey(key);
validateNonce(encryptedData.nonce);
const cipher = gcm(key, encryptedData.nonce, encryptedData.aad);
try {
return cipher.decrypt(encryptedData.ciphertext);
} catch (error) {
throw new Error('Decryption failed: invalid ciphertext or authentication tag');
}
}
/**
* Encrypts a string message
*/
export function encryptString(
message: string,
key: Uint8Array,
aad?: Uint8Array
): EncryptedData {
const plaintext = new TextEncoder().encode(message);
try {
return encrypt(plaintext, key, aad);
} finally {
plaintext.fill(0);
}
}
/**
* Decrypts to a string message
*/
export function decryptString(encryptedData: EncryptedData, key: Uint8Array): string {
const plaintext = decrypt(encryptedData, key);
try {
return new TextDecoder().decode(plaintext);
} finally {
plaintext.fill(0);
}
}
/**
* Serializes encrypted data (prepends nonce to ciphertext)
*/
export function serialize(encryptedData: EncryptedData): SerializedEncryptedData {
const data = concatBytes(encryptedData.nonce, encryptedData.ciphertext);
return {
data,
aad: encryptedData.aad,
};
}
/**
* Deserializes encrypted data
*/
export function deserialize(serialized: SerializedEncryptedData): EncryptedData {
if (serialized.data.length < NONCE_SIZE + TAG_SIZE) {
throw new Error('Invalid serialized data: too short');
}
const nonce = serialized.data.slice(0, NONCE_SIZE);
const ciphertext = serialized.data.slice(NONCE_SIZE);
return {
ciphertext,
nonce,
aad: serialized.aad,
};
}
/**
* Encrypts and serializes data in one step
*/
export function encryptAndSerialize(
plaintext: Uint8Array,
key: Uint8Array,
aad?: Uint8Array
): SerializedEncryptedData {
const encrypted = encrypt(plaintext, key, aad);
return serialize(encrypted);
}
/**
* Deserializes and decrypts data in one step
*/
export function deserializeAndDecrypt(
serialized: SerializedEncryptedData,
key: Uint8Array
): Uint8Array {
const encrypted = deserialize(serialized);
return decrypt(encrypted, key);
}
/**
* Converts encrypted data to hex string
*/
export function toHex(encryptedData: EncryptedData): string {
const serialized = serialize(encryptedData);
return bytesToHex(serialized.data);
}
/**
* Parses encrypted data from hex string
*/
export function fromHex(hex: string, aad?: Uint8Array): EncryptedData {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
const data = hexToBytes(normalized);
return deserialize({ data, aad });
}
/**
* Converts encrypted data to base64 string
*/
export function toBase64(encryptedData: EncryptedData): string {
const serialized = serialize(encryptedData);
if (typeof btoa === 'function') {
return btoa(String.fromCharCode(...serialized.data));
} else {
return Buffer.from(serialized.data).toString('base64');
}
}
/**
* Parses encrypted data from base64 string
*/
export function fromBase64(base64: string, aad?: Uint8Array): EncryptedData {
let data: Uint8Array;
if (typeof atob === 'function') {
const binary = atob(base64);
data = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
data[i] = binary.charCodeAt(i);
}
} else {
data = new Uint8Array(Buffer.from(base64, 'base64'));
}
return deserialize({ data, aad });
}
function validateKey(key: Uint8Array): void {
if (!(key instanceof Uint8Array)) {
throw new Error('Key must be a Uint8Array');
}
if (key.length !== KEY_SIZE) {
throw new Error(`Invalid key length: expected ${KEY_SIZE}, got ${key.length}`);
}
}
function validateNonce(nonce: Uint8Array): void {
if (!(nonce instanceof Uint8Array)) {
throw new Error('Nonce must be a Uint8Array');
}
if (nonce.length !== NONCE_SIZE) {
throw new Error(`Invalid nonce length: expected ${NONCE_SIZE}, got ${nonce.length}`);
}
}
/**
* Generates a random encryption key
*/
export function generateKey(): Uint8Array {
return randomBytes(KEY_SIZE);
}
/**
* Generates a random nonce
*/
export function generateNonce(): Uint8Array {
return randomBytes(NONCE_SIZE);
}
/**
* Securely clears a key from memory
*/
export function clearKey(key: Uint8Array): void {
key.fill(0);
}
/**
* Checks if encrypted data appears valid (basic structure check)
*/
export function isValidEncryptedData(data: EncryptedData): boolean {
return (
data.nonce instanceof Uint8Array &&
data.nonce.length === NONCE_SIZE &&
data.ciphertext instanceof Uint8Array &&
data.ciphertext.length >= TAG_SIZE
);
}

42
src/vault/crypto/hkdf.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* HKDF Key Derivation
*
* Derives deterministic sub-keys from a master secret using HKDF-SHA256 (RFC 5869).
*/
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha256';
/** Default output length in bytes (256 bits) */
const DEFAULT_KEY_LENGTH = 32;
/** Maximum allowed output length (255 * SHA-256 output = 8160 bytes) */
const MAX_KEY_LENGTH = 255 * 32;
/**
* Derives a sub-key from input key material using HKDF-SHA256.
*
* @param ikm - Input key material (e.g., wallet private key). MUST be high-entropy.
* @param salt - Domain separation salt. Can be a string or bytes.
* @param info - Context-specific info. Can be a string or bytes.
* @param length - Output key length in bytes (default: 32).
* @returns Derived key as Uint8Array. Caller MUST zero this after use.
*/
export function deriveKeyHKDF(
ikm: Uint8Array,
salt: string | Uint8Array,
info: string | Uint8Array,
length: number = DEFAULT_KEY_LENGTH,
): Uint8Array {
if (!ikm || ikm.length === 0) {
throw new Error('HKDF: input key material must not be empty');
}
if (length <= 0 || length > MAX_KEY_LENGTH) {
throw new Error(`HKDF: output length must be between 1 and ${MAX_KEY_LENGTH}`);
}
const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt;
const infoBytes = typeof info === 'string' ? new TextEncoder().encode(info) : info;
return hkdf(sha256, ikm, saltBytes, infoBytes, length);
}

27
src/vault/crypto/index.ts Normal file
View File

@ -0,0 +1,27 @@
export {
encrypt,
decrypt,
encryptString,
decryptString,
serialize,
deserialize,
encryptAndSerialize,
deserializeAndDecrypt,
toHex,
fromHex,
toBase64,
fromBase64,
generateKey,
generateNonce,
clearKey,
isValidEncryptedData,
KEY_SIZE,
NONCE_SIZE,
TAG_SIZE,
} from './aes';
export type { EncryptedData, SerializedEncryptedData } from './aes';
export { deriveKeyHKDF } from './hkdf';
export { split as shamirSplit, combine as shamirCombine } from './shamir';
export type { Share as ShamirShare } from './shamir';

173
src/vault/crypto/shamir.ts Normal file
View File

@ -0,0 +1,173 @@
/**
* Shamir's Secret Sharing over GF(2^8)
*
* Information-theoretic secret splitting: any K shares reconstruct the secret,
* K-1 shares reveal zero information.
*
* Uses GF(2^8) with irreducible polynomial x^8 + x^4 + x^3 + x + 1 (0x11B),
* same as AES. This is the standard choice for byte-level SSS.
*/
import { randomBytes } from '@noble/ciphers/webcrypto';
// ── GF(2^8) Arithmetic ─────────────────────────────────────────────────────
const IRREDUCIBLE = 0x11b;
/** Exponential table: exp[log[a] + log[b]] = a * b */
const EXP_TABLE = new Uint8Array(512);
/** Logarithm table: log[a] for a in 1..255 (log[0] is undefined) */
const LOG_TABLE = new Uint8Array(256);
// Build log/exp tables using generator 3
(function buildTables() {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP_TABLE[i] = x;
LOG_TABLE[x] = i;
x = x ^ (x << 1); // multiply by generator (3 is primitive in this field)
if (x >= 256) x ^= IRREDUCIBLE;
}
// Extend exp table for easy modular arithmetic (avoid mod 255)
for (let i = 255; i < 512; i++) {
EXP_TABLE[i] = EXP_TABLE[i - 255]!;
}
})();
/** GF(2^8) addition: XOR */
function gfAdd(a: number, b: number): number {
return a ^ b;
}
/** GF(2^8) multiplication via log/exp tables */
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP_TABLE[LOG_TABLE[a]! + LOG_TABLE[b]!]!;
}
/** GF(2^8) multiplicative inverse */
function gfInv(a: number): number {
if (a === 0) throw new Error('GF(2^8): division by zero');
return EXP_TABLE[255 - LOG_TABLE[a]!]!;
}
/** GF(2^8) division: a / b */
function gfDiv(a: number, b: number): number {
if (b === 0) throw new Error('GF(2^8): division by zero');
if (a === 0) return 0;
return EXP_TABLE[(LOG_TABLE[a]! - LOG_TABLE[b]! + 255) % 255]!;
}
// ── Share Type ──────────────────────────────────────────────────────────────
/** A single Shamir share */
export interface Share {
/** Share index (1..N, never 0) */
x: number;
/** Share data (same length as secret) */
y: Uint8Array;
}
// ── Split ───────────────────────────────────────────────────────────────────
/**
* Splits a secret into N shares with threshold K.
*
* @param secret - Secret bytes to split (any length)
* @param n - Total number of shares to create (2..255)
* @param k - Minimum shares needed for reconstruction (2..n)
* @returns Array of N shares
*/
export function split(secret: Uint8Array, n: number, k: number): Share[] {
if (k < 2) throw new Error('Threshold K must be at least 2');
if (n < k) throw new Error('Share count N must be >= threshold K');
if (n > 255) throw new Error('Maximum 255 shares (GF(2^8) limit)');
if (secret.length === 0) throw new Error('Secret must not be empty');
const coefficients = new Array<Uint8Array>(secret.length);
for (let i = 0; i < secret.length; i++) {
const poly = new Uint8Array(k);
poly[0] = secret[i]!;
const rand = randomBytes(k - 1);
poly.set(rand, 1);
coefficients[i] = poly;
}
const shares: Share[] = [];
for (let xi = 1; xi <= n; xi++) {
const y = new Uint8Array(secret.length);
for (let byteIdx = 0; byteIdx < secret.length; byteIdx++) {
y[byteIdx] = evaluatePolynomial(coefficients[byteIdx]!, xi);
}
shares.push({ x: xi, y });
}
for (const poly of coefficients) {
poly.fill(0);
}
return shares;
}
function evaluatePolynomial(coeffs: Uint8Array, x: number): number {
let result = 0;
for (let i = coeffs.length - 1; i >= 0; i--) {
result = gfAdd(gfMul(result, x), coeffs[i]!);
}
return result;
}
// ── Combine ─────────────────────────────────────────────────────────────────
/**
* Reconstructs a secret from K or more shares using Lagrange interpolation.
*
* @param shares - Array of K or more shares (must all have same y.length)
* @returns Reconstructed secret
*/
export function combine(shares: Share[]): Uint8Array {
if (shares.length < 2) throw new Error('Need at least 2 shares');
const secretLength = shares[0]!.y.length;
for (const share of shares) {
if (share.y.length !== secretLength) {
throw new Error('All shares must have the same data length');
}
if (share.x === 0) {
throw new Error('Share index must not be 0');
}
}
const xValues = new Set(shares.map(s => s.x));
if (xValues.size !== shares.length) {
throw new Error('Duplicate share indices');
}
const secret = new Uint8Array(secretLength);
for (let byteIdx = 0; byteIdx < secretLength; byteIdx++) {
let value = 0;
for (let i = 0; i < shares.length; i++) {
const xi = shares[i]!.x;
const yi = shares[i]!.y[byteIdx]!;
let basis = 1;
for (let j = 0; j < shares.length; j++) {
if (i === j) continue;
const xj = shares[j]!.x;
basis = gfMul(basis, gfDiv(xj, gfAdd(xi, xj)));
}
value = gfAdd(value, gfMul(yi, basis));
}
secret[byteIdx] = value;
}
return secret;
}
/** @internal Exported for cross-platform test vector validation */
export const _gf = { add: gfAdd, mul: gfMul, inv: gfInv, div: gfDiv, EXP_TABLE, LOG_TABLE } as const;

65
src/vault/index.ts Normal file
View File

@ -0,0 +1,65 @@
// High-level vault client
export { VaultClient } from './client';
export { adaptiveThreshold, writeQuorum } from './quorum';
export type {
VaultConfig,
SecretMeta,
StoreResult,
RetrieveResult,
ListResult,
DeleteResult,
GuardianResult,
} from './types';
// Vault auth (renamed to avoid collision with top-level AuthClient)
export { AuthClient as VaultAuthClient } from './auth';
// Transport (guardian communication)
export { GuardianClient, GuardianError } from './transport';
export { fanOut, fanOutIndexed, withTimeout, withRetry } from './transport';
export type {
GuardianEndpoint,
GuardianErrorCode,
GuardianInfo,
HealthResponse as GuardianHealthResponse,
StatusResponse as GuardianStatusResponse,
PushResponse,
PullResponse,
StoreSecretResponse,
GetSecretResponse,
DeleteSecretResponse,
ListSecretsResponse,
SecretEntry,
ChallengeResponse as GuardianChallengeResponse,
SessionResponse as GuardianSessionResponse,
FanOutResult,
} from './transport';
// Crypto primitives
export {
encrypt,
decrypt,
encryptString,
decryptString,
serialize as serializeEncrypted,
deserialize as deserializeEncrypted,
encryptAndSerialize,
deserializeAndDecrypt,
toHex as encryptedToHex,
fromHex as encryptedFromHex,
toBase64 as encryptedToBase64,
fromBase64 as encryptedFromBase64,
generateKey,
generateNonce,
clearKey,
isValidEncryptedData,
KEY_SIZE,
NONCE_SIZE,
TAG_SIZE,
} from './crypto';
export type { EncryptedData, SerializedEncryptedData } from './crypto';
export { deriveKeyHKDF } from './crypto';
export { shamirSplit, shamirCombine } from './crypto';
export type { ShamirShare } from './crypto';

16
src/vault/quorum.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* Quorum calculations for distributed vault operations.
* Must match orama-vault (Zig side).
*/
/** Adaptive Shamir threshold: max(3, floor(N/3)). */
export function adaptiveThreshold(n: number): number {
return Math.max(3, Math.floor(n / 3));
}
/** Write quorum: ceil(2N/3). Requires majority for consistency. */
export function writeQuorum(n: number): number {
if (n === 0) return 0;
if (n <= 2) return n;
return Math.ceil((2 * n) / 3);
}

View File

@ -0,0 +1,94 @@
import { GuardianClient, GuardianError } from './guardian';
import type { GuardianEndpoint, GuardianErrorCode, FanOutResult } from './types';
/**
* Fan out an operation to multiple guardians in parallel.
* Returns results from all guardians (both successes and failures).
*/
export async function fanOut<T>(
guardians: GuardianEndpoint[],
operation: (client: GuardianClient) => Promise<T>,
): Promise<FanOutResult<T>[]> {
const results = await Promise.allSettled(
guardians.map(async (endpoint) => {
const client = new GuardianClient(endpoint);
const result = await operation(client);
return { endpoint, result, error: null } as FanOutResult<T>;
}),
);
return results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
const reason = r.reason as Error;
const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined;
return {
endpoint: guardians[i]!,
result: null,
error: reason.message,
errorCode,
};
});
}
/**
* Fan out an indexed operation to multiple guardians in parallel.
* The operation receives the index so each guardian can get a different share.
*/
export async function fanOutIndexed<T>(
guardians: GuardianEndpoint[],
operation: (client: GuardianClient, index: number) => Promise<T>,
): Promise<FanOutResult<T>[]> {
const results = await Promise.allSettled(
guardians.map(async (endpoint, i) => {
const client = new GuardianClient(endpoint);
const result = await operation(client, i);
return { endpoint, result, error: null } as FanOutResult<T>;
}),
);
return results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
const reason = r.reason as Error;
const errorCode: GuardianErrorCode | undefined = reason instanceof GuardianError ? reason.code : undefined;
return {
endpoint: guardians[i]!,
result: null,
error: reason.message,
errorCode,
};
});
}
/**
* Race a promise against a timeout.
*/
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms),
),
]);
}
/**
* Retry a function with exponential backoff.
* Does not retry auth or not-found errors.
*/
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
if (err instanceof GuardianError && (err.code === 'AUTH' || err.code === 'NOT_FOUND')) {
throw err;
}
if (i < attempts - 1) {
await new Promise((r) => setTimeout(r, 200 * Math.pow(2, i)));
}
}
}
throw lastError!;
}

View File

@ -0,0 +1,285 @@
import type {
GuardianEndpoint,
GuardianErrorCode,
GuardianErrorBody,
HealthResponse,
StatusResponse,
GuardianInfo,
PushResponse,
PullResponse,
StoreSecretResponse,
GetSecretResponse,
DeleteSecretResponse,
ListSecretsResponse,
ChallengeResponse,
SessionResponse,
} from './types';
export class GuardianError extends Error {
constructor(public readonly code: GuardianErrorCode, message: string) {
super(message);
this.name = 'GuardianError';
}
}
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* HTTP client for a single orama-vault guardian node.
* Supports V1 (push/pull) and V2 (CRUD secrets) endpoints.
*/
export class GuardianClient {
private baseUrl: string;
private timeoutMs: number;
private sessionToken: string | null = null;
constructor(endpoint: GuardianEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS) {
this.baseUrl = `http://${endpoint.address}:${endpoint.port}`;
this.timeoutMs = timeoutMs;
}
/** Set a session token for authenticated V2 requests. */
setSessionToken(token: string): void {
this.sessionToken = token;
}
/** Get the current session token. */
getSessionToken(): string | null {
return this.sessionToken;
}
/** Clear the session token. */
clearSessionToken(): void {
this.sessionToken = null;
}
// ── V1 endpoints ────────────────────────────────────────────────────
/** GET /v1/vault/health */
async health(): Promise<HealthResponse> {
return this.get<HealthResponse>('/v1/vault/health');
}
/** GET /v1/vault/status */
async status(): Promise<StatusResponse> {
return this.get<StatusResponse>('/v1/vault/status');
}
/** GET /v1/vault/guardians */
async guardians(): Promise<GuardianInfo> {
return this.get<GuardianInfo>('/v1/vault/guardians');
}
/** POST /v1/vault/push — store a share (V1). */
async push(identity: string, share: Uint8Array): Promise<PushResponse> {
return this.post<PushResponse>('/v1/vault/push', {
identity,
share: uint8ToBase64(share),
});
}
/** POST /v1/vault/pull — retrieve a share (V1). */
async pull(identity: string): Promise<Uint8Array> {
const resp = await this.post<PullResponse>('/v1/vault/pull', { identity });
return base64ToUint8(resp.share);
}
/** Check if this guardian is reachable. */
async isReachable(): Promise<boolean> {
try {
await this.health();
return true;
} catch {
return false;
}
}
// ── V2 auth endpoints ───────────────────────────────────────────────
/** POST /v2/vault/auth/challenge — request an auth challenge. */
async requestChallenge(identity: string): Promise<ChallengeResponse> {
return this.post<ChallengeResponse>('/v2/vault/auth/challenge', { identity });
}
/** POST /v2/vault/auth/session — exchange challenge for session token. */
async createSession(identity: string, nonce: string, created_ns: number, tag: string): Promise<SessionResponse> {
return this.post<SessionResponse>('/v2/vault/auth/session', {
identity,
nonce,
created_ns,
tag,
});
}
// ── V2 secrets CRUD ─────────────────────────────────────────────────
/** PUT /v2/vault/secrets/{name} — store a secret. Requires session token. */
async putSecret(name: string, share: Uint8Array, version: number): Promise<StoreSecretResponse> {
return this.authedRequest<StoreSecretResponse>('PUT', `/v2/vault/secrets/${encodeURIComponent(name)}`, {
share: uint8ToBase64(share),
version,
});
}
/** GET /v2/vault/secrets/{name} — retrieve a secret. Requires session token. */
async getSecret(name: string): Promise<{ share: Uint8Array; name: string; version: number; created_ns: number; updated_ns: number }> {
const resp = await this.authedRequest<GetSecretResponse>('GET', `/v2/vault/secrets/${encodeURIComponent(name)}`);
return {
share: base64ToUint8(resp.share),
name: resp.name,
version: resp.version,
created_ns: resp.created_ns,
updated_ns: resp.updated_ns,
};
}
/** DELETE /v2/vault/secrets/{name} — delete a secret. Requires session token. */
async deleteSecret(name: string): Promise<DeleteSecretResponse> {
return this.authedRequest<DeleteSecretResponse>('DELETE', `/v2/vault/secrets/${encodeURIComponent(name)}`);
}
/** GET /v2/vault/secrets — list all secrets. Requires session token. */
async listSecrets(): Promise<ListSecretsResponse> {
return this.authedRequest<ListSecretsResponse>('GET', '/v2/vault/secrets');
}
// ── Internal HTTP methods ───────────────────────────────────────────
private async authedRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
if (!this.sessionToken) {
throw new GuardianError('AUTH', 'No session token set. Call authenticate() first.');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const headers: Record<string, string> = {
'X-Session-Token': this.sessionToken,
};
const init: RequestInit = {
method,
headers,
signal: controller.signal,
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const resp = await fetch(`${this.baseUrl}${path}`, init);
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
const msg = errBody.error || `HTTP ${resp.status}`;
throw new GuardianError(classifyHttpStatus(resp.status), msg);
}
return (await resp.json()) as T;
} catch (err) {
throw classifyError(err);
} finally {
clearTimeout(timeout);
}
}
private async get<T>(path: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await fetch(`${this.baseUrl}${path}`, {
method: 'GET',
signal: controller.signal,
});
if (!resp.ok) {
const body = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
const msg = body.error || `HTTP ${resp.status}`;
throw new GuardianError(classifyHttpStatus(resp.status), msg);
}
return (await resp.json()) as T;
} catch (err) {
throw classifyError(err);
} finally {
clearTimeout(timeout);
}
}
private async post<T>(path: string, body: unknown): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!resp.ok) {
const errBody = (await resp.json().catch(() => ({}))) as GuardianErrorBody;
const msg = errBody.error || `HTTP ${resp.status}`;
throw new GuardianError(classifyHttpStatus(resp.status), msg);
}
return (await resp.json()) as T;
} catch (err) {
throw classifyError(err);
} finally {
clearTimeout(timeout);
}
}
}
// ── Error classification ──────────────────────────────────────────────────
function classifyHttpStatus(status: number): GuardianErrorCode {
if (status === 404) return 'NOT_FOUND';
if (status === 401 || status === 403) return 'AUTH';
if (status === 409) return 'CONFLICT';
if (status >= 500) return 'SERVER_ERROR';
return 'NETWORK';
}
function classifyError(err: unknown): GuardianError {
if (err instanceof GuardianError) return err;
if (err instanceof Error) {
if (err.name === 'AbortError') {
return new GuardianError('TIMEOUT', `Request timed out: ${err.message}`);
}
if (err.name === 'TypeError' || err.message.includes('fetch')) {
return new GuardianError('NETWORK', `Network error: ${err.message}`);
}
return new GuardianError('NETWORK', err.message);
}
return new GuardianError('NETWORK', String(err));
}
// ── Base64 helpers ────────────────────────────────────────────────────────
function uint8ToBase64(bytes: Uint8Array): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64');
}
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}
function base64ToUint8(b64: string): Uint8Array {
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(b64, 'base64'));
}
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@ -0,0 +1,19 @@
export { GuardianClient, GuardianError } from './guardian';
export { fanOut, fanOutIndexed, withTimeout, withRetry } from './fanout';
export type {
GuardianEndpoint,
GuardianErrorCode,
GuardianInfo,
HealthResponse,
StatusResponse,
PushResponse,
PullResponse,
StoreSecretResponse,
GetSecretResponse,
DeleteSecretResponse,
ListSecretsResponse,
SecretEntry,
ChallengeResponse,
SessionResponse,
FanOutResult,
} from './types';

View File

@ -0,0 +1,101 @@
/** A guardian node endpoint. */
export interface GuardianEndpoint {
address: string;
port: number;
}
/** V1 push response. */
export interface PushResponse {
status: string;
}
/** V1 pull response. */
export interface PullResponse {
share: string; // base64
}
/** V2 store response. */
export interface StoreSecretResponse {
status: string;
name: string;
version: number;
}
/** V2 get response. */
export interface GetSecretResponse {
share: string; // base64
name: string;
version: number;
created_ns: number;
updated_ns: number;
}
/** V2 delete response. */
export interface DeleteSecretResponse {
status: string;
name: string;
}
/** V2 list response. */
export interface ListSecretsResponse {
secrets: SecretEntry[];
}
/** An entry in the list secrets response. */
export interface SecretEntry {
name: string;
version: number;
size: number;
}
/** Health check response. */
export interface HealthResponse {
status: string;
version: string;
}
/** Status response. */
export interface StatusResponse {
status: string;
version: string;
data_dir: string;
client_port: number;
peer_port: number;
}
/** Guardian info response. */
export interface GuardianInfo {
guardians: Array<{ address: string; port: number }>;
threshold: number;
total: number;
}
/** Challenge response from auth endpoint. */
export interface ChallengeResponse {
nonce: string;
created_ns: number;
tag: string;
}
/** Session token response from auth endpoint. */
export interface SessionResponse {
identity: string;
expiry_ns: number;
tag: string;
}
/** Error body from guardian. */
export interface GuardianErrorBody {
error: string;
}
/** Error classification codes. */
export type GuardianErrorCode = 'TIMEOUT' | 'NOT_FOUND' | 'AUTH' | 'SERVER_ERROR' | 'NETWORK' | 'CONFLICT';
/** Fan-out result for a single guardian. */
export interface FanOutResult<T> {
endpoint: GuardianEndpoint;
result: T | null;
error: string | null;
errorCode?: GuardianErrorCode;
}

62
src/vault/types.ts Normal file
View File

@ -0,0 +1,62 @@
import type { GuardianEndpoint } from './transport/types';
/** Configuration for VaultClient. */
export interface VaultConfig {
/** Guardian endpoints to connect to. */
guardians: GuardianEndpoint[];
/** HMAC key for authentication (derived from user's secret). */
hmacKey: Uint8Array;
/** Identity hash (hex string, 64 chars). */
identityHex: string;
/** Request timeout in ms (default: 10000). */
timeoutMs?: number;
}
/** Metadata for a stored secret. */
export interface SecretMeta {
name: string;
version: number;
size: number;
}
/** Result of a store operation. */
export interface StoreResult {
/** Number of guardians that acknowledged. */
ackCount: number;
/** Total guardians contacted. */
totalContacted: number;
/** Number of failures. */
failCount: number;
/** Whether write quorum was met. */
quorumMet: boolean;
/** Per-guardian results. */
guardianResults: GuardianResult[];
}
/** Result of a retrieve operation. */
export interface RetrieveResult {
/** The reconstructed secret data. */
data: Uint8Array;
/** Number of shares collected. */
sharesCollected: number;
}
/** Result of a list operation. */
export interface ListResult {
secrets: SecretMeta[];
}
/** Result of a delete operation. */
export interface DeleteResult {
/** Number of guardians that acknowledged. */
ackCount: number;
totalContacted: number;
quorumMet: boolean;
}
/** Per-guardian operation result. */
export interface GuardianResult {
endpoint: string;
success: boolean;
error?: string;
}

View File

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { AuthClient } from '../../../src/vault/auth';
describe('AuthClient', () => {
it('constructs with identity', () => {
const auth = new AuthClient('a'.repeat(64));
expect(auth.getIdentityHex()).toBe('a'.repeat(64));
});
it('clearSessions does not throw', () => {
const auth = new AuthClient('b'.repeat(64));
expect(() => auth.clearSessions()).not.toThrow();
});
it('authenticateAll returns empty for no endpoints', async () => {
const auth = new AuthClient('c'.repeat(64));
const results = await auth.authenticateAll([]);
expect(results).toEqual([]);
});
});

View File

@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import {
encrypt,
decrypt,
encryptString,
decryptString,
generateKey,
clearKey,
serialize,
deserialize,
toHex,
fromHex,
toBase64,
fromBase64,
isValidEncryptedData,
KEY_SIZE,
NONCE_SIZE,
} from '../../../src/vault/crypto/aes';
describe('AES-256-GCM', () => {
it('encrypt/decrypt round-trip', () => {
const key = generateKey();
const plaintext = new TextEncoder().encode('hello vault');
const encrypted = encrypt(plaintext, key);
const decrypted = decrypt(encrypted, key);
expect(decrypted).toEqual(plaintext);
clearKey(key);
});
it('encryptString/decryptString round-trip', () => {
const key = generateKey();
const msg = 'sensitive data 123';
const encrypted = encryptString(msg, key);
const decrypted = decryptString(encrypted, key);
expect(decrypted).toBe(msg);
clearKey(key);
});
it('different keys cannot decrypt', () => {
const key1 = generateKey();
const key2 = generateKey();
const encrypted = encrypt(new Uint8Array([1, 2, 3]), key1);
expect(() => decrypt(encrypted, key2)).toThrow();
clearKey(key1);
clearKey(key2);
});
it('nonce is correct size', () => {
const key = generateKey();
const encrypted = encrypt(new Uint8Array([42]), key);
expect(encrypted.nonce.length).toBe(NONCE_SIZE);
clearKey(key);
});
it('rejects invalid key size', () => {
expect(() => encrypt(new Uint8Array([1]), new Uint8Array(16))).toThrow('Invalid key length');
});
it('serialize/deserialize round-trip', () => {
const key = generateKey();
const encrypted = encrypt(new Uint8Array([1, 2, 3]), key);
const serialized = serialize(encrypted);
const deserialized = deserialize(serialized);
expect(decrypt(deserialized, key)).toEqual(new Uint8Array([1, 2, 3]));
clearKey(key);
});
it('toHex/fromHex round-trip', () => {
const key = generateKey();
const encrypted = encrypt(new Uint8Array([10, 20, 30]), key);
const hex = toHex(encrypted);
const restored = fromHex(hex);
expect(decrypt(restored, key)).toEqual(new Uint8Array([10, 20, 30]));
clearKey(key);
});
it('toBase64/fromBase64 round-trip', () => {
const key = generateKey();
const encrypted = encrypt(new Uint8Array([99]), key);
const b64 = toBase64(encrypted);
const restored = fromBase64(b64);
expect(decrypt(restored, key)).toEqual(new Uint8Array([99]));
clearKey(key);
});
it('isValidEncryptedData checks structure', () => {
const key = generateKey();
const encrypted = encrypt(new Uint8Array([1]), key);
expect(isValidEncryptedData(encrypted)).toBe(true);
expect(isValidEncryptedData({ ciphertext: new Uint8Array(0), nonce: new Uint8Array(0) })).toBe(false);
clearKey(key);
});
it('generateKey produces KEY_SIZE bytes', () => {
const key = generateKey();
expect(key.length).toBe(KEY_SIZE);
clearKey(key);
});
});

View File

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { deriveKeyHKDF } from '../../../src/vault/crypto/hkdf';
describe('HKDF Derivation', () => {
it('derives 32-byte key by default', () => {
const ikm = new Uint8Array(32).fill(0xab);
const key = deriveKeyHKDF(ikm, 'test-salt', 'test-info');
expect(key.length).toBe(32);
});
it('same inputs produce same output', () => {
const ikm = new Uint8Array(32).fill(0x42);
const key1 = deriveKeyHKDF(ikm, 'salt', 'info');
const key2 = deriveKeyHKDF(ikm, 'salt', 'info');
expect(key1).toEqual(key2);
});
it('different salts produce different keys', () => {
const ikm = new Uint8Array(32).fill(0x42);
const key1 = deriveKeyHKDF(ikm, 'salt-a', 'info');
const key2 = deriveKeyHKDF(ikm, 'salt-b', 'info');
expect(key1).not.toEqual(key2);
});
it('different info produce different keys', () => {
const ikm = new Uint8Array(32).fill(0x42);
const key1 = deriveKeyHKDF(ikm, 'salt', 'info-a');
const key2 = deriveKeyHKDF(ikm, 'salt', 'info-b');
expect(key1).not.toEqual(key2);
});
it('custom length', () => {
const ikm = new Uint8Array(32).fill(0x42);
const key = deriveKeyHKDF(ikm, 'salt', 'info', 64);
expect(key.length).toBe(64);
});
it('throws on empty ikm', () => {
expect(() => deriveKeyHKDF(new Uint8Array(0), 'salt', 'info')).toThrow('must not be empty');
});
it('accepts Uint8Array salt and info', () => {
const ikm = new Uint8Array(32).fill(0xab);
const salt = new Uint8Array([1, 2, 3]);
const info = new Uint8Array([4, 5, 6]);
const key = deriveKeyHKDF(ikm, salt, info);
expect(key.length).toBe(32);
});
});

View File

@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { split, combine } from '../../../src/vault/crypto/shamir';
describe('Shamir SSS', () => {
it('2-of-3 round-trip', () => {
const secret = new Uint8Array([42]);
const shares = split(secret, 3, 2);
expect(shares).toHaveLength(3);
const recovered = combine([shares[0]!, shares[1]!]);
expect(recovered).toEqual(secret);
const recovered2 = combine([shares[0]!, shares[2]!]);
expect(recovered2).toEqual(secret);
});
it('3-of-5 multi-byte', () => {
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const shares = split(secret, 5, 3);
expect(shares).toHaveLength(5);
const recovered = combine([shares[0]!, shares[2]!, shares[4]!]);
expect(recovered).toEqual(secret);
});
it('all C(5,3) subsets reconstruct', () => {
const secret = new Uint8Array([42, 137, 255, 0]);
const shares = split(secret, 5, 3);
for (let i = 0; i < 5; i++) {
for (let j = i + 1; j < 5; j++) {
for (let l = j + 1; l < 5; l++) {
const recovered = combine([shares[i]!, shares[j]!, shares[l]!]);
expect(recovered).toEqual(secret);
}
}
}
});
it('share indices are 1..N', () => {
const shares = split(new Uint8Array([42]), 5, 3);
expect(shares.map(s => s.x)).toEqual([1, 2, 3, 4, 5]);
});
it('throws on K < 2', () => {
expect(() => split(new Uint8Array([1]), 3, 1)).toThrow('Threshold K must be at least 2');
});
it('throws on N < K', () => {
expect(() => split(new Uint8Array([1]), 2, 3)).toThrow('Share count N must be >= threshold K');
});
it('throws on N > 255', () => {
expect(() => split(new Uint8Array([1]), 256, 2)).toThrow('Maximum 255 shares');
});
it('throws on empty secret', () => {
expect(() => split(new Uint8Array(0), 3, 2)).toThrow('Secret must not be empty');
});
it('throws on duplicate shares', () => {
expect(() => combine([
{ x: 1, y: new Uint8Array([1]) },
{ x: 1, y: new Uint8Array([2]) },
])).toThrow('Duplicate share indices');
});
it('throws on mismatched lengths', () => {
expect(() => combine([
{ x: 1, y: new Uint8Array([1, 2]) },
{ x: 2, y: new Uint8Array([3]) },
])).toThrow('same data length');
});
it('large secret (256 bytes)', () => {
const secret = new Uint8Array(256);
for (let i = 0; i < 256; i++) secret[i] = i;
const shares = split(secret, 10, 5);
const recovered = combine(shares.slice(0, 5));
expect(recovered).toEqual(secret);
});
});

View File

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { withTimeout } from '../../../src/vault/transport/fanout';
describe('withTimeout', () => {
it('resolves when promise completes before timeout', async () => {
const result = await withTimeout(Promise.resolve('ok'), 1000);
expect(result).toBe('ok');
});
it('rejects when timeout expires', async () => {
const slow = new Promise<string>((resolve) => setTimeout(() => resolve('late'), 500));
await expect(withTimeout(slow, 50)).rejects.toThrow('timeout after 50ms');
});
it('propagates original error', async () => {
const failing = Promise.reject(new Error('original'));
await expect(withTimeout(failing, 1000)).rejects.toThrow('original');
});
});

View File

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { GuardianClient, GuardianError } from '../../../src/vault/transport/guardian';
describe('GuardianClient', () => {
it('constructs with endpoint', () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
expect(client).toBeDefined();
});
it('session token management', () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
expect(client.getSessionToken()).toBeNull();
client.setSessionToken('test-token');
expect(client.getSessionToken()).toBe('test-token');
client.clearSessionToken();
expect(client.getSessionToken()).toBeNull();
});
it('GuardianError has code and message', () => {
const err = new GuardianError('TIMEOUT', 'request timed out');
expect(err.code).toBe('TIMEOUT');
expect(err.message).toBe('request timed out');
expect(err.name).toBe('GuardianError');
expect(err instanceof Error).toBe(true);
});
it('putSecret throws without session token', async () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
await expect(client.putSecret('test', new Uint8Array([1]), 1)).rejects.toThrow('No session token');
});
it('getSecret throws without session token', async () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
await expect(client.getSecret('test')).rejects.toThrow('No session token');
});
it('deleteSecret throws without session token', async () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
await expect(client.deleteSecret('test')).rejects.toThrow('No session token');
});
it('listSecrets throws without session token', async () => {
const client = new GuardianClient({ address: '127.0.0.1', port: 7500 });
await expect(client.listSecrets()).rejects.toThrow('No session token');
});
});

View File

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { VaultClient } from '../../../src/vault/client';
describe('VaultClient', () => {
it('constructs with config', () => {
const client = new VaultClient({
guardians: [{ address: '127.0.0.1', port: 7500 }],
hmacKey: new Uint8Array(32),
identityHex: 'a'.repeat(64),
});
expect(client).toBeDefined();
});
it('clearSessions does not throw', () => {
const client = new VaultClient({
guardians: [],
hmacKey: new Uint8Array(32),
identityHex: 'b'.repeat(64),
});
expect(() => client.clearSessions()).not.toThrow();
});
});

View File

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { adaptiveThreshold, writeQuorum } from '../../../src/vault/quorum';
describe('adaptiveThreshold', () => {
it('returns max(3, floor(N/3))', () => {
expect(adaptiveThreshold(3)).toBe(3);
expect(adaptiveThreshold(9)).toBe(3);
expect(adaptiveThreshold(12)).toBe(4);
expect(adaptiveThreshold(30)).toBe(10);
expect(adaptiveThreshold(100)).toBe(33);
});
it('minimum is 3', () => {
for (let n = 0; n <= 9; n++) {
expect(adaptiveThreshold(n)).toBeGreaterThanOrEqual(3);
}
});
it('monotonically non-decreasing', () => {
let prev = adaptiveThreshold(0);
for (let n = 1; n <= 255; n++) {
const current = adaptiveThreshold(n);
expect(current).toBeGreaterThanOrEqual(prev);
prev = current;
}
});
});
describe('writeQuorum', () => {
it('returns ceil(2N/3) for N >= 3', () => {
expect(writeQuorum(3)).toBe(2);
expect(writeQuorum(6)).toBe(4);
expect(writeQuorum(10)).toBe(7);
expect(writeQuorum(100)).toBe(67);
});
it('returns 0 for N=0', () => {
expect(writeQuorum(0)).toBe(0);
});
it('returns N for N <= 2', () => {
expect(writeQuorum(1)).toBe(1);
expect(writeQuorum(2)).toBe(2);
});
it('always > N/2 for N >= 3', () => {
for (let n = 3; n <= 255; n++) {
expect(writeQuorum(n)).toBeGreaterThan(n / 2);
}
});
it('never exceeds N', () => {
for (let n = 0; n <= 255; n++) {
expect(writeQuorum(n)).toBeLessThanOrEqual(n);
}
});
});