libertaria-stack/core/l1-identity/did.zig

426 lines
14 KiB
Zig

//! 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
}