//! Proof of Path (RFC-0120) //! //! "Don't scan the graph. Prove the path." //! //! Sender includes O(depth) proof: [Sender->A, A->B, B->Receiver] //! Receiver verifies in O(depth) checking only adjacent signatures. //! //! Enables Kenya-class devices to participate in the trust graph without having a huge database. //! //! Wire Format (CBOR-like structure): //! [ //! hops: [[32]u8], // List of DIDs in chain //! signatures: [[64]u8], // Sigs verifying links //! timestamp: u64, // Creation time (replay protection) //! expires_at: u64 // Path expiration //! ] const std = @import("std"); const trust_graph = @import("trust_graph"); const time = @import("time"); const soulkey = @import("soulkey"); pub const PathVerdict = enum { /// Path is valid and active valid, /// Path explicitly starts/ends with wrong DIDs invalid_endpoints, /// Path expired expired, /// Path exceeds max trust depth (3 by default) too_deep, /// A link in the chain is broken (sig failure) broken_link, /// Signer revoked the trust edge revoked, /// Use for replay attacks replay, }; /// Proof of Path structure /// "I am Sender. Here is a chain of signatures proving I am trusted by you." pub const ProofOfPath = struct { /// The trust chain: [Sender, Hop1, Hop2, ..., Receiver] hops: std.ArrayListUnmanaged([32]u8), /// Signatures proving each link: /// signatures[i] = Sig_{hops[i+1]}(hops[i] + CONTEXT) /// The receiver signs for Hop N-1, Hop N-1 signs for N-2... /// NOTE: RFC-0120 implies Trust Edges are signed credentials. /// Implementation: TrustEdge struct in graph is implicit proof. /// This struct carries the *signatures* of those TrustEdges if they are signed. /// For QVL v1 (local graph), PoP is a path reconstruction from local state or /// a transmitted bundle of Signed Trust Edges. /// /// REVISION for v1: /// Since we use CompactTrustGraph (local state), PoP is primarily for *exporting* /// a path to a receiver who *doesn't* know the sender. /// The signatures here must be: /// Link A->B: "I, B, trust A" (Signed by B) signatures: std.ArrayListUnmanaged([64]u8), /// Timestamp path was generated timestamp: time.SovereignTimestamp, /// When this proof expires (min of all edge expirations) expires_at: time.SovereignTimestamp, allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) ProofOfPath { return .{ .hops = .{}, .signatures = .{}, .timestamp = time.SovereignTimestamp.now(), // Default, update later .expires_at = time.SovereignTimestamp.now().addSeconds(3600), // Default 1h .allocator = allocator, }; } pub fn deinit(self: *ProofOfPath) void { self.hops.deinit(self.allocator); self.signatures.deinit(self.allocator); } /// Construct a ProofOfPath (Sender Side) /// Finds path in local graph and bundles it. /// NOTE: In v1, we assume we have the signatures or can generate them if we own the keys. /// Realistically, Sender constructs path from [Sender -> ... -> Receiver] /// But wait, Trust flows Receiver -> Sender ("Receiver trusts Sender"). /// So the path is [Receiver -> A -> B -> Sender]. /// Sender needs to find: "Who does Receiver trust? A. Do I know A? No. Do I know B who knows A?" /// /// RFC-0120 S4.3.3: "Sender constructs path" /// This implies Sender knows the Trust Graph topology. /// If Graph is Private, Sender *cannot* know Receiver's trustees. /// /// RESOLUTION: PoP works on *Public/Friends* edges or previously exchanged credentials. /// For v1 simulation: We assume Sender has a view of the graph that allows finding the path. pub fn construct( allocator: std.mem.Allocator, sender_did: [32]u8, receiver_did: [32]u8, graph: *const trust_graph.CompactTrustGraph, ) !?ProofOfPath { // Direction of Trust: Receiver -> ... -> Sender // Sender needs to prove: "Receiver trusts X, X trusts Y, Y trusts ME." // So we look for path: Receiver -> Sender const path_indices = graph.findPath(receiver_did, sender_did) orelse return null; defer allocator.free(path_indices); var pop = ProofOfPath.init(allocator); // Default timestamp/expire // Convert indices to DIDs // Path: [Receiver(IDX), Hop1(IDX), ..., Sender(IDX)] for (path_indices) |idx| { const did = graph.getDid(idx) orelse return error.NodeNotFound; try pop.hops.append(allocator, did); } // TODO: Retrieve specific edge signatures. // For v1, we mock signatures or omit if relying on local graph verification. // If the checking node HAS the graph (Chapter mode), it just calls verifyLocal(path). // If transmitting to a stranger, we need actual crypto sigs. // We will implement `signatures` placeholders for now. // Fill mock signatures for structure validity const sig_count = if (path_indices.len > 0) path_indices.len - 1 else 0; for (0..sig_count) |_| { var sig: [64]u8 = undefined; @memset(&sig, 0xEE); // Mock sig try pop.signatures.append(allocator, sig); } return pop; } /// Verify a received path against local Trust Graph (Receiver Side) /// "Did the Sender provide a valid path that I can verify locally?" /// Complexity: O(depth) - we just check the hops exist and link up. pub fn verify( self: *const ProofOfPath, expected_receiver: [32]u8, expected_sender: [32]u8, graph: *const trust_graph.CompactTrustGraph, ) PathVerdict { if (self.hops.items.len < 2) return .invalid_endpoints; // 1. Verify Endpoints // Hops[0] should be Receiver (Trust Anchor) // Hops[Last] should be Sender (Trust Target) // Direction: Receiver -> A -> B -> Sender if (!std.mem.eql(u8, &self.hops.items[0], &expected_receiver)) return .invalid_endpoints; if (!std.mem.eql(u8, &self.hops.items[self.hops.items.len - 1], &expected_sender)) return .invalid_endpoints; // 2. Verify Expiration if (self.expires_at.isBefore(time.SovereignTimestamp.now())) return .expired; // 3. Verify Depth if (self.hops.items.len - 1 > graph.config.max_trust_depth) return .too_deep; // 4. Verify Links (O(Depth)) // We walk the path provided by Sender and check if our Local Graph agrees with the edges. // (Or verify signatures if we implemented full credential verification logic) var i: usize = 0; while (i < self.hops.items.len - 1) : (i += 1) { const truster_did = self.hops.items[i]; const trustee_did = self.hops.items[i + 1]; // Check if Truster -> Trustee exists in our view of the graph // Ideally, we verify the SIGNATURE here. // For v1 Local/Chapter verification: if (!graph.hasDirectTrustByDid(truster_did, trustee_did)) { return .broken_link; } } return .valid; } /// Serialize to wire byte array (simple encoding) pub fn serialize(self: *const ProofOfPath, allocator: std.mem.Allocator) ![]u8 { var list = std.ArrayListUnmanaged(u8){}; defer list.deinit(allocator); const writer = list.writer(allocator); // Count (u8) try writer.writeInt(u8, @intCast(self.hops.items.len), .little); // Hops (32 bytes each) for (self.hops.items) |hop| { try writer.writeAll(&hop); } // Sigs (64 bytes each) try writer.writeInt(u8, @intCast(self.signatures.items.len), .little); for (self.signatures.items) |sig| { try writer.writeAll(&sig); } // Times (17 bytes each) try writer.writeAll(&self.timestamp.serialize()); try writer.writeAll(&self.expires_at.serialize()); return list.toOwnedSlice(allocator); } /// Deserialize from wire bytes pub fn deserialize(allocator: std.mem.Allocator, data: []const u8) !ProofOfPath { if (data.len < 1) return error.InvalidData; var fbs = std.io.fixedBufferStream(data); const reader = fbs.reader(); var pop = ProofOfPath.init(allocator); // Hops const hop_count = try reader.readInt(u8, .little); for (0..hop_count) |_| { var hop: [32]u8 = undefined; try reader.readNoEof(&hop); try pop.hops.append(allocator, hop); } // Sigs const sig_count = try reader.readInt(u8, .little); for (0..sig_count) |_| { var sig: [64]u8 = undefined; try reader.readNoEof(&sig); try pop.signatures.append(allocator, sig); } // Times var ts_buf: [17]u8 = undefined; try reader.readNoEof(&ts_buf); pop.timestamp = time.SovereignTimestamp.deserialize(&ts_buf); try reader.readNoEof(&ts_buf); pop.expires_at = time.SovereignTimestamp.deserialize(&ts_buf); return pop; } }; // ============================================================================ // TESTS // ============================================================================ test "ProofOfPath: construction and verification (valid flow)" { const allocator = std.testing.allocator; // 1. Setup Graph: R -> A -> S (Receiver trusts A, A trusts Sender) // Receiver needs to verify S is trustworthy. var r_did: [32]u8 = undefined; @memset(&r_did, 0x11); // Receiver var a_did: [32]u8 = undefined; @memset(&a_did, 0xAA); // Intermediary var s_did: [32]u8 = undefined; @memset(&s_did, 0x99); // Sender var graph = try trust_graph.CompactTrustGraph.init(allocator, r_did, .{}); defer graph.deinit(); // R trusts A try graph.grantTrust(a_did, .full, .friends, 0); // Manual edge A -> S (simulate A's trust) const a_idx = graph.getNode(a_did).?; const s_idx = try graph.getOrInsertNode(s_did); try graph.adjacency.items[a_idx].append(allocator, .{ .target_idx = s_idx, .level = .full, .visibility = .public, .expires_at = 0 }); // 2. Sender constructs proof var pop = try ProofOfPath.construct(allocator, s_did, r_did, &graph); try std.testing.expect(pop != null); defer if (pop) |*p| p.deinit(); // 3. Verify path contents try std.testing.expectEqual(@as(usize, 3), pop.?.hops.items.len); // R, A, S try std.testing.expectEqualSlices(u8, &r_did, &pop.?.hops.items[0]); try std.testing.expectEqualSlices(u8, &s_did, &pop.?.hops.items[2]); // 4. Receiver Validates const verdict = pop.?.verify(r_did, s_did, &graph); try std.testing.expectEqual(PathVerdict.valid, verdict); } test "ProofOfPath: verify broken link" { const allocator = std.testing.allocator; var r_did: [32]u8 = undefined; @memset(&r_did, 0x11); var a_did: [32]u8 = undefined; @memset(&a_did, 0x22); var s_did: [32]u8 = undefined; @memset(&s_did, 0x33); var graph = try trust_graph.CompactTrustGraph.init(allocator, r_did, .{}); defer graph.deinit(); // R trusts A try graph.grantTrust(a_did, .full, .friends, 0); // A doesn't trust S in the graph! // Create fake PoP: R->A->S var pop = ProofOfPath.init(allocator); defer pop.deinit(); try pop.hops.append(allocator, r_did); try pop.hops.append(allocator, a_did); try pop.hops.append(allocator, s_did); const verdict = pop.verify(r_did, s_did, &graph); try std.testing.expectEqual(PathVerdict.broken_link, verdict); } test "ProofOfPath: serialization roundtrip" { const allocator = std.testing.allocator; var pop = ProofOfPath.init(allocator); defer pop.deinit(); try pop.hops.append(allocator, [_]u8{1} ** 32); try pop.hops.append(allocator, [_]u8{2} ** 32); try pop.signatures.append(allocator, [_]u8{9} ** 64); const serialized = try pop.serialize(allocator); defer allocator.free(serialized); var restored = try ProofOfPath.deserialize(allocator, serialized); defer restored.deinit(); try std.testing.expectEqual(pop.hops.items.len, restored.hops.items.len); try std.testing.expectEqualSlices(u8, &pop.hops.items[0], &restored.hops.items[0]); try std.testing.expectEqual(pop.signatures.items.len, restored.signatures.items.len); }