1 module uricrypt;
2 
3 import sha3d;
4 import std.base64 : Base64URLNoPadding;
5 import std.string : indexOf, indexOfAny, startsWith;
6 import std.array : appender;
7 import std.algorithm : min;
8 import std.typecons : Nullable;
9 import std.exception : assertThrown;
10 
11 @safe:
12 
13 alias Shake128 = SHAKE128;
14 enum siv_size = 16;
15 enum PADBS = 3; // Padding block size for base64 compatibility
16 enum MAX_KEYSTREAM = 1024;
17 alias LargeShake128 = KECCAK!(128u, (MAX_KEYSTREAM * 8));
18 
19 struct UriComponents
20 {
21 	Nullable!string scheme;
22 	string rest;
23 
24 	UriComponentIterator iterator() const
25 	{
26 		// Handle empty rest
27 		if (this.rest.length == 0)
28 		{
29 			return UriComponentIterator("", 0, true);
30 		}
31 
32 		// Simply iterate over the rest regardless of scheme or path type
33 		return UriComponentIterator(this.rest, 0, false);
34 	}
35 }
36 
37 struct UriComponentIterator
38 {
39 	string rest;
40 	size_t position;
41 	bool done;
42 
43 	string next()
44 	{
45 		if (this.done)
46 		{
47 			return "";
48 		}
49 
50 		if (this.position >= this.rest.length)
51 		{
52 			this.done = true;
53 			return "";
54 		}
55 
56 		// Find next component ending with '/', '?', or '#'
57 		string remaining = this.rest[this.position .. $];
58 		ptrdiff_t end_pos = indexOfAny(remaining, "/?#");
59 		if (end_pos != -1)
60 		{
61 			size_t end = this.position + cast(size_t) end_pos + 1; // Include the terminator
62 			string component = this.rest[this.position .. end];
63 			this.position = end;
64 			return component;
65 		}
66 
67 		// Last component (no trailing terminator)
68 		if (this.position < this.rest.length)
69 		{
70 			string component = this.rest[this.position .. $];
71 			this.done = true;
72 			return component;
73 		}
74 
75 		this.done = true;
76 		return "";
77 	}
78 }
79 
80 UriComponents splitUri(string uri)
81 {
82 	// Check if this is a URI with a scheme
83 	ptrdiff_t scheme_end = indexOf(uri, "://");
84 	if (scheme_end != -1)
85 	{
86 		string scheme = uri[0 .. scheme_end + 3]; // Include "://"
87 		string rest = uri[scheme_end + 3 .. $];
88 		return UriComponents(Nullable!string(scheme), rest);
89 	}
90 
91 	// No scheme found - treat as path-only URI
92 	return UriComponents(Nullable!string.init, uri);
93 }
94 
95 void xorInPlace(ubyte[] data, const(ubyte)[] keystream)
96 {
97 	size_t len = min(data.length, keystream.length);
98 	foreach (size_t i; 0 .. len)
99 	{
100 		data[i] ^= keystream[i];
101 	}
102 }
103 
104 ubyte[] encryptUri(string uri, string secret_key, string context) @trusted
105 {
106 	const auto components = splitUri(uri);
107 
108 	auto encrypted_uri = appender!(ubyte[]);
109 
110 	Shake128 base_hasher;
111 	base_hasher.start();
112 	ubyte[1] key_len = [(cast(ubyte) secret_key.length)];
113 	base_hasher.put(key_len[]);
114 	base_hasher.put(cast(ubyte[]) secret_key);
115 	ubyte[1] ctx_len = [(cast(ubyte) context.length)];
116 	base_hasher.put(ctx_len[]);
117 	base_hasher.put(cast(ubyte[]) context);
118 
119 	auto components_hasher = base_hasher;
120 	components_hasher.put(cast(ubyte[]) "IV");
121 	auto base_keystream_hasher = base_hasher;
122 	base_keystream_hasher.put(cast(ubyte[]) "KS");
123 
124 	auto uri_parts_iter = components.iterator();
125 
126 	string part;
127 	while ((part = uri_parts_iter.next()) != "")
128 	{
129 		ubyte[] part_bytes = cast(ubyte[]) part;
130 
131 		const size_t total_unpadded = siv_size + part_bytes.length;
132 		const size_t padding = (PADBS - (total_unpadded % PADBS)) % PADBS;
133 
134 		components_hasher.put(part_bytes);
135 
136 		Shake128 siv_small = components_hasher;
137 		ubyte[siv_size] siv_full;
138 		siv_full[] = siv_small.finish();
139 		ubyte[siv_size] siv = siv_full;
140 
141 		auto keystream_small = base_keystream_hasher;
142 		keystream_small.put(siv[]);
143 
144 		LargeShake128 keystream_large = *cast(LargeShake128*)&keystream_small;
145 		ubyte[MAX_KEYSTREAM] full_keystream;
146 		full_keystream[] = keystream_large.finish();
147 
148 		const size_t encrypted_part_len = part_bytes.length + padding;
149 		ubyte[] encrypted_part = new ubyte[encrypted_part_len];
150 		encrypted_part[0 .. part_bytes.length][] = part_bytes;
151 		// Rest is already 0 from new
152 
153 		ubyte[] keystream = full_keystream[0 .. encrypted_part_len];
154 		xorInPlace(encrypted_part, keystream);
155 
156 		encrypted_uri.put(siv[]);
157 		encrypted_uri.put(encrypted_part);
158 	}
159 
160 	auto result = appender!(ubyte[]);
161 	if (!components.scheme.isNull)
162 	{
163 		result.put(cast(ubyte[]) components.scheme.get);
164 	}
165 	else
166 	{
167 		// When scheme is absent, add a / prefix before the ciphertext
168 		result.put('/');
169 	}
170 
171 	// Encode to base64
172 	string encoded = cast(string) Base64URLNoPadding.encode(encrypted_uri.data);
173 	result.put(cast(ubyte[]) encoded);
174 
175 	return result.data;
176 }
177 
178 ubyte[] decryptUri(string encrypted_uri, string secret_key, string context) @trusted
179 {
180 	Nullable!string scheme;
181 	string encrypted_part_str;
182 
183 	ptrdiff_t scheme_end = indexOf(encrypted_uri, "://");
184 	if (scheme_end != -1)
185 	{
186 		string scheme_str = encrypted_uri[0 .. scheme_end + 3];
187 		scheme = Nullable!string(scheme_str);
188 		encrypted_part_str = encrypted_uri[scheme_end + 3 .. $];
189 
190 		if (encrypted_part_str.length == 0)
191 		{
192 			return cast(ubyte[]) scheme_str;
193 		}
194 	}
195 	else if (encrypted_uri.length > 0 && encrypted_uri[0] == '/')
196 	{
197 		// Path-only URI with / prefix - skip the prefix
198 		encrypted_part_str = encrypted_uri[1 .. $];
199 	}
200 	else
201 	{
202 		// Invalid format - path-only URIs must have / prefix
203 		throw new Exception("DecryptionFailed");
204 	}
205 
206 	string decoded_str = cast(string) Base64URLNoPadding.decode(encrypted_part_str);
207 	ubyte[] encrypted_bytes = cast(ubyte[]) decoded_str;
208 
209 	auto result = appender!(ubyte[]);
210 
211 	// Add scheme if present
212 	if (!scheme.isNull)
213 	{
214 		result.put(cast(ubyte[]) scheme.get);
215 	}
216 
217 	size_t pos = 0;
218 
219 	Shake128 base_hasher;
220 	base_hasher.start();
221 	ubyte[1] key_len = [(cast(ubyte) secret_key.length)];
222 	base_hasher.put(key_len[]);
223 	base_hasher.put(cast(ubyte[]) secret_key);
224 	ubyte[1] ctx_len = [(cast(ubyte) context.length)];
225 	base_hasher.put(ctx_len[]);
226 	base_hasher.put(cast(ubyte[]) context);
227 
228 	auto components_hasher = base_hasher;
229 	components_hasher.put(cast(ubyte[]) "IV");
230 
231 	auto base_keystream_hasher = base_hasher;
232 	base_keystream_hasher.put(cast(ubyte[]) "KS");
233 
234 	while (pos < encrypted_bytes.length)
235 	{
236 		if (pos + siv_size > encrypted_bytes.length)
237 		{
238 			throw new Exception("DecryptionFailed");
239 		}
240 
241 		ubyte[siv_size] siv_full;
242 		siv_full[] = encrypted_bytes[pos .. pos + siv_size];
243 		ubyte[siv_size] siv = siv_full;
244 		size_t component_start = pos + siv_size;
245 		pos += siv_size;
246 
247 		auto keystream_small = base_keystream_hasher;
248 		keystream_small.put(siv[]);
249 
250 		LargeShake128 keystream_large = *cast(LargeShake128*)&keystream_small;
251 		ubyte[MAX_KEYSTREAM] full_ks;
252 		full_ks[] = keystream_large.finish();
253 
254 		size_t ks_pos = 0;
255 
256 		// Track component start position in result
257 		const size_t component_result_start = result.data.length;
258 
259 		// Decrypt bytes directly into result
260 		while (pos < encrypted_bytes.length)
261 		{
262 			if (ks_pos == MAX_KEYSTREAM)
263 			{
264 				throw new Exception("DecryptionFailed");
265 			}
266 
267 			ubyte decrypted_byte = encrypted_bytes[pos] ^ full_ks[ks_pos];
268 			pos += 1;
269 			ks_pos += 1;
270 
271 			if (decrypted_byte == 0)
272 			{
273 				continue;
274 			}
275 
276 			result.put(decrypted_byte);
277 
278 			// Check if this byte is a terminator ('/', '?', or '#')
279 			if (decrypted_byte == '/' || decrypted_byte == '?' || decrypted_byte == '#')
280 			{
281 				const size_t bytes_read = pos - component_start;
282 				const size_t total_len = siv_size + bytes_read;
283 				const size_t padding_needed = (PADBS - (total_len % PADBS)) % PADBS;
284 				pos += padding_needed;
285 				ks_pos += padding_needed;
286 				if (pos > encrypted_bytes.length || ks_pos > MAX_KEYSTREAM)
287 				{
288 					throw new Exception("DecryptionFailed");
289 				}
290 				break;
291 			}
292 		}
293 
294 		ubyte[] component_slice = result.data[component_result_start .. $];
295 		if (component_slice.length == 0)
296 		{
297 			throw new Exception("DecryptionFailed");
298 		}
299 
300 		components_hasher.put(component_slice);
301 
302 		Shake128 expected_small = components_hasher;
303 		ubyte[siv_size] expected_siv_full;
304 		expected_siv_full[] = expected_small.finish();
305 		ubyte[siv_size] expected_siv = expected_siv_full;
306 
307 		if (expected_siv[] != siv[])
308 		{
309 			throw new Exception("DecryptionFailed");
310 		}
311 	}
312 
313 	if (result.data.length == 0 || (scheme.isNull && result.data.length == 0))
314 	{
315 		throw new Exception("DecryptionFailed");
316 	}
317 
318 	return result.data;
319 }
320 
321 version (unittest)
322 {
323 	import std.algorithm.comparison : equal;
324 
325 	@("split_uri_basic") unittest
326 	{
327 		const string uri = "https://example.com";
328 		const auto result = splitUri(uri);
329 
330 		assert(!result.scheme.isNull);
331 		assert(result.scheme.get == "https://");
332 
333 		auto iter = result.iterator();
334 		string first = iter.next();
335 		assert(first == "example.com");
336 		assert(iter.next() == "");
337 	}
338 
339 	@("split_uri_with_path") unittest
340 	{
341 		const string uri = "https://example.com/a/b/c";
342 		const auto result = splitUri(uri);
343 
344 		assert(!result.scheme.isNull);
345 		assert(result.scheme.get == "https://");
346 
347 		auto iter = result.iterator();
348 		assert(iter.next() == "example.com/");
349 		assert(iter.next() == "a/");
350 		assert(iter.next() == "b/");
351 		assert(iter.next() == "c");
352 		assert(iter.next() == "");
353 	}
354 
355 	@("split_uri_path_only_absolute") unittest
356 	{
357 		const string uri = "/path/to/file";
358 		const auto result = splitUri(uri);
359 
360 		assert(result.scheme.isNull);
361 
362 		auto iter = result.iterator();
363 		assert(iter.next() == "/");
364 		assert(iter.next() == "path/");
365 		assert(iter.next() == "to/");
366 		assert(iter.next() == "file");
367 		assert(iter.next() == "");
368 	}
369 
370 	@("split_uri_path_only_relative") unittest
371 	{
372 		const string uri = "path/to/file";
373 		const auto result = splitUri(uri);
374 
375 		assert(result.scheme.isNull);
376 
377 		auto iter = result.iterator();
378 		assert(iter.next() == "path/");
379 		assert(iter.next() == "to/");
380 		assert(iter.next() == "file");
381 		assert(iter.next() == "");
382 	}
383 
384 	@("split_uri_single_slash") unittest
385 	{
386 		const string uri = "/";
387 		const auto result = splitUri(uri);
388 
389 		assert(result.scheme.isNull);
390 
391 		auto iter = result.iterator();
392 		assert(iter.next() == "/");
393 		assert(iter.next() == "");
394 	}
395 
396 	@("split_uri_with_query_params") unittest
397 	{
398 		const string uri = "https://example.com/path?foo=bar&baz=qux";
399 		const auto result = splitUri(uri);
400 
401 		assert(!result.scheme.isNull);
402 		assert(result.scheme.get == "https://");
403 
404 		auto iter = result.iterator();
405 		assert(iter.next() == "example.com/");
406 		assert(iter.next() == "path?");
407 		assert(iter.next() == "foo=bar&baz=qux");
408 		assert(iter.next() == "");
409 	}
410 
411 	@("split_uri_with_fragment") unittest
412 	{
413 		const string uri = "https://example.com/path#section";
414 		const auto result = splitUri(uri);
415 
416 		assert(!result.scheme.isNull);
417 		assert(result.scheme.get == "https://");
418 
419 		auto iter = result.iterator();
420 		assert(iter.next() == "example.com/");
421 		assert(iter.next() == "path#");
422 		assert(iter.next() == "section");
423 		assert(iter.next() == "");
424 	}
425 
426 	@("split_uri_with_query_and_fragment") unittest
427 	{
428 		const string uri = "https://example.com/path?query=value#section";
429 		const auto result = splitUri(uri);
430 
431 		assert(!result.scheme.isNull);
432 		assert(result.scheme.get == "https://");
433 
434 		auto iter = result.iterator();
435 		assert(iter.next() == "example.com/");
436 		assert(iter.next() == "path?");
437 		assert(iter.next() == "query=value#");
438 		assert(iter.next() == "section");
439 		assert(iter.next() == "");
440 	}
441 
442 	@("split_path_with_query_params") unittest
443 	{
444 		const string uri = "/path/to/file?param=value";
445 		const auto result = splitUri(uri);
446 
447 		assert(result.scheme.isNull);
448 
449 		auto iter = result.iterator();
450 		assert(iter.next() == "/");
451 		assert(iter.next() == "path/");
452 		assert(iter.next() == "to/");
453 		assert(iter.next() == "file?");
454 		assert(iter.next() == "param=value");
455 		assert(iter.next() == "");
456 	}
457 
458 	@("split_path_with_fragment") unittest
459 	{
460 		const string uri = "/path/to/file#anchor";
461 		const auto result = splitUri(uri);
462 
463 		assert(result.scheme.isNull);
464 
465 		auto iter = result.iterator();
466 		assert(iter.next() == "/");
467 		assert(iter.next() == "path/");
468 		assert(iter.next() == "to/");
469 		assert(iter.next() == "file#");
470 		assert(iter.next() == "anchor");
471 		assert(iter.next() == "");
472 	}
473 
474 	@("xor_in_place") unittest
475 	{
476 		ubyte[4] data = [0xFF, 0x00, 0xAA, 0x55];
477 		const ubyte[4] keystream = [0x00, 0xFF, 0x55, 0xAA];
478 		xorInPlace(data, keystream);
479 		assert(data == [0xFF, 0xFF, 0xFF, 0xFF]);
480 	}
481 
482 	@("encrypt_decrypt_basic") @trusted unittest
483 	{
484 		const string uri = "https://example.com";
485 		const string secret_key = "test_key";
486 		const string context = "test_context";
487 
488 		ubyte[] encrypted = encryptUri(uri, secret_key, context);
489 
490 		// Check that scheme is preserved
491 		assert(startsWith(cast(string) encrypted, "https://"));
492 
493 		ubyte[] decrypted = decryptUri(cast(string) encrypted, secret_key, context);
494 
495 		assert(equal(decrypted, cast(ubyte[]) uri));
496 	}
497 
498 	@("encrypt_deterministic") unittest
499 	{
500 		const string uri = "https://example.com/test";
501 		const string secret_key = "my_secret";
502 		const string context = "test_ctx";
503 
504 		ubyte[] encrypted1 = encryptUri(uri, secret_key, context);
505 		ubyte[] encrypted2 = encryptUri(uri, secret_key, context);
506 
507 		// Same input should produce same output
508 		assert(equal(encrypted1, encrypted2));
509 	}
510 
511 	@("encrypt_different_keys") unittest
512 	{
513 		const string uri = "https://example.com";
514 		const string key1 = "key1";
515 		const string key2 = "key2";
516 		const string context = "test_ctx";
517 
518 		ubyte[] encrypted1 = encryptUri(uri, key1, context);
519 		ubyte[] encrypted2 = encryptUri(uri, key2, context);
520 
521 		// Different keys should produce different outputs
522 		assert(!equal(encrypted1, encrypted2));
523 	}
524 
525 	@("round_trip_various_uris") @trusted unittest
526 	{
527 		const string[] test_cases = [
528 			"https://example.com",
529 			"https://example.com/",
530 			"https://example.com/path",
531 			"https://example.com/path/",
532 			"https://example.com/a/b/c/d/e",
533 			"https://subdomain.example.com/path/to/resource",
534 			// URIs with query parameters
535 			"https://example.com?query=value",
536 			"https://example.com/path?foo=bar",
537 			"https://example.com/path?foo=bar&baz=qux",
538 			"https://example.com/path/file?param1=value1&param2=value2",
539 			// URIs with fragments
540 			"https://example.com#section",
541 			"https://example.com/path#heading",
542 			"https://example.com/path/file#anchor",
543 			// URIs with both query and fragment
544 			"https://example.com?query=value#section",
545 			"https://example.com/path?foo=bar#heading",
546 			"https://example.com/path/file?param1=value1&param2=value2#anchor",
547 		];
548 
549 		const string secret_key = "my_secret_key";
550 		const string context = "test_context";
551 
552 		foreach (string tc_uri; test_cases)
553 		{
554 			ubyte[] encrypted = encryptUri(tc_uri, secret_key, context);
555 			ubyte[] decrypted = decryptUri(cast(string) encrypted, secret_key, context);
556 			assert(equal(decrypted, cast(ubyte[]) tc_uri));
557 		}
558 	}
559 
560 	@("decrypt_wrong_key") @trusted unittest
561 	{
562 		const string uri = "https://example.com";
563 		const string encrypt_key = "key1";
564 		const string decrypt_key = "key2";
565 		const string context = "test_context";
566 
567 		ubyte[] encrypted = encryptUri(uri, encrypt_key, context);
568 
569 		assertThrown!Exception(decryptUri(cast(string) encrypted, decrypt_key, context));
570 	}
571 
572 	@("decrypt_wrong_context") @trusted unittest
573 	{
574 		const string uri = "https://example.com";
575 		const string secret_key = "test_key";
576 		const string context1 = "context1";
577 		const string context2 = "context2";
578 
579 		ubyte[] encrypted = encryptUri(uri, secret_key, context1);
580 
581 		assertThrown!Exception(decryptUri(cast(string) encrypted, secret_key, context2));
582 	}
583 
584 	@("path_only_encryption") @trusted unittest
585 	{
586 		const string secret_key = "test_key";
587 		const string context = "test_context";
588 
589 		// Test absolute path
590 		const string path1 = "/path/to/file";
591 		ubyte[] encrypted1 = encryptUri(path1, secret_key, context);
592 
593 		// Should not contain a scheme separator
594 		assert(indexOf(cast(string) encrypted1, "://") == -1);
595 		// Should have / prefix for path-only URIs
596 		assert(encrypted1[0] == '/');
597 
598 		// Should decrypt correctly
599 		ubyte[] decrypted1 = decryptUri(cast(string) encrypted1, secret_key, context);
600 		assert(equal(decrypted1, cast(ubyte[]) path1));
601 
602 		// Test relative path
603 		const string path2 = "path/to/file";
604 		ubyte[] encrypted2 = encryptUri(path2, secret_key, context);
605 
606 		// Should not contain a scheme separator
607 		assert(indexOf(cast(string) encrypted2, "://") == -1);
608 		// Should have / prefix for path-only URIs
609 		assert(encrypted2[0] == '/');
610 
611 		ubyte[] decrypted2 = decryptUri(cast(string) encrypted2, secret_key, context);
612 		assert(equal(decrypted2, cast(ubyte[]) path2));
613 	}
614 
615 	@("path_only_uris_with_prefix") @trusted unittest
616 	{
617 		const string secret_key = "test_key";
618 		const string context = "test_context";
619 
620 		// Test various path-only URIs
621 		const string[] test_cases = [
622 			"/path/to/file",
623 			"path/to/file",
624 			"/",
625 			"file.txt",
626 			"/path/with/query?param=value",
627 			"relative/path/with/fragment#section",
628 		];
629 
630 		foreach (string tc_uri; test_cases)
631 		{
632 			ubyte[] encrypted = encryptUri(tc_uri, secret_key, context);
633 
634 			// Path-only URIs should have a '/' prefix before the ciphertext
635 			assert(encrypted[0] == '/');
636 
637 			// Should not contain scheme separator
638 			assert(indexOf(cast(string) encrypted, "://") == -1);
639 
640 			// Should decrypt correctly
641 			ubyte[] decrypted = decryptUri(cast(string) encrypted, secret_key, context);
642 			assert(equal(decrypted, cast(ubyte[]) tc_uri));
643 		}
644 	}
645 
646 	@("keys_with_identical_halves_work") @trusted unittest
647 	{
648 		const string uri = "https://example.com/path";
649 		const string identical_halves_key = "same_halfsame_half"; // Both halves are identical
650 		const string context = "test";
651 
652 		// Should work fine now that validation is removed
653 		ubyte[] encrypted = encryptUri(uri, identical_halves_key, context);
654 		assert(encrypted.length > 0);
655 
656 		// Should decrypt successfully
657 		ubyte[] decrypted = decryptUri(cast(string) encrypted, identical_halves_key, context);
658 		assert(equal(decrypted, cast(ubyte[]) uri));
659 	}
660 }