Main coves client
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}