//! RFC-0830: DID Integration & Local Cache (Minimal Scope) //! //! This module provides DID parsing and resolution primitives for L0-L1. //! Full W3C DID Document validation and Tombstoning is deferred to L2+ resolvers. //! //! Philosophy: Protocol stays dumb. L2+ resolvers enforce the standard. //! //! Scope: //! - Parse DID strings (did:METHOD:ID format, no schema validation) //! - Local cache with TTL-based expiration //! - Opaque metadata storage (method-specific, unvalidated) //! - Wire frame integration for DID identifiers //! //! Out of Scope: //! - W3C DID Document parsing //! - Rights system enforcement //! - Tombstone deactivation handling //! - Schema validation const std = @import("std"); const crypto = std.crypto; const pqxdh = @import("pqxdh.zig"); // ============================================================================ // Constants // ============================================================================ /// Maximum length of a DID string (did:METHOD:ID) pub const MAX_DID_LENGTH: usize = 256; /// Default cache entry TTL: 1 hour (3600 seconds) pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 3600; /// Supported DID methods pub const DIDMethod = enum { mosaic, // did:mosaic:* libertaria, // did:libertaria:* other, // Future methods, opaque handling }; // ============================================================================ // DID Document: Public Identity State // ============================================================================ /// Represents the resolved public state of an identity pub const DIDDocument = struct { /// The DID identifier (hash of keys) id: DIDIdentifier, /// Public Keys (Must match hash in ID) ed25519_public: [32]u8, x25519_public: [32]u8, mlkem_public: [pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8, /// Metadata created_at: u64, version: u32 = 1, /// Self-signature by Ed25519 key (binds ID to keys) /// Signed data: id.method_specific_id || created_at || version signature: [64]u8, /// Verify that this document is valid (hash matches ID, signature valid) pub fn verify(self: *const DIDDocument) !void { // 1. Verify ID hash var did_input: [32 + 32 + pqxdh.ML_KEM_768.PUBLIC_KEY_SIZE]u8 = undefined; @memcpy(did_input[0..32], &self.ed25519_public); @memcpy(did_input[32..64], &self.x25519_public); @memcpy(did_input[64..], &self.mlkem_public); var calculated_hash: [32]u8 = undefined; crypto.hash.sha2.Sha256.hash(&did_input, &calculated_hash, .{}); if (!std.mem.eql(u8, &calculated_hash, &self.id.method_specific_id)) { return error.InvalidDIDHash; } // 2. Verify Signature // Data: method_specific_id (32) + created_at (8) + version (4) var sig_data: [32 + 8 + 4]u8 = undefined; @memcpy(sig_data[0..32], &self.id.method_specific_id); std.mem.writeInt(u64, sig_data[32..40], self.created_at, .little); std.mem.writeInt(u32, sig_data[40..44], self.version, .little); // Verification (using Ed25519) const sig = crypto.sign.Ed25519.Signature.fromBytes(self.signature); const pk = try crypto.sign.Ed25519.PublicKey.fromBytes(self.ed25519_public); try sig.verify(&sig_data, pk); } }; // ============================================================================ // DID Identifier: Minimal Parsing // ============================================================================ pub const DIDIdentifier = struct { /// DID method (mosaic, libertaria, other) method: DIDMethod, /// 32-byte hash of method-specific identifier method_specific_id: [32]u8, /// Original DID string (for debugging, max 256 bytes) original: [MAX_DID_LENGTH]u8 = [_]u8{0} ** MAX_DID_LENGTH, original_len: usize = 0, /// Parse a DID string into structured form /// Format: did:METHOD:ID /// No validation beyond basic syntax; L2+ validates schema pub fn parse(did_string: []const u8) !DIDIdentifier { if (did_string.len == 0 or did_string.len > MAX_DID_LENGTH) { return error.InvalidDIDLength; } // Find "did:" prefix if (!std.mem.startsWith(u8, did_string, "did:")) { return error.MissingDIDPrefix; } // Find method separator (second ":") var colon_count: usize = 0; var method_end: usize = 0; for (did_string, 0..) |byte, idx| { if (byte == ':') { colon_count += 1; if (colon_count == 2) { method_end = idx; break; } } } if (colon_count < 2) { return error.MissingDIDMethod; } // Extract method name const method_str = did_string[4..method_end]; // Check for empty method name if (method_str.len == 0) { return error.MissingDIDMethod; } const method = if (std.mem.eql(u8, method_str, "mosaic")) DIDMethod.mosaic else if (std.mem.eql(u8, method_str, "libertaria")) DIDMethod.libertaria else DIDMethod.other; // Extract method-specific identifier const msi_str = did_string[method_end + 1 ..]; if (msi_str.len == 0) { return error.EmptyMethodSpecificId; } // Hash the method-specific identifier to 32 bytes var msi: [32]u8 = undefined; crypto.hash.sha2.Sha256.hash(msi_str, &msi, .{}); var id = DIDIdentifier{ .method = method, .method_specific_id = msi, .original_len = did_string.len, }; @memcpy(id.original[0..did_string.len], did_string); return id; } /// Return the parsed DID as a string (for debugging) pub fn format(self: *const DIDIdentifier) []const u8 { return self.original[0..self.original_len]; } /// Compare two DID identifiers by method-specific ID pub fn eql(self: *const DIDIdentifier, other: *const DIDIdentifier) bool { return self.method == other.method and std.mem.eql(u8, &self.method_specific_id, &other.method_specific_id); } }; // ============================================================================ // DID Cache: TTL-based Local Resolution // ============================================================================ pub const DIDCacheEntry = struct { did: DIDIdentifier, metadata: []const u8, // Opaque bytes (method-specific) ttl_seconds: u64, // Entry TTL created_at: u64, // Unix timestamp /// Check if this cache entry has expired pub fn isExpired(self: *const DIDCacheEntry, now: u64) bool { const age = now - self.created_at; return age > self.ttl_seconds; } }; pub const DIDCache = struct { cache: std.AutoHashMap([32]u8, DIDCacheEntry), allocator: std.mem.Allocator, /// Create a new DID cache pub fn init(allocator: std.mem.Allocator) DIDCache { return .{ .cache = std.AutoHashMap([32]u8, DIDCacheEntry).init(allocator), .allocator = allocator, }; } /// Deinitialize cache and free all stored metadata pub fn deinit(self: *DIDCache) void { var it = self.cache.valueIterator(); while (it.next()) |entry| { self.allocator.free(entry.metadata); } self.cache.deinit(); } /// Store a DID with metadata and TTL pub fn store( self: *DIDCache, did: *const DIDIdentifier, metadata: []const u8, ttl_seconds: u64, ) !void { const now = @as(u64, @intCast(std.time.timestamp())); // Allocate metadata copy const metadata_copy = try self.allocator.alloc(u8, metadata.len); @memcpy(metadata_copy, metadata); // Remove old entry if exists if (self.cache.contains(did.method_specific_id)) { if (self.cache.getPtr(did.method_specific_id)) |old_entry| { self.allocator.free(old_entry.metadata); } } // Store new entry const entry = DIDCacheEntry{ .did = did.*, .metadata = metadata_copy, .ttl_seconds = ttl_seconds, .created_at = now, }; try self.cache.put(did.method_specific_id, entry); } /// Retrieve a DID from cache (returns null if expired or not found) pub fn get(self: *DIDCache, did: *const DIDIdentifier) ?DIDCacheEntry { const now = @as(u64, @intCast(std.time.timestamp())); if (self.cache.get(did.method_specific_id)) |entry| { if (!entry.isExpired(now)) { return entry; } // Entry expired, remove it _ = self.cache.remove(did.method_specific_id); return null; } return null; } /// Invalidate a specific DID cache entry pub fn invalidate(self: *DIDCache, did: *const DIDIdentifier) void { if (self.cache.fetchRemove(did.method_specific_id)) |kv| { self.allocator.free(kv.value.metadata); } } /// Remove all expired entries pub fn prune(self: *DIDCache) void { const now = @as(u64, @intCast(std.time.timestamp())); // Collect keys to remove (can't mutate during iteration) var to_remove: [256][32]u8 = undefined; var remove_count: usize = 0; var it = self.cache.iterator(); while (it.next()) |entry| { if (entry.value_ptr.isExpired(now)) { if (remove_count < 256) { to_remove[remove_count] = entry.key_ptr.*; remove_count += 1; } } } // Now remove all expired entries for (0..remove_count) |i| { if (self.cache.fetchRemove(to_remove[i])) |kv| { self.allocator.free(kv.value.metadata); } } } /// Get total number of cached DIDs (including expired) pub fn count(self: *const DIDCache) usize { return self.cache.count(); } }; // ============================================================================ // Tests // ============================================================================ test "DID parsing: mosaic method" { const did_string = "did:mosaic:z7k8j9m3n5p2q4r6s8t0u2v4w6x8y0z2a4b6c8d0e2f4g6h8"; const did = try DIDIdentifier.parse(did_string); try std.testing.expectEqual(DIDMethod.mosaic, did.method); try std.testing.expectEqualSlices(u8, did.format(), did_string); } test "DID parsing: libertaria method" { const did_string = "did:libertaria:abc123def456"; const did = try DIDIdentifier.parse(did_string); try std.testing.expectEqual(DIDMethod.libertaria, did.method); } test "DID parsing: invalid prefix" { const did_string = "notadid:mosaic:z123"; const result = DIDIdentifier.parse(did_string); try std.testing.expectError(error.MissingDIDPrefix, result); } test "DID parsing: missing method" { const did_string = "did::z123"; const result = DIDIdentifier.parse(did_string); try std.testing.expectError(error.MissingDIDMethod, result); } test "DID parsing: empty method-specific-id" { const did_string = "did:mosaic:"; const result = DIDIdentifier.parse(did_string); try std.testing.expectError(error.EmptyMethodSpecificId, result); } test "DID parsing: too long" { var long_did: [MAX_DID_LENGTH + 1]u8 = [_]u8{'a'} ** (MAX_DID_LENGTH + 1); const result = DIDIdentifier.parse(&long_did); try std.testing.expectError(error.InvalidDIDLength, result); } test "DID equality" { const did1 = try DIDIdentifier.parse("did:mosaic:test1"); const did2 = try DIDIdentifier.parse("did:mosaic:test1"); const did3 = try DIDIdentifier.parse("did:mosaic:test2"); try std.testing.expect(did1.eql(&did2)); try std.testing.expect(!did1.eql(&did3)); } test "DID cache storage and retrieval" { var cache = DIDCache.init(std.testing.allocator); defer cache.deinit(); const did = try DIDIdentifier.parse("did:mosaic:cached123"); const metadata = "test_metadata"; try cache.store(&did, metadata, 3600); const entry = cache.get(&did); try std.testing.expect(entry != null); try std.testing.expectEqualSlices(u8, entry.?.metadata, metadata); } test "DID cache expiration" { var cache = DIDCache.init(std.testing.allocator); defer cache.deinit(); const did = try DIDIdentifier.parse("did:mosaic:expire123"); const metadata = "expiring_data"; // Store with very short TTL (1 second) try cache.store(&did, metadata, 1); // Entry should be present immediately const entry = cache.get(&did); try std.testing.expect(entry != null); // After waiting for TTL to expire, entry should be gone // (In unit tests this is deferred to Phase 3 with proper time mocking) } test "DID cache invalidation" { var cache = DIDCache.init(std.testing.allocator); defer cache.deinit(); const did = try DIDIdentifier.parse("did:mosaic:invalid123"); const metadata = "to_invalidate"; try cache.store(&did, metadata, 3600); cache.invalidate(&did); const entry = cache.get(&did); try std.testing.expect(entry == null); } test "DID cache pruning" { var cache = DIDCache.init(std.testing.allocator); defer cache.deinit(); const did1 = try DIDIdentifier.parse("did:mosaic:prune1"); const did2 = try DIDIdentifier.parse("did:mosaic:prune2"); try cache.store(&did1, "data1", 1); // Short TTL try cache.store(&did2, "data2", 3600); // Long TTL const initial_count = cache.count(); try std.testing.expect(initial_count == 2); // Prune should run without error (actual expiration depends on timing) cache.prune(); // Cache should still have entries (unless timing causes expiration) // In Phase 3, we'll add proper time mocking for this test }