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¶m2=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¶m2=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 }