From c3c124ae34b769729855d0c05ca5cea8059fc7e6 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 18:50:10 +0200 Subject: [PATCH] Key of words v2: salted with capsule_id, lowered with Unicode 18.0.0 As the spec v0.11 draft decides after its review (38.1), and as the Go reference does from 2213b8c: - dkc/wordkey.ts replaces inspector/wordkey.ts. The salt carries the capsule_id too, so the same words give another key in each capsule; the words are lowered with the new LOWERCASE table of pathrule.ts, loaded on demand; checkWords refuses controls, invisible and unassigned code points and counts only different words of three letters or more, with the errors of Go. New vector of 38.1. - encryptFiles takes the words and derives their key once it has drawn capsule_id; the creator passes them, and the opener salts them with the capsule_id of the capsule. - /create asks for the words twice and shows how they are kept; the form checks them with the tables of the platform, and the writer again with those of Unicode 18.0.0. - The tables, regenerated with the lower case, and testdata synced from 2213b8c; the frozen texts of Go for the invalid options, updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/lib/components/OpenPanel.svelte | 2 +- src/lib/dkc/encrypt.test.ts | 20 +- src/lib/dkc/pathrule-tables.ts | 299 ++++++++++++++++++++++- src/lib/dkc/pathrule.ts | 14 ++ src/lib/dkc/testing/capsule-vectors.json | 20 +- src/lib/dkc/wordkey.test.ts | 75 ++++++ src/lib/dkc/wordkey.ts | 120 +++++++++ src/lib/dkc/writer.ts | 47 +++- src/lib/inspector/create-input.test.ts | 25 +- src/lib/inspector/create-input.ts | 26 +- src/lib/inspector/creator.test.ts | 11 +- src/lib/inspector/creator.ts | 18 +- src/lib/inspector/opener.test.ts | 12 +- src/lib/inspector/opener.ts | 11 +- src/lib/inspector/wordkey.test.ts | 32 --- src/lib/inspector/wordkey.ts | 50 ---- src/routes/create/+page.svelte | 24 +- testdata/SOURCE.json | 6 +- testdata/vectors/path_fold.json | 2 +- testdata/vectors/paths.json | 2 +- vitest.config.ts | 2 +- 22 files changed, 665 insertions(+), 154 deletions(-) create mode 100644 src/lib/dkc/wordkey.test.ts create mode 100644 src/lib/dkc/wordkey.ts delete mode 100644 src/lib/inspector/wordkey.test.ts delete mode 100644 src/lib/inspector/wordkey.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa7aa2..2bd12c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../do ### Llave de palabras y firma de drand +- Llave de palabras v2, como decide el borrador de la especificación 0.11 (§38.1): la sal lleva además el `capsule_id`, así que las mismas palabras dan otra llave en cada cápsula; las palabras se pasan a minúsculas con la tabla de Unicode 18.0.0 de `pathrule.ts`, no con la de la plataforma; cuentan solo las distintas de 3 letras o más; y se rechazan los caracteres invisibles. `encryptFiles` recibe las palabras (`words`) y deriva la llave al sortear el `capsule_id`. `/create` pide escribirlas dos veces y muestra cómo se guardan. Las cápsulas hechas antes con palabras ya no se abren con ellas. El módulo pasa a `src/lib/dkc/wordkey.ts`. - `/inspect` pide la firma de la ronda a los relays de drand con un botón (`drand.ts`), además de poder pegarla. - Una cápsula «solo con una llave» puede abrirse con palabras que elige quien la crea, al menos 6, en vez del fichero `.dkk` (`wordkey.ts`): PBKDF2-SHA256 de 600.000 vueltas, con la red y la ronda como sal, da una clave X25519 que entra como una persona de `age` más. El formato no cambia; dan igual mayúsculas, acentos y espacios. diff --git a/src/lib/components/OpenPanel.svelte b/src/lib/components/OpenPanel.svelte index 784956d..fa1ba7f 100644 --- a/src/lib/components/OpenPanel.svelte +++ b/src/lib/components/OpenPanel.svelte @@ -241,7 +241,7 @@ release: parsed.release, ...(timeAndKey ? { identities } : {}), ...(timeAndKey && accessKey !== undefined ? { accessKey } : {}), - ...(timeAndKey && words.trim() !== '' ? { words: { text: words, chainHash: report.profile!.chainHash, round } } : {}), + ...(timeAndKey && words.trim() !== '' ? { words: { text: words, chainHash: report.profile!.chainHash, round, capsuleId: report.capsule!.capsuleId } } : {}), // A stale opening stops writing, so open aborts the file and stops. ...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }), // Format 3: the files without an mtime take the time of the round in diff --git a/src/lib/dkc/encrypt.test.ts b/src/lib/dkc/encrypt.test.ts index ef8c56b..6229247 100644 --- a/src/lib/dkc/encrypt.test.ts +++ b/src/lib/dkc/encrypt.test.ts @@ -271,15 +271,25 @@ describe('invalid options', () => { ['a clock that is not an instant', te.encode('x'), { now: () => ({ seconds: 0, nanos: -1 }) }, 'TypeError', /now\(\) did not return an Instant/, ''], ['an instant in the past', te.encode('x'), { now: () => roundAt(1001) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''], ['an instant equal to now', te.encode('x'), { now: () => roundAt(1000) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''], - ['time_only with recipients', te.encode('x'), { recipients: [recipient] }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''], - ['time_only with a portable key', te.encode('x'), { newPortableKey: true }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''], - ['time_and_key without credentials', te.encode('x'), { policy: TIME_AND_KEY }, 'Error', 'capsule: time_and_key needs at least one recipient or a portable key', ''], + ['time_only with recipients', te.encode('x'), { recipients: [recipient] }, 'Error', 'capsule: time_only takes no recipients, no portable key and no key of words', ''], + ['time_only with a portable key', te.encode('x'), { newPortableKey: true }, 'Error', 'capsule: time_only takes no recipients, no portable key and no key of words', ''], + ['time_and_key without credentials', te.encode('x'), { policy: TIME_AND_KEY }, 'Error', 'capsule: time_and_key needs at least one recipient, a portable key or a key of words', ''], + ['time_only with words', te.encode('x'), { words: ['perro', 'luna', 'casa', 'verde', 'tren', 'mar'] }, 'Error', 'capsule: time_only takes no recipients, no portable key and no key of words', ''], + [ + 'too few words', + te.encode('x'), + { policy: TIME_AND_KEY, words: ['uno', 'dos', 'tres'] }, + 'Error', + 'capsule: wordkey: a key of words needs at least 6 different words of 3 or more letters, not 3', + '', + ], + ['a word that is not a string', te.encode('x'), { policy: TIME_AND_KEY, words: [1] }, 'TypeError', 'encrypt: word 0 is not a string', ''], [ '16 recipients and a portable key', te.encode('x'), { policy: TIME_AND_KEY, recipients: Array.from({ length: 16 }, () => x25519PublicKey(newX25519Identity())), newPortableKey: true }, 'Error', - 'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given', + 'capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given', '', ], [ @@ -287,7 +297,7 @@ describe('invalid options', () => { te.encode('x'), { policy: TIME_AND_KEY, recipients: Array.from({ length: 17 }, () => x25519PublicKey(newX25519Identity())) }, 'Error', - 'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given', + 'capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given', '', ], ['a recipient of 31 bytes', te.encode('x'), { policy: TIME_AND_KEY, recipients: [new Uint8Array(31)] }, 'TypeError', 'encrypt: recipient 0 is not a 32-byte X25519 public key', ''], diff --git a/src/lib/dkc/pathrule-tables.ts b/src/lib/dkc/pathrule-tables.ts index a1d36dd..e9317f2 100644 --- a/src/lib/dkc/pathrule-tables.ts +++ b/src/lib/dkc/pathrule-tables.ts @@ -8,7 +8,7 @@ export const UNICODE_VERSION = '18.0.0'; * The SHA-256 of the canonical text of these tables, as the Go reference * computes it in pathrule.Canonical: the tests recompute it from the arrays. */ -export const TABLES_DIGEST = '7bb770bac2c81497f520da6b079e6c4fcbcf140e937a2d4203da3ba7b46df2e2'; +export const TABLES_DIGEST = '07cf5d54aea1cd13a3ecef14a06976cc49a3cdad755cf9bc10395178b93aeb07'; /** The data files of the tables and their SHA-256 (spec §29.5.1). */ export const SOURCES: readonly { readonly path: string; readonly sha256: string }[] = [ @@ -1015,6 +1015,303 @@ export const FOLDING_DATA: readonly number[] = [ 125237, 125238, 125239, 125240, 125241, 125242, 125243, 125244, 125245, 125246, 125247, 125248, 125249, 125250, 125251, ]; +/** The simple lowercase mapping of UnicodeData.txt, field 13: the code points that have one, sorted. */ +export const LOWERCASE_KEYS: readonly number[] = [ + 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, + 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 192, 193, 194, 195, 196, 197, + 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, + 214, 216, 217, 218, 219, 220, 221, 222, 256, 258, 260, 262, 264, 266, 268, 270, + 272, 274, 276, 278, 280, 282, 284, 286, 288, 290, 292, 294, 296, 298, 300, 302, + 304, 306, 308, 310, 313, 315, 317, 319, 321, 323, 325, 327, 330, 332, 334, 336, + 338, 340, 342, 344, 346, 348, 350, 352, 354, 356, 358, 360, 362, 364, 366, 368, + 370, 372, 374, 376, 377, 379, 381, 385, 386, 388, 390, 391, 393, 394, 395, 398, + 399, 400, 401, 403, 404, 406, 407, 408, 412, 413, 415, 416, 418, 420, 422, 423, + 425, 428, 430, 431, 433, 434, 435, 437, 439, 440, 444, 452, 453, 455, 456, 458, + 459, 461, 463, 465, 467, 469, 471, 473, 475, 478, 480, 482, 484, 486, 488, 490, + 492, 494, 497, 498, 500, 502, 503, 504, 506, 508, 510, 512, 514, 516, 518, 520, + 522, 524, 526, 528, 530, 532, 534, 536, 538, 540, 542, 544, 546, 548, 550, 552, + 554, 556, 558, 560, 562, 570, 571, 573, 574, 577, 579, 580, 581, 582, 584, 586, + 588, 590, 880, 882, 886, 895, 902, 904, 905, 906, 908, 910, 911, 913, 914, 915, + 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, 928, 929, 931, 932, + 933, 934, 935, 936, 937, 938, 939, 975, 984, 986, 988, 990, 992, 994, 996, 998, + 1000, 1002, 1004, 1006, 1012, 1015, 1017, 1018, 1021, 1022, 1023, 1024, 1025, 1026, 1027, 1028, + 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, 1042, 1043, 1044, + 1045, 1046, 1047, 1048, 1049, 1050, 1051, 1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, + 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1120, 1122, 1124, 1126, 1128, + 1130, 1132, 1134, 1136, 1138, 1140, 1142, 1144, 1146, 1148, 1150, 1152, 1162, 1164, 1166, 1168, + 1170, 1172, 1174, 1176, 1178, 1180, 1182, 1184, 1186, 1188, 1190, 1192, 1194, 1196, 1198, 1200, + 1202, 1204, 1206, 1208, 1210, 1212, 1214, 1216, 1217, 1219, 1221, 1223, 1225, 1227, 1229, 1232, + 1234, 1236, 1238, 1240, 1242, 1244, 1246, 1248, 1250, 1252, 1254, 1256, 1258, 1260, 1262, 1264, + 1266, 1268, 1270, 1272, 1274, 1276, 1278, 1280, 1282, 1284, 1286, 1288, 1290, 1292, 1294, 1296, + 1298, 1300, 1302, 1304, 1306, 1308, 1310, 1312, 1314, 1316, 1318, 1320, 1322, 1324, 1326, 1329, + 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, + 1346, 1347, 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, + 1362, 1363, 1364, 1365, 1366, 4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, + 4267, 4268, 4269, 4270, 4271, 4272, 4273, 4274, 4275, 4276, 4277, 4278, 4279, 4280, 4281, 4282, + 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4290, 4291, 4292, 4293, 4295, 4301, 5024, 5025, 5026, + 5027, 5028, 5029, 5030, 5031, 5032, 5033, 5034, 5035, 5036, 5037, 5038, 5039, 5040, 5041, 5042, + 5043, 5044, 5045, 5046, 5047, 5048, 5049, 5050, 5051, 5052, 5053, 5054, 5055, 5056, 5057, 5058, + 5059, 5060, 5061, 5062, 5063, 5064, 5065, 5066, 5067, 5068, 5069, 5070, 5071, 5072, 5073, 5074, + 5075, 5076, 5077, 5078, 5079, 5080, 5081, 5082, 5083, 5084, 5085, 5086, 5087, 5088, 5089, 5090, + 5091, 5092, 5093, 5094, 5095, 5096, 5097, 5098, 5099, 5100, 5101, 5102, 5103, 5104, 5105, 5106, + 5107, 5108, 5109, 7305, 7312, 7313, 7314, 7315, 7316, 7317, 7318, 7319, 7320, 7321, 7322, 7323, + 7324, 7325, 7326, 7327, 7328, 7329, 7330, 7331, 7332, 7333, 7334, 7335, 7336, 7337, 7338, 7339, + 7340, 7341, 7342, 7343, 7344, 7345, 7346, 7347, 7348, 7349, 7350, 7351, 7352, 7353, 7354, 7357, + 7358, 7359, 7680, 7682, 7684, 7686, 7688, 7690, 7692, 7694, 7696, 7698, 7700, 7702, 7704, 7706, + 7708, 7710, 7712, 7714, 7716, 7718, 7720, 7722, 7724, 7726, 7728, 7730, 7732, 7734, 7736, 7738, + 7740, 7742, 7744, 7746, 7748, 7750, 7752, 7754, 7756, 7758, 7760, 7762, 7764, 7766, 7768, 7770, + 7772, 7774, 7776, 7778, 7780, 7782, 7784, 7786, 7788, 7790, 7792, 7794, 7796, 7798, 7800, 7802, + 7804, 7806, 7808, 7810, 7812, 7814, 7816, 7818, 7820, 7822, 7824, 7826, 7828, 7838, 7840, 7842, + 7844, 7846, 7848, 7850, 7852, 7854, 7856, 7858, 7860, 7862, 7864, 7866, 7868, 7870, 7872, 7874, + 7876, 7878, 7880, 7882, 7884, 7886, 7888, 7890, 7892, 7894, 7896, 7898, 7900, 7902, 7904, 7906, + 7908, 7910, 7912, 7914, 7916, 7918, 7920, 7922, 7924, 7926, 7928, 7930, 7932, 7934, 7944, 7945, + 7946, 7947, 7948, 7949, 7950, 7951, 7960, 7961, 7962, 7963, 7964, 7965, 7976, 7977, 7978, 7979, + 7980, 7981, 7982, 7983, 7992, 7993, 7994, 7995, 7996, 7997, 7998, 7999, 8008, 8009, 8010, 8011, + 8012, 8013, 8025, 8027, 8029, 8031, 8040, 8041, 8042, 8043, 8044, 8045, 8046, 8047, 8072, 8073, + 8074, 8075, 8076, 8077, 8078, 8079, 8088, 8089, 8090, 8091, 8092, 8093, 8094, 8095, 8104, 8105, + 8106, 8107, 8108, 8109, 8110, 8111, 8120, 8121, 8122, 8123, 8124, 8136, 8137, 8138, 8139, 8140, + 8152, 8153, 8154, 8155, 8168, 8169, 8170, 8171, 8172, 8184, 8185, 8186, 8187, 8188, 8486, 8490, + 8491, 8498, 8544, 8545, 8546, 8547, 8548, 8549, 8550, 8551, 8552, 8553, 8554, 8555, 8556, 8557, + 8558, 8559, 8579, 9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, + 9411, 9412, 9413, 9414, 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423, 11264, 11265, 11266, + 11267, 11268, 11269, 11270, 11271, 11272, 11273, 11274, 11275, 11276, 11277, 11278, 11279, 11280, 11281, 11282, + 11283, 11284, 11285, 11286, 11287, 11288, 11289, 11290, 11291, 11292, 11293, 11294, 11295, 11296, 11297, 11298, + 11299, 11300, 11301, 11302, 11303, 11304, 11305, 11306, 11307, 11308, 11309, 11310, 11311, 11360, 11362, 11363, + 11364, 11367, 11369, 11371, 11373, 11374, 11375, 11376, 11378, 11381, 11390, 11391, 11392, 11394, 11396, 11398, + 11400, 11402, 11404, 11406, 11408, 11410, 11412, 11414, 11416, 11418, 11420, 11422, 11424, 11426, 11428, 11430, + 11432, 11434, 11436, 11438, 11440, 11442, 11444, 11446, 11448, 11450, 11452, 11454, 11456, 11458, 11460, 11462, + 11464, 11466, 11468, 11470, 11472, 11474, 11476, 11478, 11480, 11482, 11484, 11486, 11488, 11490, 11499, 11501, + 11506, 42560, 42562, 42564, 42566, 42568, 42570, 42572, 42574, 42576, 42578, 42580, 42582, 42584, 42586, 42588, + 42590, 42592, 42594, 42596, 42598, 42600, 42602, 42604, 42624, 42626, 42628, 42630, 42632, 42634, 42636, 42638, + 42640, 42642, 42644, 42646, 42648, 42650, 42786, 42788, 42790, 42792, 42794, 42796, 42798, 42802, 42804, 42806, + 42808, 42810, 42812, 42814, 42816, 42818, 42820, 42822, 42824, 42826, 42828, 42830, 42832, 42834, 42836, 42838, + 42840, 42842, 42844, 42846, 42848, 42850, 42852, 42854, 42856, 42858, 42860, 42862, 42873, 42875, 42877, 42878, + 42880, 42882, 42884, 42886, 42891, 42893, 42896, 42898, 42902, 42904, 42906, 42908, 42910, 42912, 42914, 42916, + 42918, 42920, 42922, 42923, 42924, 42925, 42926, 42928, 42929, 42930, 42931, 42932, 42934, 42936, 42938, 42940, + 42942, 42944, 42946, 42948, 42949, 42950, 42951, 42953, 42955, 42956, 42958, 42960, 42962, 42964, 42966, 42968, + 42970, 42972, 42973, 42978, 42997, 43884, 43885, 65313, 65314, 65315, 65316, 65317, 65318, 65319, 65320, 65321, + 65322, 65323, 65324, 65325, 65326, 65327, 65328, 65329, 65330, 65331, 65332, 65333, 65334, 65335, 65336, 65337, + 65338, 66560, 66561, 66562, 66563, 66564, 66565, 66566, 66567, 66568, 66569, 66570, 66571, 66572, 66573, 66574, + 66575, 66576, 66577, 66578, 66579, 66580, 66581, 66582, 66583, 66584, 66585, 66586, 66587, 66588, 66589, 66590, + 66591, 66592, 66593, 66594, 66595, 66596, 66597, 66598, 66599, 66736, 66737, 66738, 66739, 66740, 66741, 66742, + 66743, 66744, 66745, 66746, 66747, 66748, 66749, 66750, 66751, 66752, 66753, 66754, 66755, 66756, 66757, 66758, + 66759, 66760, 66761, 66762, 66763, 66764, 66765, 66766, 66767, 66768, 66769, 66770, 66771, 66928, 66929, 66930, + 66931, 66932, 66933, 66934, 66935, 66936, 66937, 66938, 66940, 66941, 66942, 66943, 66944, 66945, 66946, 66947, + 66948, 66949, 66950, 66951, 66952, 66953, 66954, 66956, 66957, 66958, 66959, 66960, 66961, 66962, 66964, 66965, + 68736, 68737, 68738, 68739, 68740, 68741, 68742, 68743, 68744, 68745, 68746, 68747, 68748, 68749, 68750, 68751, + 68752, 68753, 68754, 68755, 68756, 68757, 68758, 68759, 68760, 68761, 68762, 68763, 68764, 68765, 68766, 68767, + 68768, 68769, 68770, 68771, 68772, 68773, 68774, 68775, 68776, 68777, 68778, 68779, 68780, 68781, 68782, 68783, + 68784, 68785, 68786, 68944, 68945, 68946, 68947, 68948, 68949, 68950, 68951, 68952, 68953, 68954, 68955, 68956, + 68957, 68958, 68959, 68960, 68961, 68962, 68963, 68964, 68965, 71840, 71841, 71842, 71843, 71844, 71845, 71846, + 71847, 71848, 71849, 71850, 71851, 71852, 71853, 71854, 71855, 71856, 71857, 71858, 71859, 71860, 71861, 71862, + 71863, 71864, 71865, 71866, 71867, 71868, 71869, 71870, 71871, 93760, 93761, 93762, 93763, 93764, 93765, 93766, + 93767, 93768, 93769, 93770, 93771, 93772, 93773, 93774, 93775, 93776, 93777, 93778, 93779, 93780, 93781, 93782, + 93783, 93784, 93785, 93786, 93787, 93788, 93789, 93790, 93791, 93856, 93857, 93858, 93859, 93860, 93861, 93862, + 93863, 93864, 93865, 93866, 93867, 93868, 93869, 93870, 93871, 93872, 93873, 93874, 93875, 93876, 93877, 93878, + 93879, 93880, 122688, 122696, 122698, 122701, 122705, 122728, 122730, 122732, 122734, 122738, 122740, 122742, 122744, 122746, + 122748, 122750, 125184, 125185, 125186, 125187, 125188, 125189, 125190, 125191, 125192, 125193, 125194, 125195, 125196, 125197, + 125198, 125199, 125200, 125201, 125202, 125203, 125204, 125205, 125206, 125207, 125208, 125209, 125210, 125211, 125212, 125213, + 125214, 125215, 125216, 125217, +]; + +/** The simple lowercase mapping of UnicodeData.txt, field 13: where the value of each key starts in LOWERCASE_DATA, and its end. */ +export const LOWERCASE_START: readonly number[] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, + 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, + 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, + 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, + 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, + 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, + 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, + 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, + 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, + 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, + 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, + 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, + 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, + 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, + 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, + 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, + 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, + 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, + 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, + 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, + 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, + 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, + 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, + 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, + 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, + 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, + 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, + 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, + 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, + 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, + 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, + 576, 577, 578, 579, 580, 581, 582, 583, 584, 585, 586, 587, 588, 589, 590, 591, + 592, 593, 594, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, + 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, + 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, + 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, + 656, 657, 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, + 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, + 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, + 704, 705, 706, 707, 708, 709, 710, 711, 712, 713, 714, 715, 716, 717, 718, 719, + 720, 721, 722, 723, 724, 725, 726, 727, 728, 729, 730, 731, 732, 733, 734, 735, + 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, + 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, + 768, 769, 770, 771, 772, 773, 774, 775, 776, 777, 778, 779, 780, 781, 782, 783, + 784, 785, 786, 787, 788, 789, 790, 791, 792, 793, 794, 795, 796, 797, 798, 799, + 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, + 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, + 832, 833, 834, 835, 836, 837, 838, 839, 840, 841, 842, 843, 844, 845, 846, 847, + 848, 849, 850, 851, 852, 853, 854, 855, 856, 857, 858, 859, 860, 861, 862, 863, + 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, + 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, + 896, 897, 898, 899, 900, 901, 902, 903, 904, 905, 906, 907, 908, 909, 910, 911, + 912, 913, 914, 915, 916, 917, 918, 919, 920, 921, 922, 923, 924, 925, 926, 927, + 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, + 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, + 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 971, 972, 973, 974, 975, + 976, 977, 978, 979, 980, 981, 982, 983, 984, 985, 986, 987, 988, 989, 990, 991, + 992, 993, 994, 995, 996, 997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, + 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, + 1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, + 1040, 1041, 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051, 1052, 1053, 1054, 1055, + 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, + 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, + 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, + 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, + 1120, 1121, 1122, 1123, 1124, 1125, 1126, 1127, 1128, 1129, 1130, 1131, 1132, 1133, 1134, 1135, + 1136, 1137, 1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1146, 1147, 1148, 1149, 1150, 1151, + 1152, 1153, 1154, 1155, 1156, 1157, 1158, 1159, 1160, 1161, 1162, 1163, 1164, 1165, 1166, 1167, + 1168, 1169, 1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179, 1180, 1181, 1182, 1183, + 1184, 1185, 1186, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1195, 1196, 1197, 1198, 1199, + 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, + 1216, 1217, 1218, 1219, 1220, 1221, 1222, 1223, 1224, 1225, 1226, 1227, 1228, 1229, 1230, 1231, + 1232, 1233, 1234, 1235, 1236, 1237, 1238, 1239, 1240, 1241, 1242, 1243, 1244, 1245, 1246, 1247, + 1248, 1249, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, 1259, 1260, 1261, 1262, 1263, + 1264, 1265, 1266, 1267, 1268, 1269, 1270, 1271, 1272, 1273, 1274, 1275, 1276, 1277, 1278, 1279, + 1280, 1281, 1282, 1283, 1284, 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 1295, + 1296, 1297, 1298, 1299, 1300, 1301, 1302, 1303, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, + 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321, 1322, 1323, 1324, 1325, 1326, 1327, + 1328, 1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, + 1344, 1345, 1346, 1347, 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, + 1360, 1361, 1362, 1363, 1364, 1365, 1366, 1367, 1368, 1369, 1370, 1371, 1372, 1373, 1374, 1375, + 1376, 1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, + 1392, 1393, 1394, 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, + 1408, 1409, 1410, 1411, 1412, 1413, 1414, 1415, 1416, 1417, 1418, 1419, 1420, 1421, 1422, 1423, + 1424, 1425, 1426, 1427, 1428, 1429, 1430, 1431, 1432, 1433, 1434, 1435, 1436, 1437, 1438, 1439, + 1440, 1441, 1442, 1443, 1444, 1445, 1446, 1447, 1448, 1449, 1450, 1451, 1452, 1453, 1454, 1455, + 1456, 1457, 1458, 1459, 1460, 1461, 1462, 1463, 1464, 1465, 1466, 1467, 1468, 1469, 1470, 1471, + 1472, 1473, 1474, 1475, 1476, 1477, 1478, 1479, 1480, 1481, 1482, 1483, 1484, 1485, 1486, 1487, + 1488, 1489, 1490, 1491, 1492, 1493, 1494, 1495, 1496, 1497, 1498, 1499, 1500, 1501, 1502, 1503, + 1504, 1505, 1506, 1507, 1508, +]; + +/** The simple lowercase mapping of UnicodeData.txt, field 13: the values, one after another. */ +export const LOWERCASE_DATA: readonly number[] = [ + 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, + 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 224, 225, 226, 227, 228, 229, + 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, + 246, 248, 249, 250, 251, 252, 253, 254, 257, 259, 261, 263, 265, 267, 269, 271, + 273, 275, 277, 279, 281, 283, 285, 287, 289, 291, 293, 295, 297, 299, 301, 303, + 105, 307, 309, 311, 314, 316, 318, 320, 322, 324, 326, 328, 331, 333, 335, 337, + 339, 341, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 363, 365, 367, 369, + 371, 373, 375, 255, 378, 380, 382, 595, 387, 389, 596, 392, 598, 599, 396, 477, + 601, 603, 402, 608, 611, 617, 616, 409, 623, 626, 629, 417, 419, 421, 640, 424, + 643, 429, 648, 432, 650, 651, 436, 438, 658, 441, 445, 454, 454, 457, 457, 460, + 460, 462, 464, 466, 468, 470, 472, 474, 476, 479, 481, 483, 485, 487, 489, 491, + 493, 495, 499, 499, 501, 405, 447, 505, 507, 509, 511, 513, 515, 517, 519, 521, + 523, 525, 527, 529, 531, 533, 535, 537, 539, 541, 543, 414, 547, 549, 551, 553, + 555, 557, 559, 561, 563, 11365, 572, 410, 11366, 578, 384, 649, 652, 583, 585, 587, + 589, 591, 881, 883, 887, 1011, 940, 941, 942, 943, 972, 973, 974, 945, 946, 947, + 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 963, 964, + 965, 966, 967, 968, 969, 970, 971, 983, 985, 987, 989, 991, 993, 995, 997, 999, + 1001, 1003, 1005, 1007, 952, 1016, 1010, 1019, 891, 892, 893, 1104, 1105, 1106, 1107, 1108, + 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1072, 1073, 1074, 1075, 1076, + 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, + 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1103, 1121, 1123, 1125, 1127, 1129, + 1131, 1133, 1135, 1137, 1139, 1141, 1143, 1145, 1147, 1149, 1151, 1153, 1163, 1165, 1167, 1169, + 1171, 1173, 1175, 1177, 1179, 1181, 1183, 1185, 1187, 1189, 1191, 1193, 1195, 1197, 1199, 1201, + 1203, 1205, 1207, 1209, 1211, 1213, 1215, 1231, 1218, 1220, 1222, 1224, 1226, 1228, 1230, 1233, + 1235, 1237, 1239, 1241, 1243, 1245, 1247, 1249, 1251, 1253, 1255, 1257, 1259, 1261, 1263, 1265, + 1267, 1269, 1271, 1273, 1275, 1277, 1279, 1281, 1283, 1285, 1287, 1289, 1291, 1293, 1295, 1297, + 1299, 1301, 1303, 1305, 1307, 1309, 1311, 1313, 1315, 1317, 1319, 1321, 1323, 1325, 1327, 1377, + 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, + 1394, 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, + 1410, 1411, 1412, 1413, 1414, 11520, 11521, 11522, 11523, 11524, 11525, 11526, 11527, 11528, 11529, 11530, + 11531, 11532, 11533, 11534, 11535, 11536, 11537, 11538, 11539, 11540, 11541, 11542, 11543, 11544, 11545, 11546, + 11547, 11548, 11549, 11550, 11551, 11552, 11553, 11554, 11555, 11556, 11557, 11559, 11565, 43888, 43889, 43890, + 43891, 43892, 43893, 43894, 43895, 43896, 43897, 43898, 43899, 43900, 43901, 43902, 43903, 43904, 43905, 43906, + 43907, 43908, 43909, 43910, 43911, 43912, 43913, 43914, 43915, 43916, 43917, 43918, 43919, 43920, 43921, 43922, + 43923, 43924, 43925, 43926, 43927, 43928, 43929, 43930, 43931, 43932, 43933, 43934, 43935, 43936, 43937, 43938, + 43939, 43940, 43941, 43942, 43943, 43944, 43945, 43946, 43947, 43948, 43949, 43950, 43951, 43952, 43953, 43954, + 43955, 43956, 43957, 43958, 43959, 43960, 43961, 43962, 43963, 43964, 43965, 43966, 43967, 5112, 5113, 5114, + 5115, 5116, 5117, 7306, 4304, 4305, 4306, 4307, 4308, 4309, 4310, 4311, 4312, 4313, 4314, 4315, + 4316, 4317, 4318, 4319, 4320, 4321, 4322, 4323, 4324, 4325, 4326, 4327, 4328, 4329, 4330, 4331, + 4332, 4333, 4334, 4335, 4336, 4337, 4338, 4339, 4340, 4341, 4342, 4343, 4344, 4345, 4346, 4349, + 4350, 4351, 7681, 7683, 7685, 7687, 7689, 7691, 7693, 7695, 7697, 7699, 7701, 7703, 7705, 7707, + 7709, 7711, 7713, 7715, 7717, 7719, 7721, 7723, 7725, 7727, 7729, 7731, 7733, 7735, 7737, 7739, + 7741, 7743, 7745, 7747, 7749, 7751, 7753, 7755, 7757, 7759, 7761, 7763, 7765, 7767, 7769, 7771, + 7773, 7775, 7777, 7779, 7781, 7783, 7785, 7787, 7789, 7791, 7793, 7795, 7797, 7799, 7801, 7803, + 7805, 7807, 7809, 7811, 7813, 7815, 7817, 7819, 7821, 7823, 7825, 7827, 7829, 223, 7841, 7843, + 7845, 7847, 7849, 7851, 7853, 7855, 7857, 7859, 7861, 7863, 7865, 7867, 7869, 7871, 7873, 7875, + 7877, 7879, 7881, 7883, 7885, 7887, 7889, 7891, 7893, 7895, 7897, 7899, 7901, 7903, 7905, 7907, + 7909, 7911, 7913, 7915, 7917, 7919, 7921, 7923, 7925, 7927, 7929, 7931, 7933, 7935, 7936, 7937, + 7938, 7939, 7940, 7941, 7942, 7943, 7952, 7953, 7954, 7955, 7956, 7957, 7968, 7969, 7970, 7971, + 7972, 7973, 7974, 7975, 7984, 7985, 7986, 7987, 7988, 7989, 7990, 7991, 8000, 8001, 8002, 8003, + 8004, 8005, 8017, 8019, 8021, 8023, 8032, 8033, 8034, 8035, 8036, 8037, 8038, 8039, 8064, 8065, + 8066, 8067, 8068, 8069, 8070, 8071, 8080, 8081, 8082, 8083, 8084, 8085, 8086, 8087, 8096, 8097, + 8098, 8099, 8100, 8101, 8102, 8103, 8112, 8113, 8048, 8049, 8115, 8050, 8051, 8052, 8053, 8131, + 8144, 8145, 8054, 8055, 8160, 8161, 8058, 8059, 8165, 8056, 8057, 8060, 8061, 8179, 969, 107, + 229, 8526, 8560, 8561, 8562, 8563, 8564, 8565, 8566, 8567, 8568, 8569, 8570, 8571, 8572, 8573, + 8574, 8575, 8580, 9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, + 9437, 9438, 9439, 9440, 9441, 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449, 11312, 11313, 11314, + 11315, 11316, 11317, 11318, 11319, 11320, 11321, 11322, 11323, 11324, 11325, 11326, 11327, 11328, 11329, 11330, + 11331, 11332, 11333, 11334, 11335, 11336, 11337, 11338, 11339, 11340, 11341, 11342, 11343, 11344, 11345, 11346, + 11347, 11348, 11349, 11350, 11351, 11352, 11353, 11354, 11355, 11356, 11357, 11358, 11359, 11361, 619, 7549, + 637, 11368, 11370, 11372, 593, 625, 592, 594, 11379, 11382, 575, 576, 11393, 11395, 11397, 11399, + 11401, 11403, 11405, 11407, 11409, 11411, 11413, 11415, 11417, 11419, 11421, 11423, 11425, 11427, 11429, 11431, + 11433, 11435, 11437, 11439, 11441, 11443, 11445, 11447, 11449, 11451, 11453, 11455, 11457, 11459, 11461, 11463, + 11465, 11467, 11469, 11471, 11473, 11475, 11477, 11479, 11481, 11483, 11485, 11487, 11489, 11491, 11500, 11502, + 11507, 42561, 42563, 42565, 42567, 42569, 42571, 42573, 42575, 42577, 42579, 42581, 42583, 42585, 42587, 42589, + 42591, 42593, 42595, 42597, 42599, 42601, 42603, 42605, 42625, 42627, 42629, 42631, 42633, 42635, 42637, 42639, + 42641, 42643, 42645, 42647, 42649, 42651, 42787, 42789, 42791, 42793, 42795, 42797, 42799, 42803, 42805, 42807, + 42809, 42811, 42813, 42815, 42817, 42819, 42821, 42823, 42825, 42827, 42829, 42831, 42833, 42835, 42837, 42839, + 42841, 42843, 42845, 42847, 42849, 42851, 42853, 42855, 42857, 42859, 42861, 42863, 42874, 42876, 7545, 42879, + 42881, 42883, 42885, 42887, 42892, 613, 42897, 42899, 42903, 42905, 42907, 42909, 42911, 42913, 42915, 42917, + 42919, 42921, 614, 604, 609, 620, 618, 670, 647, 669, 43859, 42933, 42935, 42937, 42939, 42941, + 42943, 42945, 42947, 42900, 642, 7566, 42952, 42954, 612, 42957, 42959, 42961, 42963, 42965, 42967, 42969, + 42971, 411, 631, 636, 42998, 43851, 43852, 65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, + 65354, 65355, 65356, 65357, 65358, 65359, 65360, 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, + 65370, 66600, 66601, 66602, 66603, 66604, 66605, 66606, 66607, 66608, 66609, 66610, 66611, 66612, 66613, 66614, + 66615, 66616, 66617, 66618, 66619, 66620, 66621, 66622, 66623, 66624, 66625, 66626, 66627, 66628, 66629, 66630, + 66631, 66632, 66633, 66634, 66635, 66636, 66637, 66638, 66639, 66776, 66777, 66778, 66779, 66780, 66781, 66782, + 66783, 66784, 66785, 66786, 66787, 66788, 66789, 66790, 66791, 66792, 66793, 66794, 66795, 66796, 66797, 66798, + 66799, 66800, 66801, 66802, 66803, 66804, 66805, 66806, 66807, 66808, 66809, 66810, 66811, 66967, 66968, 66969, + 66970, 66971, 66972, 66973, 66974, 66975, 66976, 66977, 66979, 66980, 66981, 66982, 66983, 66984, 66985, 66986, + 66987, 66988, 66989, 66990, 66991, 66992, 66993, 66995, 66996, 66997, 66998, 66999, 67000, 67001, 67003, 67004, + 68800, 68801, 68802, 68803, 68804, 68805, 68806, 68807, 68808, 68809, 68810, 68811, 68812, 68813, 68814, 68815, + 68816, 68817, 68818, 68819, 68820, 68821, 68822, 68823, 68824, 68825, 68826, 68827, 68828, 68829, 68830, 68831, + 68832, 68833, 68834, 68835, 68836, 68837, 68838, 68839, 68840, 68841, 68842, 68843, 68844, 68845, 68846, 68847, + 68848, 68849, 68850, 68976, 68977, 68978, 68979, 68980, 68981, 68982, 68983, 68984, 68985, 68986, 68987, 68988, + 68989, 68990, 68991, 68992, 68993, 68994, 68995, 68996, 68997, 71872, 71873, 71874, 71875, 71876, 71877, 71878, + 71879, 71880, 71881, 71882, 71883, 71884, 71885, 71886, 71887, 71888, 71889, 71890, 71891, 71892, 71893, 71894, + 71895, 71896, 71897, 71898, 71899, 71900, 71901, 71902, 71903, 93792, 93793, 93794, 93795, 93796, 93797, 93798, + 93799, 93800, 93801, 93802, 93803, 93804, 93805, 93806, 93807, 93808, 93809, 93810, 93811, 93812, 93813, 93814, + 93815, 93816, 93817, 93818, 93819, 93820, 93821, 93822, 93823, 93883, 93884, 93885, 93886, 93887, 93888, 93889, + 93890, 93891, 93892, 93893, 93894, 93895, 93896, 93897, 93898, 93899, 93900, 93901, 93902, 93903, 93904, 93905, + 93906, 93907, 122689, 122697, 122699, 122702, 122706, 122729, 122731, 122733, 122735, 122739, 122741, 122743, 122745, 122747, + 122749, 122751, 125218, 125219, 125220, 125221, 125222, 125223, 125224, 125225, 125226, 125227, 125228, 125229, 125230, 125231, + 125232, 125233, 125234, 125235, 125236, 125237, 125238, 125239, 125240, 125241, 125242, 125243, 125244, 125245, 125246, 125247, + 125248, 125249, 125250, 125251, +]; + /** The characters of an emoji variation sequence with U+FE0E, sorted. */ export const VS15_BASES: readonly number[] = [ 35, 42, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 169, 174, 8252, 8265, diff --git a/src/lib/dkc/pathrule.ts b/src/lib/dkc/pathrule.ts index 3da22f9..f9f63ba 100644 --- a/src/lib/dkc/pathrule.ts +++ b/src/lib/dkc/pathrule.ts @@ -22,6 +22,9 @@ import { FOLDING_DATA, FOLDING_KEYS, FOLDING_START, + LOWERCASE_DATA, + LOWERCASE_KEYS, + LOWERCASE_START, IGNORABLE_RANGES, UNICODE_VERSION, VS15_BASES, @@ -206,6 +209,16 @@ export function fold(s: string): string { return fromCodePoints(out); } +/** + * The simple lowercase mapping of r in Unicode 18.0.0, field 13 of + * UnicodeData.txt, or r itself: Go pathrule.Lower. The key of words uses it + * (spec §38.1). + */ +export function lower(r: number): number { + const l = lookup(LOWERCASE_KEYS, LOWERCASE_START, LOWERCASE_DATA, r); + return l === undefined ? r : l[0]!; +} + // The whitelist of R4: the Default_Ignorable_Code_Point that a path may hold // (spec §29.5). const ZWNJ = 0x200c; @@ -498,6 +511,7 @@ export function canonicalTables(): string { for (const [name, keys, start, data] of [ ['decomp', DECOMPOSITION_KEYS, DECOMPOSITION_START, DECOMPOSITION_DATA], ['fold', FOLDING_KEYS, FOLDING_START, FOLDING_DATA], + ['lower', LOWERCASE_KEYS, LOWERCASE_START, LOWERCASE_DATA], ] as const) { keys.forEach((k, i) => { lines.push(`${name} ${hex4(k)}` + data.slice(start[i]!, start[i + 1]!).map((r) => ' ' + hex4(r)).join('')); diff --git a/src/lib/dkc/testing/capsule-vectors.json b/src/lib/dkc/testing/capsule-vectors.json index 04dd84f..b35c1bd 100644 --- a/src/lib/dkc/testing/capsule-vectors.json +++ b/src/lib/dkc/testing/capsule-vectors.json @@ -1318,8 +1318,8 @@ "unlock_at": "2023-08-23T15:59:24Z", "now": "2023-08-23T15:09:27Z", "length": 1, - "ts": "capsule: time_only takes no recipients and no portable key", - "go": "capsule: time_only takes no recipients and no portable key" + "ts": "capsule: time_only takes no recipients, no portable key and no key of words", + "go": "capsule: time_only takes no recipients, no portable key and no key of words" }, { "name": "time_only with a portable key", @@ -1328,8 +1328,8 @@ "unlock_at": "2023-08-23T15:59:24Z", "now": "2023-08-23T15:09:27Z", "length": 1, - "ts": "capsule: time_only takes no recipients and no portable key", - "go": "capsule: time_only takes no recipients and no portable key" + "ts": "capsule: time_only takes no recipients, no portable key and no key of words", + "go": "capsule: time_only takes no recipients, no portable key and no key of words" }, { "name": "time_and_key without credentials", @@ -1337,8 +1337,8 @@ "unlock_at": "2023-08-23T15:59:24Z", "now": "2023-08-23T15:09:27Z", "length": 1, - "ts": "capsule: time_and_key needs at least one recipient or a portable key", - "go": "capsule: time_and_key needs at least one recipient or a portable key" + "ts": "capsule: time_and_key needs at least one recipient, a portable key or a key of words", + "go": "capsule: time_and_key needs at least one recipient, a portable key or a key of words" }, { "name": "16 recipients and a portable key", @@ -1365,8 +1365,8 @@ "unlock_at": "2023-08-23T15:59:24Z", "now": "2023-08-23T15:09:27Z", "length": 1, - "ts": "capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given", - "go": "capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given" + "ts": "capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given", + "go": "capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given" }, { "name": "17 recipients", @@ -1393,8 +1393,8 @@ "unlock_at": "2023-08-23T15:59:24Z", "now": "2023-08-23T15:09:27Z", "length": 1, - "ts": "capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given", - "go": "capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given" + "ts": "capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given", + "go": "capsule: time_and_key takes at most 16 credentials, recipients, portable key and key of words together; 17 given" }, { "name": "a recipient with bit 255", diff --git a/src/lib/dkc/wordkey.test.ts b/src/lib/dkc/wordkey.test.ts new file mode 100644 index 0000000..f25a148 --- /dev/null +++ b/src/lib/dkc/wordkey.test.ts @@ -0,0 +1,75 @@ +// Tests of wordkey.ts: the words as Go wordkey reads them, with the tables of +// Unicode 18.0.0; the checks of a writer, with the errors of Go; and the +// identity derived from them, against the PBKDF2 of Node and the vector of +// spec §38.1. + +import { pbkdf2Sync } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { fromHex } from './bytes.ts'; +import { checkWords, countedWords, hiddenCodePoint, normalizeWords, quickWords, WORD_KEY_ROUNDS, wordKey } from './wordkey.ts'; + +const CHAIN = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971'; +const CAPSULE_ID = fromHex('000102030405060708090a0b0c0d0e0f'); +const cp = (r: number): string => String.fromCodePoint(r); + +describe('normalizeWords', () => { + it('ignores case, accents and extra spaces, as Go wordkey.Normalize does', async () => { + // The case of TestNormalize of datekeys-go: Σ in lower case on its own, + // not as a final sigma, and İ without its dot. + expect(await normalizeWords(` Ábaco${cp(0xa0)}ÁRBOL${cp(9)}niño ΣΑΣ İ `)).toEqual(['abaco', 'arbol', 'nino', 'σασ', 'i']); + expect(await normalizeWords(' ')).toEqual([]); + expect(await normalizeWords(`a${cp(0xfeff)}b`)).toEqual([`a${cp(0xfeff)}b`]); + }); + + it('lowers with the tables of Unicode 18.0.0, not with those of the platform', async () => { + // U+A7CB, added in Unicode 16.0, lowers to U+0264, which Go 1.26 does not know. + expect(await normalizeWords(cp(0xa7cb))).toEqual([cp(0x264)]); + }); +}); + +describe('quickWords and countedWords', () => { + it('read the words with the tables of the platform, and count the different ones of 3 letters or more', () => { + expect(quickWords(' Perro LUNA casa verde trén mar ')).toEqual(['perro', 'luna', 'casa', 'verde', 'tren', 'mar']); + expect(quickWords('')).toEqual([]); + expect(countedWords(quickWords('de la casa al mar en tren verde casa'))).toBe(4); + }); + + it('finds the code points a person cannot see, but not white space', () => { + expect(hiddenCodePoint(`perro${cp(9)}luna${cp(0xa0)}casa`)).toBeUndefined(); + expect(hiddenCodePoint(`perro${cp(0x200b)}luna`)).toBe(0x200b); + expect(hiddenCodePoint(`perro${cp(1)}`)).toBe(1); + }); +}); + +describe('checkWords', () => { + it('accepts six different words, and refuses fewer and what a person cannot see, with the errors of Go', async () => { + await expect(checkWords(await normalizeWords('Perro LUNA casa verde trén mar'))).resolves.toBeUndefined(); + const six = 'perro luna casa verde tren mar'; + const cases: [string, string][] = [ + ['uno dos tres', 'not 3'], + ['a b c d e f g h', 'not 0'], + ['perro perro perro perro perro perro', 'not 1'], + ['de la casa al mar en tren verde', 'not 4'], + [six + cp(0x200b), 'invisible character U+200B'], + [six + cp(1), 'control character U+0001'], + [six + cp(0x9f), 'control character U+009F'], + [six + cp(0x378), 'U+0378, unassigned in Unicode 18.0.0'], + ]; + for (const [text, want] of cases) await expect(checkWords(await normalizeWords(text))).rejects.toThrow(want); + }); +}); + +describe('wordKey', () => { + it('is PBKDF2-SHA256 of the words joined by one space, salted with the chain, the round and capsule_id', async () => { + const words = await normalizeWords('perro luna casa verde tren mar'); + const key = await wordKey(words, CHAIN, 1000, CAPSULE_ID); + const want = pbkdf2Sync(words.join(' '), `DateKeys llave de palabras v2|${CHAIN}|1000|000102030405060708090a0b0c0d0e0f`, WORD_KEY_ROUNDS, 32, 'sha256'); + expect(Buffer.from(key).toString('hex')).toBe(want.toString('hex')); + // The vector of spec §38.1 and of TestKeyVector of datekeys-go. + expect(Buffer.from(key).toString('hex')).toBe('fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41'); + const other = CAPSULE_ID.slice(); + other[15]! ^= 1; + expect(Buffer.from(await wordKey(words, CHAIN, 1001, CAPSULE_ID)).equals(Buffer.from(key))).toBe(false); + expect(Buffer.from(await wordKey(words, CHAIN, 1000, other)).equals(Buffer.from(key))).toBe(false); + }); +}); diff --git a/src/lib/dkc/wordkey.ts b/src/lib/dkc/wordkey.ts new file mode 100644 index 0000000..4321ca1 --- /dev/null +++ b/src/lib/dkc/wordkey.ts @@ -0,0 +1,120 @@ +// The key of words (spec §38.1): an X25519 identity derived from words a +// person chooses, so that a time_and_key capsule opens with them instead of +// a .dkk file or an age identity. The words are normalized with the Unicode +// 18.0.0 tables of pathrule.ts, never with those of the platform, so that +// case, accents and extra spaces do not matter; and stretched with +// PBKDF2-SHA256 of Web Crypto, WORD_KEY_ROUNDS rounds, salted with the chain, +// the round and the capsule_id of the capsule, so that each capsule needs its +// own attack, even among the many of a popular round. It is Go wordkey, byte +// for byte. The tables load on demand, so the functions that need them are +// async. Once the date has come, whoever holds the .dkc can try words +// offline: words of the person's own are weaker than random ones. + +import { toHex } from './bytes.ts'; + +/** The fewest different words of MIN_LETTERS characters or more that a writer accepts. */ +export const MIN_WORDS = 6; +/** The fewest characters of a word that counts toward MIN_WORDS. */ +export const MIN_LETTERS = 3; +/** The rounds of PBKDF2-SHA256, OWASP's figure for 2023. */ +export const WORD_KEY_ROUNDS = 600_000; + +// The white space at which the words are split: Go's unicode.IsSpace, the +// list of §38.1. +const SPACES = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0x85, 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000]); +for (let r = 0x2000; r <= 0x200a; r++) SPACES.add(r); + +const hex4 = (r: number): string => r.toString(16).toUpperCase().padStart(4, '0'); + +// The words of text, with nfd and lower as the normalization. +function split(text: string, nfd: (s: string) => string, lower: (ch: string) => string): string[] { + const words: string[] = []; + let word = ''; + for (const ch of nfd(text)) { + const r = ch.codePointAt(0)!; + if (r >= 0x300 && r <= 0x36f) continue; + if (SPACES.has(r)) { + if (word !== '') words.push(word); + word = ''; + } else word += lower(ch); + } + if (word !== '') words.push(word); + return words; +} + +/** + * The words of a text, as Go wordkey.Normalize reads them: its NFD by the + * tables of pathrule.ts, without the combining marks U+0300 to U+036F, each + * code point in lower case by its simple mapping of Unicode 18.0.0, split at + * white space. + */ +export async function normalizeWords(text: string): Promise { + const { nfd, lower } = await import('./pathrule.ts'); + return split(text, nfd, (ch) => String.fromCodePoint(lower(ch.codePointAt(0)!))); +} + +/** + * The words of a text with the normalization and the lower case of the + * platform, for a form that cannot wait for the tables. They are those of + * normalizeWords save for the code points whose decomposition or lower case + * the platform does not know as Unicode 18.0.0 does: the writer checks the + * words again with normalizeWords. + */ +export function quickWords(text: string): string[] { + return split(text, (s) => s.normalize('NFD'), (ch) => ch.toLowerCase()); +} + +// The code points a person cannot see, by the tables of the platform. +const HIDDEN = /^[\p{Cc}\p{Default_Ignorable_Code_Point}\p{Cn}]$/u; + +/** + * The first code point of text that a person cannot see, by the tables of + * the platform: a control other than white space, a + * Default_Ignorable_Code_Point or an unassigned one. For a form that cannot + * wait for the tables: checkWords decides. + */ +export function hiddenCodePoint(text: string): number | undefined { + for (const ch of text) { + const r = ch.codePointAt(0)!; + if (!SPACES.has(r) && HIDDEN.test(ch)) return r; + } + return undefined; +} + +/** The number of different words of MIN_LETTERS characters or more: those that count toward MIN_WORDS. */ +export function countedWords(words: readonly string[]): number { + return new Set(words.filter((w) => [...w].length >= MIN_LETTERS)).size; +} + +/** + * Why a writer refuses words, as normalizeWords returns them, for a key: a + * control, a Default_Ignorable_Code_Point or a code point unassigned in + * Unicode 18.0.0, which a person cannot see and would not type again, or + * fewer than MIN_WORDS words that count. It throws the error of Go + * wordkey.Check. + */ +export async function checkWords(words: readonly string[]): Promise { + const [{ isAssigned, isDefaultIgnorable }, { UNICODE_VERSION }] = await Promise.all([import('./pathrule.ts'), import('./pathrule-tables.ts')]); + for (const w of words) { + for (const ch of w) { + const r = ch.codePointAt(0)!; + if (r <= 0x1f || (r >= 0x7f && r <= 0x9f)) throw new Error(`wordkey: the words hold the control character U+${hex4(r)}`); + if (isDefaultIgnorable(r)) throw new Error(`wordkey: the words hold the invisible character U+${hex4(r)}`); + if (!isAssigned(r)) throw new Error(`wordkey: the words hold U+${hex4(r)}, unassigned in Unicode ${UNICODE_VERSION}`); + } + } + const n = countedWords(words); + if (n < MIN_WORDS) throw new Error(`wordkey: a key of words needs at least ${MIN_WORDS} different words of ${MIN_LETTERS} or more letters, not ${n}`); +} + +/** + * The raw X25519 identity of the words for the capsule capsuleId, of the + * round `round` of the chain whose hash is chainHash, in hexadecimal. The + * caller wipes it. + */ +export async function wordKey(words: readonly string[], chainHash: string, round: number, capsuleId: Uint8Array): Promise { + const te = new TextEncoder(); + const material = await crypto.subtle.importKey('raw', te.encode(words.join(' ')), 'PBKDF2', false, ['deriveBits']); + const salt = te.encode(`DateKeys llave de palabras v2|${chainHash}|${round}|${toHex(capsuleId)}`); + return new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: WORD_KEY_ROUNDS }, material, 256)); +} diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts index 99dabe8..8fb5bca 100644 --- a/src/lib/dkc/writer.ts +++ b/src/lib/dkc/writer.ts @@ -19,7 +19,7 @@ import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts'; import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts'; import { decryptAll } from './agefile.ts'; import { AREA_LEN, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, MAX_HEAD_LEN, parseBodyFrame } from './body.ts'; -import { compareBytes, copyBytes, equalBytes, goQuote, utf8Bytes, utf8Length } from './bytes.ts'; +import { compareBytes, copyBytes, equalBytes, goQuote, toHex, utf8Bytes, utf8Length } from './bytes.ts'; import { decodeControl, encodeControl } from './control.ts'; import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInstant, resolveDateKey, roundTime } from './datekey.ts'; import { sha256Hasher } from './digest.ts'; @@ -37,6 +37,7 @@ import { permute, type RandomWords } from './random.ts'; import { checkX25519Recipient, formatX25519Recipient } from './recipient.ts'; import { encodeSecurity, evaluateSecurity } from './security.ts'; import { timeRecipient } from './tlock.ts'; +import { checkWords, wordKey } from './wordkey.ts'; import { x25519PublicKey } from './x25519.ts'; /** The largest .dkc that encrypt returns in memory; a larger one needs an output stream. */ @@ -79,6 +80,13 @@ export interface EncryptOptions { readonly recipients?: readonly Uint8Array[]; /** Generates a fresh I_ACCESS for this capsule only, returned as a .dkk (§38). */ readonly newPortableKey?: boolean; + /** + * The words of a key of words, as normalizeWords returns them and + * checkWords accepts them, for time_and_key (§38.1). The writer derives + * their identity once it has drawn capsule_id, which salts it, and adds + * its recipient to the credentials. + */ + readonly words?: readonly string[]; /** L, the exact number of bytes of the source: its size for a Blob or a Uint8Array, required for a stream (§62.1 rule 6). */ readonly length?: number; /** The padding rule; reforzado when omitted (§29.1, §62.1 rule 10). */ @@ -420,6 +428,8 @@ interface Sealer { /** The recipients, checked; I_ACCESS is drawn when the capsule is sealed. */ readonly credentials: readonly Uint8Array[]; readonly portable: boolean; + /** The words of a key of words, checked; their identity is derived once capsule_id is drawn. */ + readonly words: readonly string[]; readonly code: Padding; readonly critical: Extension[]; readonly noncritical: Extension[]; @@ -449,6 +459,10 @@ async function newSealer(opts: EncryptOptions, length: number): Promise const controlNoncritical = copyExtensions(opts.controlNoncritical); const { policy, output, progress } = opts; const portable = opts.newPortableKey === true; + const words = (opts.words ?? []).map((w, i) => { + if (typeof w !== 'string') throw new TypeError(`encrypt: word ${i} is not a string`); + return w; + }); const code = opts.padding === undefined ? REFORZADO : opts.padding; // Step 3: the profile. @@ -470,9 +484,17 @@ async function newSealer(opts: EncryptOptions, length: number): Promise throw new DateKeysError('ERR_ROUND_MISMATCH', `capsule: resolved round ${dateKey.round} opens before the requested time`); } - // Step 7: the credentials. - const credentials = accessRecipients(policy, recipients, portable); - return { profile, policy, credentials, portable, code, critical, noncritical, controlCritical, controlNoncritical, output, progress, dateKey, unlock }; + // Step 7: the credentials, and the words of a key of words as Go + // wordkey.Check accepts them (§38.1). + if (policy === TIME_AND_KEY && words.length > 0) { + try { + await checkWords(words); + } catch (e) { + throw new Error(`capsule: ${(e as Error).message}`); + } + } + const credentials = accessRecipients(policy, recipients, portable, words.length > 0); + return { profile, policy, credentials, portable, words, code, critical, noncritical, controlCritical, controlNoncritical, output, progress, dateKey, unlock }; } // The padding rule and L, as PaddedLength checks them. @@ -507,6 +529,13 @@ async function seal( // Step 8: capsule_id and I_PAYLOAD. const capsuleId = copyBytes(drawn(draws.capsuleId(), 16, 'capsule_id')); + // The key of words is salted with capsule_id (§38.1), so its recipient + // joins the credentials only now; its private half is wiped with the rest. + if (s.words.length > 0) { + const id = await wordKey(s.words, toHex(profile.chainHash), dateKey.round, capsuleId); + wipe.push(id); + credentials.push(x25519PublicKey(id)); + } const payloadId = drawn(draws.payloadIdentity(), 32, 'I_PAYLOAD'); wipe.push(payloadId); const payloadRecipient = x25519PublicKey(payloadId); @@ -713,16 +742,16 @@ function drawn(b: Uint8Array, n: number, what: string): Uint8Array { // The credentials of INNER_ACCESS_AGE, as accessRecipients of the Go // reference: from 1 to 16, X25519, canonical, not of low order, none twice // (§37, §39, §62.1 rule 3). I_ACCESS is added by the caller. -function accessRecipients(policy: Policy, recipients: readonly Uint8Array[], portable: boolean): Uint8Array[] { +function accessRecipients(policy: Policy, recipients: readonly Uint8Array[], portable: boolean, words: boolean): Uint8Array[] { if (policy === TIME_ONLY) { - if (recipients.length !== 0 || portable) throw new Error('capsule: time_only takes no recipients and no portable key'); + if (recipients.length !== 0 || portable || words) throw new Error('capsule: time_only takes no recipients, no portable key and no key of words'); return []; } if (policy !== TIME_AND_KEY) throw new Error(`capsule: unknown access policy ${policy}`); - const n = recipients.length + (portable ? 1 : 0); - if (n === 0) throw new Error('capsule: time_and_key needs at least one recipient or a portable key'); + const n = recipients.length + (portable ? 1 : 0) + (words ? 1 : 0); + if (n === 0) throw new Error('capsule: time_and_key needs at least one recipient, a portable key or a key of words'); if (n > ACCESS_SLOTS) { - throw new Error(`capsule: time_and_key takes at most ${ACCESS_SLOTS} credentials, recipients and portable key together; ${n} given`); + throw new Error(`capsule: time_and_key takes at most ${ACCESS_SLOTS} credentials, recipients, portable key and key of words together; ${n} given`); } const out: Uint8Array[] = []; for (const [i, r] of recipients.entries()) { diff --git a/src/lib/inspector/create-input.test.ts b/src/lib/inspector/create-input.test.ts index 8bf5740..7676b6f 100644 --- a/src/lib/inspector/create-input.test.ts +++ b/src/lib/inspector/create-input.test.ts @@ -28,6 +28,7 @@ const input = (extra: Partial = {}): CreateInput => ({ recipients: '', portable: true, words: '', + wordsAgain: '', ...extra, }); const recipient = (i: number): string => formatX25519Recipient(x25519PublicKey(sampleIdentity(i))); @@ -198,7 +199,7 @@ describe('planCapsule', () => { const many = `Una cápsula admite como mucho 16 llaves, contando el fichero .dkk y las palabras.`; expect(problem({ policy: TIME_AND_KEY, recipients: lines(16), portable: true })).toEqual(['recipients', `${many} Hay 16 personas.`]); expect(problem({ policy: TIME_AND_KEY, recipients: lines(17), portable: false })).toEqual(['recipients', `${many} Hay 17 personas.`]); - expect(problem({ policy: TIME_AND_KEY, recipients: lines(15), portable: true, words: 'a b c d e f' })).toEqual(['recipients', `${many} Hay 15 personas.`]); + expect(problem({ policy: TIME_AND_KEY, recipients: lines(15), portable: true, words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna casa verde tren mar' })).toEqual(['recipients', `${many} Hay 15 personas.`]); expect(problem({ policy: TIME_AND_KEY, recipients: `${recipient(1)}\nage1nope`, portable: true })).toEqual([ 'recipients', 'La línea 2 no es un destinatario de age (age1…).', @@ -207,12 +208,24 @@ describe('planCapsule', () => { 'portable', 'Sin personas ni palabras, el fichero de la llave es la única forma de abrir la cápsula: déjalo marcado, escribe tus palabras o añade una persona.', ]); - // Words are a key of their own, of at least six. - const worded = planCapsule(input({ policy: TIME_AND_KEY, portable: false, words: ' Perro LUNA casa verde trén mar ' }), GENESIS_MS); - expect(worded.ok && worded.plan.words).toEqual(['perro', 'luna', 'casa', 'verde', 'tren', 'mar']); - expect(problem({ policy: TIME_AND_KEY, words: 'uno dos tres' })).toEqual(['words', 'Escribe al menos 6 palabras: con menos, cualquiera puede adivinarlas.']); + // Words are a key of their own, of at least six different words of three + // letters or more, written twice, and with nothing a person cannot see. + const typed = ' Perro LUNA casa verde trén mar '; + const worded = planCapsule(input({ policy: TIME_AND_KEY, portable: false, words: typed, wordsAgain: 'perro luna casa verde tren mar' }), GENESIS_MS); + expect(worded.ok && [worded.plan.words, worded.plan.wordsText]).toEqual([['perro', 'luna', 'casa', 'verde', 'tren', 'mar'], typed]); + const few = 'Escribe al menos 6 palabras distintas de 3 letras o más: con menos, cualquiera puede adivinarlas.'; + expect(problem({ policy: TIME_AND_KEY, words: 'uno dos tres' })).toEqual(['words', few]); + expect(problem({ policy: TIME_AND_KEY, words: 'de la casa al mar en tren verde' })).toEqual(['words', few]); + expect(problem({ policy: TIME_AND_KEY, words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna' })).toEqual([ + 'wordsAgain', + 'Escribe otra vez las mismas palabras, para comprobar que las recuerdas.', + ]); + expect(problem({ policy: TIME_AND_KEY, words: `perro luna casa verde tren mar${String.fromCodePoint(0x200b)}` })).toEqual([ + 'words', + 'Las palabras llevan un carácter que no se ve (U+200B): escríbelas a mano, sin pegarlas.', + ]); const timeOnly = planCapsule(input({ words: 'uno dos' }), GENESIS_MS); - expect(timeOnly.ok && timeOnly.plan.words).toEqual([]); + expect(timeOnly.ok && [timeOnly.plan.words, timeOnly.plan.wordsText]).toEqual([[], '']); // time_only ignores both. const plain = planCapsule(input({ recipients: 'not a recipient', portable: true }), GENESIS_MS); expect(plain.ok && [plain.plan.recipients, plain.plan.portable]).toEqual([[], false]); diff --git a/src/lib/inspector/create-input.ts b/src/lib/inspector/create-input.ts index df055fa..377f401 100644 --- a/src/lib/inspector/create-input.ts +++ b/src/lib/inspector/create-input.ts @@ -17,7 +17,7 @@ import { quicknet } from '../dkc/profile.ts'; import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts'; import { MAX_CAPSULE_FILES } from './create-files.ts'; import { formatByteCount, formatInteger, safeFileName } from './format.ts'; -import { MIN_WORDS, normalizeWords } from './wordkey.ts'; +import { countedWords, hiddenCodePoint, MIN_LETTERS, MIN_WORDS, quickWords } from '../dkc/wordkey.ts'; import { localToEpochMs } from './localtime.ts'; // The limits of the texts of a head, as pathrule.ts has them: that module @@ -29,7 +29,7 @@ const MAX_AUTHOR_BYTES = 256; export const SOON_MS = 3600_000; /** The inputs of the form. */ -export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable' | 'words'; +export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable' | 'words' | 'wordsAgain'; /** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */ export interface PlannedFile { @@ -68,6 +68,8 @@ export interface CreateInput { readonly portable: boolean; /** For time_and_key: the words of a key of words (wordkey.ts), '' for none. */ readonly words: string; + /** The words written again, to check that the person remembers them. */ + readonly wordsAgain: string; } /** Everything the page shows before encrypting, and what encrypt takes. */ @@ -89,6 +91,8 @@ export interface CapsulePlan { readonly portable: boolean; /** The words of a key of words, normalized; none for time_only. */ readonly words: readonly string[]; + /** The words as the person typed them, '' for none: the writer reads them with the tables of Unicode 18.0.0. */ + readonly wordsText: string; /** The files of the capsule, in the order of the list, and the texts of its head. */ readonly files: readonly PlannedFile[]; readonly comment: string; @@ -203,9 +207,20 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned { if (!read.ok) return fail('recipients', read.problem); recipients = read.keys; portable = input.portable; - words = normalizeWords(input.words); - if (words.length > 0 && words.length < MIN_WORDS) { - return fail('words', `Escribe al menos ${MIN_WORDS} palabras: con menos, cualquiera puede adivinarlas.`); + words = quickWords(input.words); + if (words.length > 0) { + const hidden = hiddenCodePoint(input.words); + if (hidden !== undefined) { + const name = `U+${hidden.toString(16).toUpperCase().padStart(4, '0')}`; + return fail('words', `Las palabras llevan un carácter que no se ve (${name}): escríbelas a mano, sin pegarlas.`); + } + if (countedWords(words) < MIN_WORDS) { + return fail('words', `Escribe al menos ${MIN_WORDS} palabras distintas de ${MIN_LETTERS} letras o más: con menos, cualquiera puede adivinarlas.`); + } + const again = quickWords(input.wordsAgain); + if (again.length !== words.length || again.some((w, i) => w !== words[i])) { + return fail('wordsAgain', 'Escribe otra vez las mismas palabras, para comprobar que las recuerdas.'); + } } const keys = recipients.length + (portable ? 1 : 0) + (words.length > 0 ? 1 : 0); if (keys === 0) { @@ -239,6 +254,7 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned { recipients, portable, words, + wordsText: words.length > 0 ? input.words : '', files: list, comment: headComment(input.comment), author: input.author, diff --git a/src/lib/inspector/creator.test.ts b/src/lib/inspector/creator.test.ts index b2cd009..97eedb1 100644 --- a/src/lib/inspector/creator.test.ts +++ b/src/lib/inspector/creator.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { decodeAccessKey } from '../dkc/accesskey.ts'; -import { concatBytes, toHex } from '../dkc/bytes.ts'; +import { concatBytes, fromHex, toHex } from '../dkc/bytes.ts'; import { TIME_AND_KEY } from '../dkc/header.ts'; import { open } from '../dkc/open.ts'; import { MemorySink } from '../dkc/sink.ts'; @@ -15,7 +15,7 @@ import { suppliedRelease } from '../dkc/release.ts'; import { h, readJSON } from '../dkc/testing/testdata.ts'; import { type CapsulePlan, chooseFiles, type CreateInput, type PlannedFile, planCapsule } from './create-input.ts'; import { createCapsule, CreateStopped } from './creator.ts'; -import { wordKey } from './wordkey.ts'; +import { normalizeWords, wordKey } from '../dkc/wordkey.ts'; import { quicknet } from '../dkc/profile.ts'; import type { TempFile } from './tempfile.ts'; @@ -46,7 +46,7 @@ const one = (size: number, mtime?: number) => chooseFiles([{ path: 'nota.txt', s // A plan for round 1000, whose release is published. function plan(extra: Partial = {}, nowMs = GENESIS_MS): CapsulePlan { const r = planCapsule( - { files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, words: '', ...extra }, + { files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, words: '', wordsAgain: '', ...extra }, nowMs, ); if (!r.ok) throw new Error(r.problem); @@ -162,10 +162,11 @@ describe('createCapsule', () => { }); it('adds the key of the words, which opens the capsule without a .dkk', async () => { - const p = plan({ files: one(4), policy: TIME_AND_KEY, portable: false, words: 'Perro luna casa verde tren mar' }); + const p = plan({ files: one(4), policy: TIME_AND_KEY, portable: false, words: 'Perro luna casa verde trén mar', wordsAgain: 'perro luna casa verde tren mar' }); const c = await createCapsule({ files: [blob(content(4))], plan: p, cancelled: () => false, now: genesis }); expect(c.dkk).toBeUndefined(); - const id = await wordKey(p.words, toHex(quicknet().chainHash), p.dateKey.round); + // Salted with the capsule_id that the writer drew (§38.1). + const id = await wordKey(await normalizeWords('perro luna casa verde tren mar'), toHex(quicknet().chainHash), p.dateKey.round, fromHex(c.capsuleId)); const sink = new MemorySink(); const r = await open(c.capsule, { source: suppliedRelease(RELEASE), now: () => ({ seconds: RELEASE.round * 3 + GENESIS_MS / 1000, nanos: 0 }), sink, identities: [id] }); expect([r.error, sink.opened?.files[0]]).toEqual([undefined, content(4)]); diff --git a/src/lib/inspector/creator.ts b/src/lib/inspector/creator.ts index 43b8e0c..f7738e7 100644 --- a/src/lib/inspector/creator.ts +++ b/src/lib/inspector/creator.ts @@ -12,8 +12,7 @@ import { encryptFiles } from '../dkc/encrypt.ts'; import type { CapsulePlan } from './create-input.ts'; import { systemClock } from './opener.ts'; import type { TempFile } from './tempfile.ts'; -import { wordKey } from './wordkey.ts'; -import { x25519PublicKey } from '../dkc/x25519.ts'; +import { normalizeWords } from '../dkc/wordkey.ts'; export interface CreateRequest { /** The person's files, one for each of plan.files and in its order, each read twice. */ @@ -116,14 +115,10 @@ export async function createCapsule(req: CreateRequest): Promise { ); }; const sources = plan.files.map((f, i) => ({ path: f.path, size: f.size, ...(f.mtime === undefined ? {} : { mtime: f.mtime }), open: () => first(i) })); - // The key of the words is one more recipient, derived for the round of - // the plan; its private half is wiped at once. - let recipients = plan.recipients; - if (plan.words.length > 0) { - const id = await wordKey(plan.words, toHex(quicknet().chainHash), plan.dateKey.round); - recipients = [...recipients, x25519PublicKey(id)]; - id.fill(0); - } + // The key of the words is one more credential: the writer derives it + // once it has drawn capsule_id, which salts it, from the words as the + // tables of Unicode 18.0.0 read them (§38.1). + const words = plan.wordsText === '' ? [] : await normalizeWords(plan.wordsText); let res: Awaited>; try { req.progress?.(1, 0, all); @@ -131,8 +126,9 @@ export async function createCapsule(req: CreateRequest): Promise { profile: quicknet(), unlockAt: plan.requested, policy: plan.policy, - recipients, + recipients: plan.recipients, newPortableKey: plan.portable, + ...(words.length === 0 ? {} : { words }), ...(plan.comment === '' ? {} : { comment: plan.comment }), ...(plan.author === '' ? {} : { author: plan.author }), now: req.now ?? systemClock, diff --git a/src/lib/inspector/opener.test.ts b/src/lib/inspector/opener.test.ts index 9120f5b..2a1565a 100644 --- a/src/lib/inspector/opener.test.ts +++ b/src/lib/inspector/opener.test.ts @@ -6,8 +6,7 @@ import { memoryFile } from '../dkc/testing/zip.ts'; import { openCapsule, type OpenRequest, parseIdentities, PREVIEW_BYTES, systemClock } from './opener.ts'; import { encryptFiles, fileSource } from '../dkc/encrypt.ts'; import { TIME_AND_KEY } from '../dkc/header.ts'; -import { x25519PublicKey } from '../dkc/x25519.ts'; -import { normalizeWords, wordKey } from './wordkey.ts'; +import { normalizeWords } from '../dkc/wordkey.ts'; import type { TempFile } from './tempfile.ts'; import { zipLayout } from './zip.ts'; import { zipEntries } from './zipsink.ts'; @@ -256,20 +255,23 @@ describe('openCapsule with words', () => { const f = fixture('format3_single'); const round = f.record.release.round; const chainHash = toHex(quicknet().chainHash); - const id = await wordKey(normalizeWords('perro luna casa verde tren mar'), chainHash, round); const res = await encryptFiles([fileSource('nota.txt', new Blob(['hola']))], { profile: quicknet(), unlockAt: roundTime(quicknet(), round), policy: TIME_AND_KEY, - recipients: [x25519PublicKey(id)], + words: await normalizeWords('perro luna casa verde tren mar'), now: () => ({ seconds: 1692803367, nanos: 0 }), }); - const withWords = (text: string): OpenRequest => ({ ...f.request, capsule: res.dkc!, words: { text, chainHash, round } }); + const capsuleId = toHex(res.capsuleId); + const withWords = (text: string, id = capsuleId): OpenRequest => ({ ...f.request, capsule: res.dkc!, words: { text, chainHash, round, capsuleId: id } }); const ok = await openCapsule(withWords(' Perro LUNA casa verde trén mar ')); expect(ok.ok && ok.opened.error).toBeUndefined(); const other = await openCapsule(withWords('gato luna casa verde tren mar')); expect(other.ok && other.opened.error !== undefined).toBe(true); const none = await openCapsule(withWords(' ')); expect(none.ok && none.opened.error !== undefined).toBe(true); + // The same words for another capsule_id give another key. + const elsewhere = await openCapsule(withWords('perro luna casa verde tren mar', '00'.repeat(16))); + expect(elsewhere.ok && elsewhere.opened.error !== undefined).toBe(true); }); }); diff --git a/src/lib/inspector/opener.ts b/src/lib/inspector/opener.ts index 267cb9d..974e25d 100644 --- a/src/lib/inspector/opener.ts +++ b/src/lib/inspector/opener.ts @@ -9,7 +9,8 @@ // demand with the Unicode tables of the paths. import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts'; -import { normalizeWords, wordKey } from './wordkey.ts'; +import { normalizeWords, wordKey } from '../dkc/wordkey.ts'; +import { fromHex } from '../dkc/bytes.ts'; import { sha256Stream } from '../dkc/digest.ts'; import type { Head } from '../dkc/head.ts'; import { open, type Opened } from '../dkc/open.ts'; @@ -31,8 +32,8 @@ export interface OpenRequest { readonly identities?: string; /** A .dkk file, for time_and_key. */ readonly accessKey?: Blob; - /** The words of a key of words, with the chain and the round of the capsule, for time_and_key. */ - readonly words?: { readonly text: string; readonly chainHash: string; readonly round: number }; + /** The words of a key of words, with the chain, the round and the capsule_id of the capsule, in hexadecimal, for time_and_key. */ + readonly words?: { readonly text: string; readonly chainHash: string; readonly round: number; readonly capsuleId: string }; /** * Where the plaintext of a capsule of format 1 or 2 goes, and the files of * a format 3 capsule through a ZipSink; memory when omitted. @@ -162,8 +163,8 @@ export async function openCapsule(req: OpenRequest): Promise { const parsed = parseIdentities(req.identities ?? ''); if (!parsed.ok) return { ...parsed, field: 'identities' }; const ids = parsed.ids; - const words = normalizeWords(req.words?.text ?? ''); - if (words.length > 0) ids.push(await wordKey(words, req.words!.chainHash, req.words!.round)); + const words = req.words === undefined ? [] : await normalizeWords(req.words.text); + if (words.length > 0) ids.push(await wordKey(words, req.words!.chainHash, req.words!.round, fromHex(req.words!.capsuleId))); try { let accessKeyFile: Uint8Array | undefined; if (req.accessKey !== undefined) { diff --git a/src/lib/inspector/wordkey.test.ts b/src/lib/inspector/wordkey.test.ts deleted file mode 100644 index ad80f00..0000000 --- a/src/lib/inspector/wordkey.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Tests of wordkey.ts: the words as the page reads them, and the identity -// derived from them, checked against the PBKDF2 of Node. - -import { pbkdf2Sync } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; -import { normalizeWords, WORD_KEY_ROUNDS, wordKey } from './wordkey.ts'; - -const CHAIN = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971'; - -describe('normalizeWords', () => { - it('ignores case, accents and extra spaces, as Go wordkey.Normalize does', () => { - const nbsp = String.fromCharCode(0xa0); - const tab = String.fromCharCode(9); - // The case of TestNormalize of datekeys-go: Σ in lower case on its own, - // not as a final sigma, and İ without its dot. - expect(normalizeWords(` Ábaco${nbsp}ÁRBOL${tab}niño ΣΑΣ İ `)).toEqual(['abaco', 'arbol', 'nino', 'σασ', 'i']); - expect(normalizeWords(' ')).toEqual([]); - expect(normalizeWords(`a${String.fromCharCode(0xfeff)}b`)).toEqual([`a${String.fromCharCode(0xfeff)}b`]); - }); -}); - -describe('wordKey', () => { - it('is PBKDF2-SHA256 of the words joined by one space, salted with the chain and the round', async () => { - const words = ['perro', 'luna', 'casa', 'verde', 'tren', 'mar']; - const key = await wordKey(words, CHAIN, 1000); - const want = pbkdf2Sync('perro luna casa verde tren mar', `DateKeys llave de palabras v1|${CHAIN}|1000`, WORD_KEY_ROUNDS, 32, 'sha256'); - expect(Buffer.from(key).toString('hex')).toBe(want.toString('hex')); - // The vector of TestKeyMatchesTypeScript of datekeys-go. - expect(Buffer.from(key).toString('hex')).toBe('be74aecd9ea734bfece963597a2269188a4ea8a1247bc381dffbd6a38a2c6376'); - expect(Buffer.from(await wordKey(words, CHAIN, 1001)).equals(Buffer.from(key))).toBe(false); - }); -}); diff --git a/src/lib/inspector/wordkey.ts b/src/lib/inspector/wordkey.ts deleted file mode 100644 index ea8ae4a..0000000 --- a/src/lib/inspector/wordkey.ts +++ /dev/null @@ -1,50 +0,0 @@ -// The key of words (docs/spec_v0.11/llave_palabras.md): an X25519 identity -// derived from words the person knows, so that a capsule that needs a key -// opens with them, instead of a .dkk file or an age identity. The words are -// normalized so that case, accents and extra spaces do not matter, and -// stretched with PBKDF2-SHA256 of Web Crypto, WORD_KEY_ROUNDS rounds, salted -// with the chain and the round of the capsule, so that each date needs its -// own attack. Once the date has come, whoever holds the .dkc can try words -// offline: a phrase of the person's own is weaker than random words, and the -// page asks for at least MIN_WORDS. - -/** The fewest words the page accepts. */ -export const MIN_WORDS = 6; -/** The rounds of PBKDF2-SHA256, OWASP's figure for 2023. */ -export const WORD_KEY_ROUNDS = 600_000; - -// The white space at which the words are split: Go's unicode.IsSpace, so -// that the CLI of the reference reads the same words. -const SPACES = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0x85, 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000]); -for (let r = 0x2000; r <= 0x200a; r++) SPACES.add(r); - -/** - * The words of a text, as the CLI of the reference reads them (Go's - * wordkey.Normalize): its NFD without the combining marks U+0300 to U+036F, - * each code point in lower case on its own, split at white space. - */ -export function normalizeWords(text: string): string[] { - const words: string[] = []; - let word = ''; - for (const ch of text.normalize('NFD')) { - const r = ch.codePointAt(0)!; - if (r >= 0x300 && r <= 0x36f) continue; - if (SPACES.has(r)) { - if (word !== '') words.push(word); - word = ''; - } else word += ch.toLowerCase(); - } - if (word !== '') words.push(word); - return words; -} - -/** - * The raw X25519 identity of the words, for a capsule of the round `round` - * of the chain `chainHash`, in hexadecimal. The caller wipes it. - */ -export async function wordKey(words: readonly string[], chainHash: string, round: number): Promise { - const te = new TextEncoder(); - const material = await crypto.subtle.importKey('raw', te.encode(words.join(' ')), 'PBKDF2', false, ['deriveBits']); - const salt = te.encode(`DateKeys llave de palabras v1|${chainHash}|${round}`); - return new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: WORD_KEY_ROUNDS }, material, 256)); -} diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 4ce5ca4..76079c7 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -30,6 +30,7 @@ withIncluded, withPath, } from '$lib/inspector/create-files.ts'; + import { quickWords } from '$lib/dkc/wordkey.ts'; import { type CapsulePlan, chooseFiles, type CreateField, downloadName, planCapsule, readRecipients } from '$lib/inspector/create-input.ts'; import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, viewerTimeZone } from '$lib/inspector/format.ts'; import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts'; @@ -93,6 +94,7 @@ let recipients = $state(''); let portable = $state(true); let words = $state(''); + let wordsAgain = $state(''); // Read on mount, every second while the tab is visible and nothing is // being written, and when the person creates the capsule: the page is // prerendered, so never at build time. @@ -127,6 +129,7 @@ recipients: 'recipients-input', portable: 'portable-input', words: 'words-input', + wordsAgain: 'words-again-input', }; // The rules of the paths and the texts, loaded with the first file or text. @@ -153,7 +156,7 @@ // What happened with the last files chosen or dropped, shown by the list: // the status line only reaches screen readers. let notice = $state(''); - const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words }, nowMs)); + const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words, wordsAgain }, nowMs)); const plan = $derived(planned.ok ? planned.plan : undefined); const commentCheck = $derived(checker?.commentProblem(comment)); const authorCheck = $derived(checker?.authorProblem(author)); @@ -476,7 +479,7 @@ // reader for the paths and the texts. nowMs = Date.now(); const files = includedEntries(entries); - const p = planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words }, nowMs); + const p = planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words, wordsAgain }, nowMs); if (!p.ok && p.field === 'files') { await fail(p.problem, p.field); return; @@ -970,10 +973,25 @@ aria-describedby={described('words', 'words-hint')} />

- Quien las escriba al abrirla podrá abrir la cápsula, sin guardar ningún fichero. Al menos 6, y que no formen una frase + Quien las escriba al abrirla podrá abrir la cápsula, sin guardar ningún fichero. Al menos 6 distintas de 3 letras o más, y que no formen una frase conocida: pasada la fecha, quien tenga la cápsula puede probar palabras. Dan igual mayúsculas, acentos y espacios.

+ {#if words.trim() !== ''} +
+ + +

Se guardan así: {quickWords(words).join(' ')}

+
+ {/if}
Dar la llave a personas con age
diff --git a/testdata/SOURCE.json b/testdata/SOURCE.json index cf6263a..f55a4f5 100644 --- a/testdata/SOURCE.json +++ b/testdata/SOURCE.json @@ -1,6 +1,6 @@ { "module": "g.activething.com/go/DateKeys", - "commit": "cc35d2c78c07363ae1f75d965b2d99d0d7677b19", + "commit": "2213b8c3fd7e20dac09b82ae3fba383d8993edd6", "files": { "README.md": "e6d0c3a0fe0fcfdefec609d2c02c4ac667430e45b2eb3756b17d745cb056ae03", "fixtures/empty_payload.dkc": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4", @@ -105,8 +105,8 @@ "vectors/inspect_differential.json": "f4215d4ffff091f09ee9bc056a761898d059e073286e4d95841868e447c72e37", "vectors/mutations.json": "6550d80c4de1e0e72ed5c78be773eb9e101a7e29e42c4ef338723ce0e97500c2", "vectors/padding.json": "2396fc02db96857de9cdb054ea22b898ab2cda964960d1a00761706a59998f6f", - "vectors/path_fold.json": "4c69fccc3c1331095bd9e90e554958bba9302947c994bbeec0269e91aeb5284b", - "vectors/paths.json": "299643929070b4e93ea90a9f4170997de92edc246c57f258de66f209f8c7fabd", + "vectors/path_fold.json": "bdfad44d28076a48418d9ab18c7f001fd7adc2e27f70330c3d285a779ce58c7d", + "vectors/paths.json": "e8ad92847ded09d4f67c61477dd8403864d04f54a15af6bd7203b514eb342dc2", "vectors/profile_quicknet.json": "6e67ac8956295229fe1bbe639877c89f4bb4d06b769ff8c0c4c0e186f1b6570b", "vectors/quicknet_rounds.json": "c1a4c7d9240d438c770cb386c489409b05029df4185a617ff65555128fb4c830", "vectors/security.json": "425ae16dfd95b9c1eb4f911db6232b6a6e3b29d9f3ea14fecbf1bda9d900957d", diff --git a/testdata/vectors/path_fold.json b/testdata/vectors/path_fold.json index 683c8ba..21c043d 100644 --- a/testdata/vectors/path_fold.json +++ b/testdata/vectors/path_fold.json @@ -2,7 +2,7 @@ "spec": "0.10", "description": "The key of R7 (spec §29.5) of segments, with the Unicode 18.0.0 tables of §29.5.1, generated by the reference implementation: nfd is NFD(segment) and key is NFD(fold(NFD(s'))), s' the segment without ZWNJ, ZWJ, VS15 and VS16. See testdata/README.md.", "unicode_version": "18.0.0", - "tables_digest": "7bb770bac2c81497f520da6b079e6c4fcbcf140e937a2d4203da3ba7b46df2e2", + "tables_digest": "07cf5d54aea1cd13a3ecef14a06976cc49a3cdad755cf9bc10395178b93aeb07", "keys": [ { "name": "ASCII capitals", diff --git a/testdata/vectors/paths.json b/testdata/vectors/paths.json index 271a751..e9125ec 100644 --- a/testdata/vectors/paths.json +++ b/testdata/vectors/paths.json @@ -2,7 +2,7 @@ "spec": "0.10", "description": "Paths of a format 3 head (spec §29.5) with the Unicode 18.0.0 and best-fit tables of §29.5.1, generated by the reference implementation. paths: one path and the rules of one entry, R2 to R6c and R10; trees: the paths of a head, of 0 bytes each, and the result of decoding it. See testdata/README.md.", "unicode_version": "18.0.0", - "tables_digest": "7bb770bac2c81497f520da6b079e6c4fcbcf140e937a2d4203da3ba7b46df2e2", + "tables_digest": "07cf5d54aea1cd13a3ecef14a06976cc49a3cdad755cf9bc10395178b93aeb07", "paths": [ { "name": "a file", diff --git a/vitest.config.ts b/vitest.config.ts index 86d6e44..07def51 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -66,7 +66,7 @@ export default defineConfig({ 'src/lib/inspector/create-files.ts': { 100: true }, 'src/lib/inspector/create-check.ts': { 100: true }, 'src/lib/inspector/drand.ts': { 100: true }, - 'src/lib/inspector/wordkey.ts': { 100: true }, + 'src/lib/dkc/wordkey.ts': { 100: true }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, // The page model and helpers of the inspector (plan §8, phase 1). 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },