1import 'constants.dart'; 2import 'identity_resolver_error.dart'; 3 4/// Checks if a string is a valid DID. 5/// 6/// A valid DID follows the format: did:method:method-specific-id 7/// where method is lowercase alphanumeric and method-specific-id 8/// contains only allowed characters. 9bool isDid(String input) { 10 try { 11 assertDid(input); 12 return true; 13 } catch (e) { 14 if (e is IdentityResolverError) { 15 return false; 16 } 17 rethrow; 18 } 19} 20 21/// Asserts that a string is a valid DID, throwing if not. 22void assertDid(String input) { 23 if (input.length > maxDidLength) { 24 throw InvalidDidError(input, 'DID is too long ($maxDidLength chars max)'); 25 } 26 27 if (!input.startsWith(didPrefix)) { 28 throw InvalidDidError(input, 'DID requires "$didPrefix" prefix'); 29 } 30 31 final methodEndIndex = input.indexOf(':', didPrefix.length); 32 if (methodEndIndex == -1) { 33 throw InvalidDidError(input, 'Missing colon after method name'); 34 } 35 36 _assertDidMethod(input, didPrefix.length, methodEndIndex); 37 _assertDidMsid(input, methodEndIndex + 1, input.length); 38} 39 40/// Validates DID method name (lowercase alphanumeric). 41void _assertDidMethod(String input, int start, int end) { 42 if (end == start) { 43 throw InvalidDidError(input, 'Empty method name'); 44 } 45 46 for (int i = start; i < end; i++) { 47 final c = input.codeUnitAt(i); 48 if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39))) { 49 // Not a-z or 0-9 50 throw InvalidDidError( 51 input, 52 'Invalid character at position $i in DID method name', 53 ); 54 } 55 } 56} 57 58/// Validates DID method-specific identifier. 59void _assertDidMsid(String input, int start, int end) { 60 if (end == start) { 61 throw InvalidDidError(input, 'DID method-specific id must not be empty'); 62 } 63 64 for (int i = start; i < end; i++) { 65 final c = input.codeUnitAt(i); 66 67 // Check for frequent chars first (a-z, A-Z, 0-9, ., -, _) 68 if ((c >= 0x61 && c <= 0x7a) || // a-z 69 (c >= 0x41 && c <= 0x5a) || // A-Z 70 (c >= 0x30 && c <= 0x39) || // 0-9 71 c == 0x2e || // . 72 c == 0x2d || // - 73 c == 0x5f) { 74 // _ 75 continue; 76 } 77 78 // ":" 79 if (c == 0x3a) { 80 if (i == end - 1) { 81 throw InvalidDidError(input, 'DID cannot end with ":"'); 82 } 83 continue; 84 } 85 86 // pct-encoded: %HEXDIG HEXDIG 87 if (c == 0x25) { 88 // % 89 if (i + 2 >= end) { 90 throw InvalidDidError( 91 input, 92 'Incomplete pct-encoded character at position $i', 93 ); 94 } 95 96 i++; 97 final c1 = input.codeUnitAt(i); 98 if (!((c1 >= 0x30 && c1 <= 0x39) || (c1 >= 0x41 && c1 <= 0x46))) { 99 // Not 0-9 or A-F 100 throw InvalidDidError( 101 input, 102 'Invalid pct-encoded character at position $i', 103 ); 104 } 105 106 i++; 107 final c2 = input.codeUnitAt(i); 108 if (!((c2 >= 0x30 && c2 <= 0x39) || (c2 >= 0x41 && c2 <= 0x46))) { 109 // Not 0-9 or A-F 110 throw InvalidDidError( 111 input, 112 'Invalid pct-encoded character at position $i', 113 ); 114 } 115 116 continue; 117 } 118 119 throw InvalidDidError(input, 'Disallowed character in DID at position $i'); 120 } 121} 122 123/// Extracts the method name from a DID. 124/// 125/// Example: extractDidMethod('did:plc:abc123') returns 'plc' 126String extractDidMethod(String did) { 127 final methodEndIndex = did.indexOf(':', didPrefix.length); 128 return did.substring(didPrefix.length, methodEndIndex); 129} 130 131/// Checks if a string is a valid did:plc identifier. 132bool isDidPlc(String input) { 133 if (input.length != didPlcLength) return false; 134 if (!input.startsWith(didPlcPrefix)) return false; 135 136 // Check that all characters after prefix are base32 [a-z2-7] 137 for (int i = didPlcPrefix.length; i < didPlcLength; i++) { 138 if (!_isBase32Char(input.codeUnitAt(i))) return false; 139 } 140 141 return true; 142} 143 144/// Checks if a string is a valid did:web identifier. 145bool isDidWeb(String input) { 146 if (!input.startsWith(didWebPrefix)) return false; 147 if (input.length <= didWebPrefix.length) return false; 148 149 // Check if next char after prefix is ":" 150 if (input.codeUnitAt(didWebPrefix.length) == 0x3a) return false; 151 152 try { 153 _assertDidMsid(input, didWebPrefix.length, input.length); 154 return true; 155 } catch (e) { 156 return false; 157 } 158} 159 160/// Checks if a DID uses an atProto-blessed method (plc or web). 161bool isAtprotoDid(String input) { 162 return isDidPlc(input) || isDidWeb(input); 163} 164 165/// Asserts that a string is a valid atProto DID (did:plc or did:web). 166/// 167/// Throws [InvalidDidError] if the DID is not a valid atProto DID. 168void assertAtprotoDid(String input) { 169 if (!isAtprotoDid(input)) { 170 throw InvalidDidError( 171 input, 172 'DID must use atProto-blessed method (did:plc or did:web)', 173 ); 174 } 175} 176 177/// Asserts that a string is a valid did:plc identifier. 178void assertDidPlc(String input) { 179 if (!input.startsWith(didPlcPrefix)) { 180 throw InvalidDidError(input, 'Invalid did:plc prefix'); 181 } 182 183 if (input.length != didPlcLength) { 184 throw InvalidDidError( 185 input, 186 'did:plc must be $didPlcLength characters long', 187 ); 188 } 189 190 for (int i = didPlcPrefix.length; i < didPlcLength; i++) { 191 if (!_isBase32Char(input.codeUnitAt(i))) { 192 throw InvalidDidError(input, 'Invalid character at position $i'); 193 } 194 } 195} 196 197/// Asserts that a string is a valid did:web identifier. 198void assertDidWeb(String input) { 199 if (!input.startsWith(didWebPrefix)) { 200 throw InvalidDidError(input, 'Invalid did:web prefix'); 201 } 202 203 if (input.codeUnitAt(didWebPrefix.length) == 0x3a) { 204 throw InvalidDidError(input, 'did:web MSID must not start with a colon'); 205 } 206 207 _assertDidMsid(input, didWebPrefix.length, input.length); 208} 209 210/// Checks if a character code is a base32 character [a-z2-7]. 211bool _isBase32Char(int c) => 212 (c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37); 213 214/// Converts a did:web to an HTTPS URL. 215/// 216/// Example: 217/// - did:web:example.com -> https://example.com 218/// - did:web:example.com:user:alice -> https://example.com/user/alice 219/// - did:web:localhost%3A3000 -> http://localhost:3000 220Uri didWebToUrl(String did) { 221 assertDidWeb(did); 222 223 final hostIdx = didWebPrefix.length; 224 final pathIdx = did.indexOf(':', hostIdx); 225 226 final hostEnc = 227 pathIdx == -1 ? did.substring(hostIdx) : did.substring(hostIdx, pathIdx); 228 final host = hostEnc.replaceAll('%3A', ':'); 229 final path = pathIdx == -1 ? '' : did.substring(pathIdx).replaceAll(':', '/'); 230 231 // Use http for localhost, https for everything else 232 final proto = 233 host.startsWith('localhost') && 234 (host.length == 9 || host.codeUnitAt(9) == 0x3a) // ':' 235 ? 'http' 236 : 'https'; 237 238 return Uri.parse('$proto://$host$path'); 239} 240 241/// Converts an HTTPS URL to a did:web identifier. 242/// 243/// Example: 244/// - https://example.com -> did:web:example.com 245/// - https://example.com/user/alice -> did:web:example.com:user:alice 246String urlToDidWeb(Uri url) { 247 final port = url.hasPort ? '%3A${url.port}' : ''; 248 final path = url.path == '/' ? '' : url.path.replaceAll('/', ':'); 249 250 return '$didWebPrefix${url.host}$port$path'; 251}