orama/vault/src/test_integration.zig

737 lines
31 KiB
Zig

/// Integration tests — tests the full system working together across modules.
///
/// These tests verify end-to-end flows that span multiple subsystems:
/// SSS split/combine, AES-256-GCM encryption, file_store with HMAC integrity,
/// Merkle commitment verification, proactive re-sharing, and quorum logic.
const std = @import("std");
// ── Module imports ──────────────────────────────────────────────────────────
const split_mod = @import("sss/split.zig");
const combine_mod = @import("sss/combine.zig");
const commitment_mod = @import("sss/commitment.zig");
const reshare_mod = @import("sss/reshare.zig");
const types = @import("sss/types.zig");
const aes = @import("crypto/aes.zig");
const hmac = @import("crypto/hmac.zig");
const hkdf = @import("crypto/hkdf.zig");
const file_store = @import("storage/file_store.zig");
const quorum = @import("membership/quorum.zig");
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Creates a temporary directory and returns its real path.
/// Caller must call cleanup() on the returned tmpDir.
fn makeTmpDir(buf: []u8) struct { dir: std.testing.TmpDir, path: []const u8 } {
var tmp = std.testing.tmpDir(.{});
const path = tmp.dir.realpath(".", buf) catch @panic("failed to get tmp realpath");
return .{ .dir = tmp, .path = path };
}
/// Generates a random byte buffer of `len` bytes.
fn randomBytes(allocator: std.mem.Allocator, len: usize) ![]u8 {
const buf = try allocator.alloc(u8, len);
std.crypto.random.bytes(buf);
return buf;
}
/// Hex-encode a byte slice into a stack buffer suitable for identity hashes.
fn hexEncode(bytes: []const u8, out: []u8) []const u8 {
const charset = "0123456789abcdef";
var i: usize = 0;
for (bytes) |b| {
out[i] = charset[b >> 4];
out[i + 1] = charset[b & 0x0F];
i += 2;
}
return out[0..i];
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. Full vault lifecycle simulation
// ═══════════════════════════════════════════════════════════════════════════
test "integration: full vault lifecycle — split, store, read, combine" {
const allocator = std.testing.allocator;
// Generate a random 1KB secret (simulating encrypted vault data)
const secret = try randomBytes(allocator, 1024);
defer allocator.free(secret);
// Split with N=7, K=3 (7 guardian nodes)
const share_set = try split_mod.split(allocator, secret, 7, 3);
defer share_set.deinit(allocator);
// Store each share to a separate temp directory using file_store
var tmp_dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp = makeTmpDir(&tmp_dir_buf);
var tmp_dir = tmp.dir;
defer tmp_dir.cleanup();
const tmp_path = tmp.path;
const integrity_key = "vault-integration-test-key-32b!!";
// Write each share to disk with a unique identity
for (share_set.shares, 0..) |share, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(i)), share.x, 0xAB, 0xCD };
const identity = hexEncode(&id_byte, &id_buf);
try file_store.writeShare(tmp_path, identity, share.y, integrity_key, allocator);
}
// Read shares back and verify HMAC integrity
for (share_set.shares, 0..) |share, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(i)), share.x, 0xAB, 0xCD };
const identity = hexEncode(&id_byte, &id_buf);
const read_data = try file_store.readShare(tmp_path, identity, integrity_key, allocator);
defer allocator.free(read_data);
try std.testing.expectEqualSlices(u8, share.y, read_data);
}
// Combine using multiple different K-subsets and verify reconstruction
// Subset 1: shares 0, 1, 2
{
const subset = [_]types.Share{ share_set.shares[0], share_set.shares[1], share_set.shares[2] };
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// Subset 2: shares 2, 4, 6
{
const subset = [_]types.Share{ share_set.shares[2], share_set.shares[4], share_set.shares[6] };
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// Subset 3: shares 1, 3, 5
{
const subset = [_]types.Share{ share_set.shares[1], share_set.shares[3], share_set.shares[5] };
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 2. Multi-guardian failure tolerance
// ═══════════════════════════════════════════════════════════════════════════
test "integration: multi-guardian failure tolerance — N=10, K=4" {
const allocator = std.testing.allocator;
const secret = try randomBytes(allocator, 256);
defer allocator.free(secret);
const n: u8 = 10;
const k: u8 = 4;
const share_set = try split_mod.split(allocator, secret, n, k);
defer share_set.deinit(allocator);
// Recovery with exactly K shares (indices 0,3,5,9)
{
const subset = [_]types.Share{
share_set.shares[0],
share_set.shares[3],
share_set.shares[5],
share_set.shares[9],
};
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// Recovery with K+1 shares
{
const recovered = try combine_mod.combine(allocator, share_set.shares[0..5]);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// Recovery with K+2 shares
{
const recovered = try combine_mod.combine(allocator, share_set.shares[0..6]);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// Recovery with all N shares
{
const recovered = try combine_mod.combine(allocator, share_set.shares);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// K-1 shares should NOT reconstruct the original secret
// (with overwhelming probability for a 256-byte secret)
{
const subset = [_]types.Share{
share_set.shares[0],
share_set.shares[1],
share_set.shares[2],
};
const result = try combine_mod.combine(allocator, &subset);
defer {
@memset(result, 0);
allocator.free(result);
}
try std.testing.expect(!std.mem.eql(u8, secret, result));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 3. Storage integrity under tampering
// ═══════════════════════════════════════════════════════════════════════════
test "integration: storage integrity under tampering" {
const allocator = std.testing.allocator;
var tmp_dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp = makeTmpDir(&tmp_dir_buf);
var tmp_dir = tmp.dir;
defer tmp_dir.cleanup();
const tmp_path = tmp.path;
const identity = "tampertest01";
const share_data = try randomBytes(allocator, 512);
defer allocator.free(share_data);
const integrity_key = "tamper-integrity-key-32-bytes!!!";
// Write and read back successfully
try file_store.writeShare(tmp_path, identity, share_data, integrity_key, allocator);
{
const read_data = try file_store.readShare(tmp_path, identity, integrity_key, allocator);
defer allocator.free(read_data);
try std.testing.expectEqualSlices(u8, share_data, read_data);
}
// Tamper with the share.bin file (flip a byte)
const share_path = try std.fmt.allocPrint(allocator, "{s}/shares/{s}/share.bin", .{ tmp_path, identity });
defer allocator.free(share_path);
{
const file = try std.fs.cwd().openFile(share_path, .{ .mode = .write_only });
defer file.close();
// Write a single modified byte at the beginning
const tampered_byte = [_]u8{share_data[0] ^ 0xFF};
try file.writeAll(&tampered_byte);
}
// readShare should now fail with IntegrityCheckFailed
try std.testing.expectError(
file_store.StoreError.IntegrityCheckFailed,
file_store.readShare(tmp_path, identity, integrity_key, allocator),
);
// Re-write the original data and verify it works again
try file_store.writeShare(tmp_path, identity, share_data, integrity_key, allocator);
{
const read_data = try file_store.readShare(tmp_path, identity, integrity_key, allocator);
defer allocator.free(read_data);
try std.testing.expectEqualSlices(u8, share_data, read_data);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 4. Shamir + AES-GCM full encryption round-trip
// ═══════════════════════════════════════════════════════════════════════════
test "integration: Shamir + AES-256-GCM encryption round-trip" {
const allocator = std.testing.allocator;
// Generate a random AES key
const key = aes.generateKey();
// Encrypt a payload
const plaintext = "This is sensitive vault data that must survive split/combine via Shamir + AES-GCM.";
const encrypted = try aes.encrypt(allocator, plaintext, key);
defer allocator.free(@constCast(encrypted.ciphertext));
// Use the ciphertext + nonce + tag as the "secret" for Shamir split.
// Concatenate: nonce (12) || tag (16) || ciphertext (N)
const secret_len = aes.NONCE_SIZE + aes.TAG_SIZE + encrypted.ciphertext.len;
const secret = try allocator.alloc(u8, secret_len);
defer allocator.free(secret);
@memcpy(secret[0..aes.NONCE_SIZE], &encrypted.nonce);
@memcpy(secret[aes.NONCE_SIZE .. aes.NONCE_SIZE + aes.TAG_SIZE], &encrypted.tag);
@memcpy(secret[aes.NONCE_SIZE + aes.TAG_SIZE ..], encrypted.ciphertext);
// Split into 5 shares with K=3
const share_set = try split_mod.split(allocator, secret, 5, 3);
defer share_set.deinit(allocator);
// Combine K=3 shares back
const subset = [_]types.Share{ share_set.shares[0], share_set.shares[2], share_set.shares[4] };
const recovered_secret = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered_secret, 0);
allocator.free(recovered_secret);
}
// Extract nonce, tag, ciphertext from recovered secret
var recovered_nonce: [aes.NONCE_SIZE]u8 = undefined;
@memcpy(&recovered_nonce, recovered_secret[0..aes.NONCE_SIZE]);
var recovered_tag: [aes.TAG_SIZE]u8 = undefined;
@memcpy(&recovered_tag, recovered_secret[aes.NONCE_SIZE .. aes.NONCE_SIZE + aes.TAG_SIZE]);
const recovered_ct = recovered_secret[aes.NONCE_SIZE + aes.TAG_SIZE ..];
const recovered_encrypted = aes.EncryptedData{
.ciphertext = recovered_ct,
.nonce = recovered_nonce,
.tag = recovered_tag,
};
// Decrypt with original key
const decrypted = try aes.decrypt(allocator, recovered_encrypted, key);
defer {
@memset(decrypted, 0);
allocator.free(decrypted);
}
try std.testing.expectEqualSlices(u8, plaintext, decrypted);
}
// ═══════════════════════════════════════════════════════════════════════════
// 5. Adaptive threshold + quorum consistency
// ═══════════════════════════════════════════════════════════════════════════
test "integration: adaptive threshold + quorum consistency" {
// readQuorum is the Shamir threshold K = max(3, floor(N/3))
// writeQuorum is ceil(2/3 * N)
//
// Invariants that always hold:
// - threshold >= 3 (minimum security)
// - writeQuorum > N/2 (majority) for N >= 3
//
// For production-sized clusters (N >= 9), an additional invariant holds:
// - threshold <= writeQuorum (enough shares stored for reconstruction)
// For small clusters (N < 9), the minimum threshold of 3 can exceed the
// write quorum. This is a known tradeoff: small clusters require ALL nodes
// for write+read to succeed, which is acceptable for tiny deployments.
const test_values = [_]usize{ 3, 5, 7, 10, 14, 50, 100 };
for (test_values) |n| {
const threshold = quorum.readQuorum(n);
const wq = quorum.writeQuorum(n);
// threshold >= 3 (minimum security)
try std.testing.expect(threshold >= 3);
// writeQuorum > N/2 (majority) for N >= 3
try std.testing.expect(wq > n / 2);
// For production clusters (N >= 9), threshold must fit within write quorum
if (n >= 9) {
try std.testing.expect(threshold <= wq);
}
// writeQuorum + threshold >= N (read+write overlap guarantees consistency)
// This is the fundamental quorum intersection property: any write set
// and any read set must share at least one node.
try std.testing.expect(wq + threshold >= n);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 6. Large payload round-trip
// ═══════════════════════════════════════════════════════════════════════════
test "integration: large payload round-trip (100KB)" {
const allocator = std.testing.allocator;
// Generate a 100KB random payload
const payload_size = 100 * 1024;
const secret = try randomBytes(allocator, payload_size);
defer allocator.free(secret);
// Split into 14 shares with K=5
const share_set = try split_mod.split(allocator, secret, 14, 5);
defer share_set.deinit(allocator);
// Store all shares to disk via file_store
var tmp_dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp = makeTmpDir(&tmp_dir_buf);
var tmp_dir = tmp.dir;
defer tmp_dir.cleanup();
const tmp_path = tmp.path;
const integrity_key = "large-payload-integrity-key-32b!";
for (share_set.shares, 0..) |share, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(i)), share.x, 0x11, 0x22 };
const identity = hexEncode(&id_byte, &id_buf);
try file_store.writeShare(tmp_path, identity, share.y, integrity_key, allocator);
}
// Read back any 5 shares (indices 2, 5, 7, 10, 13)
const read_indices = [_]usize{ 2, 5, 7, 10, 13 };
var read_shares: [5]types.Share = undefined;
var read_bufs: [5][]u8 = undefined;
for (read_indices, 0..) |idx, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(idx)), share_set.shares[idx].x, 0x11, 0x22 };
const identity = hexEncode(&id_byte, &id_buf);
const data = try file_store.readShare(tmp_path, identity, integrity_key, allocator);
read_bufs[i] = data;
read_shares[i] = types.Share{
.x = share_set.shares[idx].x,
.y = data,
};
}
defer {
for (&read_bufs) |buf| allocator.free(buf);
}
// Combine and verify exact match
const recovered = try combine_mod.combine(allocator, &read_shares);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqual(payload_size, recovered.len);
try std.testing.expectEqualSlices(u8, secret, recovered);
}
// ═══════════════════════════════════════════════════════════════════════════
// 7. Commitment tree cross-check
// ═══════════════════════════════════════════════════════════════════════════
test "integration: Merkle commitment tree — build, prove, verify, tamper" {
const allocator = std.testing.allocator;
// Create a set of shares via Shamir split
const secret = try randomBytes(allocator, 64);
defer allocator.free(secret);
const share_set = try split_mod.split(allocator, secret, 7, 3);
defer share_set.deinit(allocator);
// Build the Merkle commitment tree
const tree = try commitment_mod.buildTree(allocator, share_set.shares);
defer tree.deinit(allocator);
try std.testing.expectEqual(@as(usize, 7), tree.leaves.len);
// Verify that each share's Merkle proof validates against the root
for (share_set.shares, 0..) |share, i| {
const proof = try commitment_mod.generateProof(allocator, share_set.shares, i);
defer proof.deinit(allocator);
try std.testing.expect(commitment_mod.verifyProof(share, proof, tree.root));
}
// Tamper with one share and verify the proof fails
{
const proof_0 = try commitment_mod.generateProof(allocator, share_set.shares, 0);
defer proof_0.deinit(allocator);
// Create a tampered share with modified y data
const tampered_y = try allocator.alloc(u8, share_set.shares[0].y.len);
defer allocator.free(tampered_y);
@memcpy(tampered_y, share_set.shares[0].y);
tampered_y[0] ^= 0xFF; // flip a byte
const tampered_share = types.Share{
.x = share_set.shares[0].x,
.y = tampered_y,
};
// Proof should fail for the tampered share
try std.testing.expect(!commitment_mod.verifyProof(tampered_share, proof_0, tree.root));
}
// Verify wrong root also fails
{
const proof_0 = try commitment_mod.generateProof(allocator, share_set.shares, 0);
defer proof_0.deinit(allocator);
const wrong_root = [_]u8{0xDE} ** 32;
try std.testing.expect(!commitment_mod.verifyProof(share_set.shares[0], proof_0, wrong_root));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 8. Reshare round-trip
// ═══════════════════════════════════════════════════════════════════════════
test "integration: proactive reshare — new shares reconstruct, old+new mixed fails" {
const allocator = std.testing.allocator;
const secret = [_]u8{ 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160 };
const n: u8 = 5;
const k: u8 = 3;
// Step 1: Split the secret into original shares
const share_set = try split_mod.split(allocator, &secret, n, k);
defer share_set.deinit(allocator);
// Step 2: Each guardian generates reshare deltas
var all_deltas: [5][]reshare_mod.ReshareDelta = undefined;
for (0..n) |i| {
all_deltas[i] = try reshare_mod.generateDeltas(
allocator,
@as(u8, @truncate(i)) + 1,
secret.len,
n,
k,
);
}
defer {
for (0..n) |i| {
for (all_deltas[i]) |*d| d.deinit(allocator);
allocator.free(all_deltas[i]);
}
}
// Step 3: Each guardian applies deltas to get new shares
var new_shares: [5]types.Share = undefined;
for (0..n) |j| {
var deltas_for_j: [5]reshare_mod.ReshareDelta = undefined;
for (0..n) |i| {
deltas_for_j[i] = all_deltas[i][j];
}
new_shares[j] = try reshare_mod.applyDeltas(
allocator,
share_set.shares[j],
&deltas_for_j,
);
}
defer {
for (&new_shares) |*s| s.deinit(allocator);
}
// Step 4: Verify new shares reconstruct the same secret
// Test multiple subsets of new shares
{
const subset = [_]types.Share{ new_shares[0], new_shares[2], new_shares[4] };
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, &secret, recovered);
}
{
const subset = [_]types.Share{ new_shares[1], new_shares[3], new_shares[4] };
const recovered = try combine_mod.combine(allocator, &subset);
defer {
@memset(recovered, 0);
allocator.free(recovered);
}
try std.testing.expectEqualSlices(u8, &secret, recovered);
}
// Step 5: Verify old + new shares mixed does NOT reconstruct the secret
// Mix: 1 new share + 2 old shares from different epoch
{
const mixed = [_]types.Share{ new_shares[0], share_set.shares[2], share_set.shares[4] };
const result = try combine_mod.combine(allocator, &mixed);
defer {
@memset(result, 0);
allocator.free(result);
}
// With a 16-byte secret, the probability of an accidental match is ~1/2^128
try std.testing.expect(!std.mem.eql(u8, &secret, result));
}
// Mix: 2 new shares + 1 old share
{
const mixed = [_]types.Share{ new_shares[0], new_shares[1], share_set.shares[3] };
const result = try combine_mod.combine(allocator, &mixed);
defer {
@memset(result, 0);
allocator.free(result);
}
try std.testing.expect(!std.mem.eql(u8, &secret, result));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 9. HKDF-derived keys for domain separation in vault operations
// ═══════════════════════════════════════════════════════════════════════════
test "integration: HKDF domain separation — different contexts yield different keys" {
// Simulate deriving multiple keys from a single seed for different vault operations:
// one for AES encryption, one for HMAC integrity, one for share commitment.
const seed = "master-vault-seed-from-wallet-signature-1234567890";
var aes_key: [32]u8 = undefined;
var hmac_key: [32]u8 = undefined;
var commit_key: [32]u8 = undefined;
hkdf.deriveKey(&aes_key, seed, "orama-vault-v1", "aes-encryption-key");
hkdf.deriveKey(&hmac_key, seed, "orama-vault-v1", "hmac-integrity-key");
hkdf.deriveKey(&commit_key, seed, "orama-vault-v1", "commitment-key");
// All three keys must be different
try std.testing.expect(!std.mem.eql(u8, &aes_key, &hmac_key));
try std.testing.expect(!std.mem.eql(u8, &aes_key, &commit_key));
try std.testing.expect(!std.mem.eql(u8, &hmac_key, &commit_key));
// Use AES key to encrypt, HMAC key for integrity check on the ciphertext
const allocator = std.testing.allocator;
const plaintext = "vault entry: private key material";
const encrypted = try aes.encrypt(allocator, plaintext, aes_key);
defer allocator.free(@constCast(encrypted.ciphertext));
// Compute HMAC of ciphertext with the HMAC-specific key
const mac = hmac.compute(&hmac_key, encrypted.ciphertext);
// Verify HMAC passes with correct key
try std.testing.expect(hmac.verify(&hmac_key, encrypted.ciphertext, mac));
// Verify HMAC fails with wrong key
try std.testing.expect(!hmac.verify(&aes_key, encrypted.ciphertext, mac));
// Decrypt successfully with the right AES key
const decrypted = try aes.decrypt(allocator, encrypted, aes_key);
defer {
@memset(decrypted, 0);
allocator.free(decrypted);
}
try std.testing.expectEqualSlices(u8, plaintext, decrypted);
}
// ═══════════════════════════════════════════════════════════════════════════
// 10. Full pipeline: HKDF -> AES -> Shamir -> Store -> Read -> Combine -> Decrypt
// ═══════════════════════════════════════════════════════════════════════════
test "integration: full pipeline — derive keys, encrypt, split, store, reconstruct, decrypt" {
const allocator = std.testing.allocator;
// 1. Derive keys from a wallet seed
const wallet_seed = "user-wallet-signature-bytes-here-at-least-32-bytes!!";
var encryption_key: [aes.KEY_SIZE]u8 = undefined;
hkdf.deriveKey(&encryption_key, wallet_seed, "orama-vault-v1", "encryption");
var integrity_key_buf: [32]u8 = undefined;
hkdf.deriveKey(&integrity_key_buf, wallet_seed, "orama-vault-v1", "integrity");
// 2. Encrypt the vault payload
const vault_data = "{ \"privateKey\": \"0xDEADBEEF...\", \"mnemonic\": \"abandon abandon ...\" }";
const encrypted = try aes.encrypt(allocator, vault_data, encryption_key);
defer allocator.free(@constCast(encrypted.ciphertext));
// 3. Package ciphertext + nonce + tag as the Shamir secret
const secret_len = aes.NONCE_SIZE + aes.TAG_SIZE + encrypted.ciphertext.len;
const shamir_secret = try allocator.alloc(u8, secret_len);
defer allocator.free(shamir_secret);
@memcpy(shamir_secret[0..aes.NONCE_SIZE], &encrypted.nonce);
@memcpy(shamir_secret[aes.NONCE_SIZE .. aes.NONCE_SIZE + aes.TAG_SIZE], &encrypted.tag);
@memcpy(shamir_secret[aes.NONCE_SIZE + aes.TAG_SIZE ..], encrypted.ciphertext);
// 4. Split into shares
const n: u8 = 7;
const k: u8 = 3;
const share_set = try split_mod.split(allocator, shamir_secret, n, k);
defer share_set.deinit(allocator);
// 5. Build Merkle commitment tree (for cross-guardian verification)
const tree = try commitment_mod.buildTree(allocator, share_set.shares);
defer tree.deinit(allocator);
// 6. Store shares to disk
var tmp_dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp = makeTmpDir(&tmp_dir_buf);
var tmp_dir = tmp.dir;
defer tmp_dir.cleanup();
const tmp_path = tmp.path;
for (share_set.shares, 0..) |share, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(i)), share.x, 0xFE, 0xED };
const identity = hexEncode(&id_byte, &id_buf);
try file_store.writeShare(tmp_path, identity, share.y, &integrity_key_buf, allocator);
}
// 7. Simulate recovery: read K shares from disk, verify commitments, combine
const recovery_indices = [_]usize{ 1, 4, 6 };
var recovery_shares: [3]types.Share = undefined;
var recovery_bufs: [3][]u8 = undefined;
for (recovery_indices, 0..) |idx, i| {
var id_buf: [8]u8 = undefined;
const id_byte = [_]u8{ @as(u8, @truncate(idx)), share_set.shares[idx].x, 0xFE, 0xED };
const identity = hexEncode(&id_byte, &id_buf);
const data = try file_store.readShare(tmp_path, identity, &integrity_key_buf, allocator);
recovery_bufs[i] = data;
recovery_shares[i] = types.Share{
.x = share_set.shares[idx].x,
.y = data,
};
// Verify Merkle proof for each recovered share
const proof = try commitment_mod.generateProof(allocator, share_set.shares, idx);
defer proof.deinit(allocator);
try std.testing.expect(commitment_mod.verifyProof(recovery_shares[i], proof, tree.root));
}
defer {
for (&recovery_bufs) |buf| allocator.free(buf);
}
// 8. Combine shares to recover the Shamir secret
const recovered_secret = try combine_mod.combine(allocator, &recovery_shares);
defer {
@memset(recovered_secret, 0);
allocator.free(recovered_secret);
}
try std.testing.expectEqualSlices(u8, shamir_secret, recovered_secret);
// 9. Extract nonce/tag/ciphertext and decrypt
var rec_nonce: [aes.NONCE_SIZE]u8 = undefined;
@memcpy(&rec_nonce, recovered_secret[0..aes.NONCE_SIZE]);
var rec_tag: [aes.TAG_SIZE]u8 = undefined;
@memcpy(&rec_tag, recovered_secret[aes.NONCE_SIZE .. aes.NONCE_SIZE + aes.TAG_SIZE]);
const rec_ct = recovered_secret[aes.NONCE_SIZE + aes.TAG_SIZE ..];
const rec_encrypted = aes.EncryptedData{
.ciphertext = rec_ct,
.nonce = rec_nonce,
.tag = rec_tag,
};
const decrypted = try aes.decrypt(allocator, rec_encrypted, encryption_key);
defer {
@memset(decrypted, 0);
allocator.free(decrypted);
}
try std.testing.expectEqualSlices(u8, vault_data, decrypted);
}