diff --git a/QUICKSTART.md b/QUICKSTART.md index 4f11e27..319b5be 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -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 diff --git a/README.md b/README.md index 8399735..6774611 100644 --- a/README.md +++ b/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 — 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( + "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: diff --git a/package.json b/package.json index bbd9301..c50ade6 100644 --- a/package.json +++ b/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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 491a319..6007fa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/index.ts b/src/index.ts index 0ee1839..415c494 100644 --- a/src/index.ts +++ b/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 { apiKey?: string; @@ -25,6 +27,8 @@ export interface ClientConfig extends Omit { * 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"; diff --git a/src/vault/auth.ts b/src/vault/auth.ts new file mode 100644 index 0000000..c991a20 --- /dev/null +++ b/src/vault/auth.ts @@ -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: `::` + */ +export class AuthClient { + private sessions = new Map(); + 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 { + 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; + } +} diff --git a/src/vault/client.ts b/src/vault/client.ts new file mode 100644 index 0000000..028658e --- /dev/null +++ b/src/vault/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/vault/crypto/aes.ts b/src/vault/crypto/aes.ts new file mode 100644 index 0000000..ff349ac --- /dev/null +++ b/src/vault/crypto/aes.ts @@ -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 + ); +} diff --git a/src/vault/crypto/hkdf.ts b/src/vault/crypto/hkdf.ts new file mode 100644 index 0000000..f2ba542 --- /dev/null +++ b/src/vault/crypto/hkdf.ts @@ -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); +} diff --git a/src/vault/crypto/index.ts b/src/vault/crypto/index.ts new file mode 100644 index 0000000..1ef3ceb --- /dev/null +++ b/src/vault/crypto/index.ts @@ -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'; diff --git a/src/vault/crypto/shamir.ts b/src/vault/crypto/shamir.ts new file mode 100644 index 0000000..63a2c3a --- /dev/null +++ b/src/vault/crypto/shamir.ts @@ -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(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; diff --git a/src/vault/index.ts b/src/vault/index.ts new file mode 100644 index 0000000..b0eecdc --- /dev/null +++ b/src/vault/index.ts @@ -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'; diff --git a/src/vault/quorum.ts b/src/vault/quorum.ts new file mode 100644 index 0000000..f621c33 --- /dev/null +++ b/src/vault/quorum.ts @@ -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); +} diff --git a/src/vault/transport/fanout.ts b/src/vault/transport/fanout.ts new file mode 100644 index 0000000..fce3786 --- /dev/null +++ b/src/vault/transport/fanout.ts @@ -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( + guardians: GuardianEndpoint[], + operation: (client: GuardianClient) => Promise, +): Promise[]> { + 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; + }), + ); + + 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( + guardians: GuardianEndpoint[], + operation: (client: GuardianClient, index: number) => Promise, +): Promise[]> { + 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; + }), + ); + + 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(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, 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(fn: () => Promise, attempts = 3): Promise { + 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!; +} diff --git a/src/vault/transport/guardian.ts b/src/vault/transport/guardian.ts new file mode 100644 index 0000000..66b06e7 --- /dev/null +++ b/src/vault/transport/guardian.ts @@ -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 { + return this.get('/v1/vault/health'); + } + + /** GET /v1/vault/status */ + async status(): Promise { + return this.get('/v1/vault/status'); + } + + /** GET /v1/vault/guardians */ + async guardians(): Promise { + return this.get('/v1/vault/guardians'); + } + + /** POST /v1/vault/push — store a share (V1). */ + async push(identity: string, share: Uint8Array): Promise { + return this.post('/v1/vault/push', { + identity, + share: uint8ToBase64(share), + }); + } + + /** POST /v1/vault/pull — retrieve a share (V1). */ + async pull(identity: string): Promise { + const resp = await this.post('/v1/vault/pull', { identity }); + return base64ToUint8(resp.share); + } + + /** Check if this guardian is reachable. */ + async isReachable(): Promise { + 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 { + return this.post('/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 { + return this.post('/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 { + return this.authedRequest('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('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 { + return this.authedRequest('DELETE', `/v2/vault/secrets/${encodeURIComponent(name)}`); + } + + /** GET /v2/vault/secrets — list all secrets. Requires session token. */ + async listSecrets(): Promise { + return this.authedRequest('GET', '/v2/vault/secrets'); + } + + // ── Internal HTTP methods ─────────────────────────────────────────── + + private async authedRequest(method: string, path: string, body?: unknown): Promise { + 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 = { + '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(path: string): Promise { + 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(path: string, body: unknown): Promise { + 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; +} diff --git a/src/vault/transport/index.ts b/src/vault/transport/index.ts new file mode 100644 index 0000000..ebdcfeb --- /dev/null +++ b/src/vault/transport/index.ts @@ -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'; diff --git a/src/vault/transport/types.ts b/src/vault/transport/types.ts new file mode 100644 index 0000000..cb740b5 --- /dev/null +++ b/src/vault/transport/types.ts @@ -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 { + endpoint: GuardianEndpoint; + result: T | null; + error: string | null; + errorCode?: GuardianErrorCode; +} diff --git a/src/vault/types.ts b/src/vault/types.ts new file mode 100644 index 0000000..febb30d --- /dev/null +++ b/src/vault/types.ts @@ -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; +} diff --git a/tests/unit/vault-auth/auth.test.ts b/tests/unit/vault-auth/auth.test.ts new file mode 100644 index 0000000..0b3f1dc --- /dev/null +++ b/tests/unit/vault-auth/auth.test.ts @@ -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([]); + }); +}); diff --git a/tests/unit/vault-crypto/aes.test.ts b/tests/unit/vault-crypto/aes.test.ts new file mode 100644 index 0000000..0757a0a --- /dev/null +++ b/tests/unit/vault-crypto/aes.test.ts @@ -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); + }); +}); diff --git a/tests/unit/vault-crypto/hkdf.test.ts b/tests/unit/vault-crypto/hkdf.test.ts new file mode 100644 index 0000000..f242f59 --- /dev/null +++ b/tests/unit/vault-crypto/hkdf.test.ts @@ -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); + }); +}); diff --git a/tests/unit/vault-crypto/shamir.test.ts b/tests/unit/vault-crypto/shamir.test.ts new file mode 100644 index 0000000..d5941ad --- /dev/null +++ b/tests/unit/vault-crypto/shamir.test.ts @@ -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); + }); +}); diff --git a/tests/unit/vault-transport/fanout.test.ts b/tests/unit/vault-transport/fanout.test.ts new file mode 100644 index 0000000..852b9ae --- /dev/null +++ b/tests/unit/vault-transport/fanout.test.ts @@ -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((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'); + }); +}); diff --git a/tests/unit/vault-transport/guardian.test.ts b/tests/unit/vault-transport/guardian.test.ts new file mode 100644 index 0000000..879e41b --- /dev/null +++ b/tests/unit/vault-transport/guardian.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/vault/client.test.ts b/tests/unit/vault/client.test.ts new file mode 100644 index 0000000..0850528 --- /dev/null +++ b/tests/unit/vault/client.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/vault/quorum.test.ts b/tests/unit/vault/quorum.test.ts new file mode 100644 index 0000000..e7913cf --- /dev/null +++ b/tests/unit/vault/quorum.test.ts @@ -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); + } + }); +});