mirror of
https://github.com/DeBrosOfficial/network-ts-sdk.git
synced 2026-04-30 20:34:12 +00:00
Added vaults
This commit is contained in:
parent
3108ccd908
commit
195607809e
@ -1,21 +1,21 @@
|
||||
# Quick Start Guide for @network/sdk
|
||||
# Quick Start Guide for @debros/network-ts-sdk
|
||||
|
||||
## 5-Minute Setup
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
npm install @network/sdk
|
||||
npm install @debros/network-ts-sdk
|
||||
```
|
||||
|
||||
### 2. Create a Client
|
||||
|
||||
```typescript
|
||||
import { createClient } from "@network/sdk";
|
||||
import { createClient } from "@debros/network-ts-sdk";
|
||||
|
||||
const client = createClient({
|
||||
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
|
||||
cd ../network-ts-sdk
|
||||
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
|
||||
```
|
||||
|
||||
@ -124,7 +124,7 @@ await client.db.transaction([
|
||||
|
||||
### Error Handling
|
||||
```typescript
|
||||
import { SDKError } from "@network/sdk";
|
||||
import { SDKError } from "@debros/network-ts-sdk";
|
||||
|
||||
try {
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
191
README.md
191
README.md
@ -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
|
||||
|
||||
The SDK throws `SDKError` for all errors:
|
||||
|
||||
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@ -23,7 +23,12 @@
|
||||
"wasm",
|
||||
"serverless",
|
||||
"distributed",
|
||||
"gateway"
|
||||
"gateway",
|
||||
"vault",
|
||||
"secrets",
|
||||
"shamir",
|
||||
"encryption",
|
||||
"guardian"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -53,6 +58,8 @@
|
||||
"release:gh": "npm publish --registry=https://npm.pkg.github.com"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.3",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"isomorphic-ws": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@noble/ciphers':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3
|
||||
'@noble/hashes':
|
||||
specifier: ^1.4.0
|
||||
version: 1.8.0
|
||||
isomorphic-ws:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(ws@8.18.3)
|
||||
@ -419,6 +425,13 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
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':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -1848,6 +1861,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@noble/ciphers@0.5.3': {}
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
||||
66
src/index.ts
66
src/index.ts
@ -6,12 +6,14 @@ import { NetworkClient } from "./network/client";
|
||||
import { CacheClient } from "./cache/client";
|
||||
import { StorageClient } from "./storage/client";
|
||||
import { FunctionsClient, FunctionsClientConfig } from "./functions/client";
|
||||
import { VaultClient } from "./vault/client";
|
||||
import { WSClientConfig } from "./core/ws";
|
||||
import {
|
||||
StorageAdapter,
|
||||
MemoryStorage,
|
||||
LocalStorageAdapter,
|
||||
} from "./auth/types";
|
||||
import type { VaultConfig } from "./vault/types";
|
||||
|
||||
export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
||||
apiKey?: string;
|
||||
@ -25,6 +27,8 @@ export interface ClientConfig extends Omit<HttpClientConfig, "fetch"> {
|
||||
* Use this to trigger gateway failover at the application layer.
|
||||
*/
|
||||
onNetworkError?: NetworkErrorCallback;
|
||||
/** Configuration for the vault (distributed secrets store). */
|
||||
vaultConfig?: VaultConfig;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
@ -35,6 +39,7 @@ export interface Client {
|
||||
cache: CacheClient;
|
||||
storage: StorageClient;
|
||||
functions: FunctionsClient;
|
||||
vault: VaultClient | null;
|
||||
}
|
||||
|
||||
export function createClient(config: ClientConfig): Client {
|
||||
@ -68,6 +73,9 @@ export function createClient(config: ClientConfig): Client {
|
||||
const cache = new CacheClient(httpClient);
|
||||
const storage = new StorageClient(httpClient);
|
||||
const functions = new FunctionsClient(httpClient, config.functionsConfig);
|
||||
const vault = config.vaultConfig
|
||||
? new VaultClient(config.vaultConfig)
|
||||
: null;
|
||||
|
||||
return {
|
||||
auth,
|
||||
@ -77,6 +85,7 @@ export function createClient(config: ClientConfig): Client {
|
||||
cache,
|
||||
storage,
|
||||
functions,
|
||||
vault,
|
||||
};
|
||||
}
|
||||
|
||||
@ -133,3 +142,60 @@ export type {
|
||||
} from "./storage/client";
|
||||
export type { FunctionsClientConfig } from "./functions/client";
|
||||
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
98
src/vault/auth.ts
Normal 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
197
src/vault/client.ts
Normal 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
271
src/vault/crypto/aes.ts
Normal 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
42
src/vault/crypto/hkdf.ts
Normal 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
27
src/vault/crypto/index.ts
Normal 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
173
src/vault/crypto/shamir.ts
Normal 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
65
src/vault/index.ts
Normal 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
16
src/vault/quorum.ts
Normal 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);
|
||||
}
|
||||
94
src/vault/transport/fanout.ts
Normal file
94
src/vault/transport/fanout.ts
Normal 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!;
|
||||
}
|
||||
285
src/vault/transport/guardian.ts
Normal file
285
src/vault/transport/guardian.ts
Normal 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;
|
||||
}
|
||||
19
src/vault/transport/index.ts
Normal file
19
src/vault/transport/index.ts
Normal 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';
|
||||
101
src/vault/transport/types.ts
Normal file
101
src/vault/transport/types.ts
Normal 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
62
src/vault/types.ts
Normal 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;
|
||||
}
|
||||
20
tests/unit/vault-auth/auth.test.ts
Normal file
20
tests/unit/vault-auth/auth.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
99
tests/unit/vault-crypto/aes.test.ts
Normal file
99
tests/unit/vault-crypto/aes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
tests/unit/vault-crypto/hkdf.test.ts
Normal file
49
tests/unit/vault-crypto/hkdf.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
tests/unit/vault-crypto/shamir.test.ts
Normal file
80
tests/unit/vault-crypto/shamir.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
tests/unit/vault-transport/fanout.test.ts
Normal file
19
tests/unit/vault-transport/fanout.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
tests/unit/vault-transport/guardian.test.ts
Normal file
48
tests/unit/vault-transport/guardian.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
tests/unit/vault/client.test.ts
Normal file
22
tests/unit/vault/client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
57
tests/unit/vault/quorum.test.ts
Normal file
57
tests/unit/vault/quorum.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user