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 <noreply@anthropic.com>
main
dev 6 days ago
parent 67d626ee9a
commit c3c124ae34

@ -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.

@ -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

@ -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', ''],

@ -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,

@ -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(''));

@ -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",

@ -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);
});
});

@ -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<string[]> {
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<void> {
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<Uint8Array> {
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));
}

@ -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<Sealer>
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<Sealer>
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()) {

@ -28,6 +28,7 @@ const input = (extra: Partial<CreateInput> = {}): 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]);

@ -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,

@ -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<CreateInput> = {}, 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)]);

@ -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<Created> {
);
};
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<ReturnType<typeof encryptFiles>>;
try {
req.progress?.(1, 0, all);
@ -131,8 +126,9 @@ export async function createCapsule(req: CreateRequest): Promise<Created> {
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,

@ -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);
});
});

@ -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<OpenAttempt> {
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) {

@ -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);
});
});

@ -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<Uint8Array> {
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));
}

@ -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')}
/>
<p id="words-hint" class="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.
</p>
</div>
{#if words.trim() !== ''}
<div class="field">
<label for="words-again-input">Escríbelas otra vez</label>
<input
id="words-again-input"
type="text"
bind:value={wordsAgain}
autocomplete="off"
spellcheck="false"
aria-invalid={invalid('wordsAgain')}
aria-describedby={described('wordsAgain', 'words-again-hint')}
/>
<p id="words-again-hint" class="hint">Se guardan así: {quickWords(words).join(' ')}</p>
</div>
{/if}
<details class="help" open={recipients.trim() !== ''}>
<summary>Dar la llave a personas con <code>age</code></summary>
<div class="field">

@ -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",

@ -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",

@ -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",

@ -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 },

Loading…
Cancel
Save

Powered by TurnKey Linux.