426 lines
14 KiB
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
|
|
}
|