mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-17 04:44:12 +00:00
737 lines
31 KiB
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);
|
|
}
|