/create: a reminder in the calendar, and 0.6.0-dev

The MAY of spec §62.1 rule 26, as encrypt -reminder of datekeys-go
(7e6a03d): after creating the capsule, "Descargar el recordatorio" gives
<capsule>.recordatorio.ics, an iCalendar event (RFC 5545) at the round time
with an alarm and what opening will take. reminder.ts writes the bytes of
Go; its test compares them with a text computed apart, from the RFC: CRLF,
lines folded at 75 octets without cutting a character, the name escaped,
and a fold that steps back before an ñ. The UID is a random UUID, which
names no capsule; the page says that a calendar that syncs with a server
learns the name and the date. Checked in the built page: text/calendar,
CRLF, no line over 75 octets, the event at the round time.

The version moves to 0.6.0-dev after the release of 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.10
dev 2 hours ago
parent 2c305cf253
commit f37da12504

@ -2,6 +2,10 @@
Cambios notables de la librería TypeScript y de la página. El proyecto usa versionado semántico; mientras sea 0.x, no hay promesa de estabilidad. La sección «Versiones» del [README](README.md) explica qué cubre cada número.
## 0.6.0 — sin publicar
- **El recordatorio en el calendario** (§62.1, regla 26, MAY), como `encrypt -reminder` de `datekeys-go` (`7e6a03d`): tras crear la cápsula, «Descargar el recordatorio» da `<cápsula>.recordatorio.ics`, un evento de iCalendar (RFC 5545) a la hora de la ronda, con una alarma y lo que hará falta para abrirla. `reminder.ts` escribe los mismos bytes que Go, y su prueba los compara con un texto calculado aparte, a partir del RFC: CRLF, líneas plegadas a 75 octetos sin cortar un carácter y el nombre escapado. El UID es un UUID al azar, que no nombra la cápsula; la página dice que un calendario que se sincroniza con un servidor sabrá el nombre y la fecha.
## 0.5.0 — 7 de octubre de 2026
La especificación 0.16, el tag `spec-v0.16` de `datekeys-go`: la revisión de Astra de la v0.15, con el sello sin `accuracy` y el JSON de drand estricto; y las palabras al azar de la llave de palabras, de una lista inglesa y una española, con el ordenador o con dados, y lo que dice el SDK oficial al sellar. El autor la cerró el 7 de octubre de 2026, con el tag `v0.5.0`.

@ -26,6 +26,8 @@ Hay tres números de versión, cada uno con su significado, como en la referenci
`version.test.ts` comprueba que `VERSION` coincide con `package.json` y con su lockfile, y que `SPEC_VERSION` es la versión que nombran los vectores y fixtures compartidos; `vectors.test.ts` exige esa versión a cada fichero. El pie de la página muestra las dos.
En desarrollo está la `0.6.0` (`0.6.0-dev` en `package.json`): el recordatorio en el calendario de `/create` (§62.1, regla 26). Lo que cambia está en el [CHANGELOG](CHANGELOG.md).
La versión actual es la `0.5.0`, del 7 de octubre de 2026, con el tag `v0.5.0`. Cubre:
- la especificación 0.16 (el tag `spec-v0.16` de `datekeys-go`): lee los formatos de cápsula 1 a 3 y escribe el 3, y el 2 solo como generador de vectores (§62.1, regla 1);
- un sello sin `accuracy` no prueba nada antes de la fecha de apertura: los veredictos dicen por qué, y el escritor devuelve los del área que escribe (§29.7, §29.11, §62.1 regla 19);
@ -232,6 +234,7 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una
| `src/lib/inspector/create-files.ts` | La lista de ficheros de `/create`, sin tablas: lo elegido y lo soltado, carpetas recorridas incluidas, los ficheros de un sistema fuera por defecto como en `collect.go`, las rutas editadas y lo que la cápsula guarda |
| `src/lib/inspector/create-check.ts` | Las reglas de las rutas y de los textos (§29.5, §29.6) como las explica `/create`: todos los problemas de todas las rutas, en español, con los de `pathrule.ts`. Se carga bajo demanda, con las tablas |
| `src/lib/inspector/create-input.ts` | El formulario de `/create`, sin DOM, reloj ni writer: `planCapsule` comprueba los campos en su orden y da lo que se muestra antes de cifrar, con los ficheros medidos una vez (`chooseFiles`); los destinatarios y los nombres de los ficheros |
| `src/lib/inspector/reminder.ts` | El recordatorio que `/create` ofrece junto a la cápsula (§62.1, regla 26, MAY): un evento de iCalendar (RFC 5545) a la hora de la ronda, con una alarma y lo que hará falta para abrirla, byte a byte como `encrypt -reminder` de Go; el nombre del fichero, el de la cápsula con `.recordatorio.ics` |
| `src/lib/inspector/annex.ts` | El anexo de recuperación que `/create` ofrece junto a la cápsula: `annex/recovery.md`, que Vite publica con un nombre con hash, y el nombre de su fichero, el de la cápsula con `.recuperacion.txt`, como `RecoveryAnnexSuffix` de Go |
| `src/lib/inspector/create-words.ts` | La lista de las palabras al azar de `/create`: `wordListLoader` descarga una vez `wordlists/es.txt`, que Vite publica con un nombre con hash, y la lee con `readWordList`, con su SHA-256 fijado; un fallo no se guarda y la siguiente llamada lo vuelve a intentar |
| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encryptFiles` con los ficheros, el comentario y el autor hacia el fichero temporal o la memoria, con el progreso de las dos lecturas, la cancelación y la cuota, y los pasos 1 a 8 de lo escrito |

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "2.4.0",

@ -1,6 +1,6 @@
{
"name": "datekeys-ts",
"version": "0.5.0",
"version": "0.6.0-dev",
"private": true,
"description": "DateKeys in TypeScript: canonical CBOR codec, DKC1/DKK1 parsers, capsule inspector library and its static inspector page; later, browser encryption and decryption.",
"license": "Apache-2.0",

@ -12,9 +12,10 @@
* 0.4.0 implements spec 0.15: the release object and a release in hand;
* 0.5.0 implements spec 0.16: a seal without accuracy proves nothing before
* the opening date and drand's JSON is read strictly; it also draws the
* random words of a key of words, from a computer or from dice.
* random words of a key of words, from a computer or from dice; 0.6.0-dev is
* what comes after it.
*/
export const VERSION = '0.5.0';
export const VERSION = '0.6.0-dev';
/**
* The version of the DateKeys Protocol Specification that this library

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { REMINDER_SUFFIX, newUID, reminderICS, reminderName } from './reminder.ts';
// The calendar file of the reminder of /create, byte for byte the one of
// datekeys encrypt -reminder (cmd/datekeys/reminder_test.go of datekeys-go):
// CRLF, lines folded at 75 octets without cutting a character, and a name
// whose semicolon, comma and backslash are escaped (spec §62.1 rule 26, RFC
// 5545). The expected text was computed apart from this code, from the RFC.
describe('reminderICS', () => {
const base = {
name: 'carta; de, mamá\\.dkc',
round: 1000,
unlockMs: Date.UTC(2023, 7, 23, 15, 59, 24),
timeAndKey: true,
uid: '0f1e2d3c-4b5a-4697-8877-665544332211',
stampMs: Date.UTC(2026, 9, 7, 12, 0, 0),
};
it('writes the event of Go byte for byte', () => {
expect(reminderICS(base)).toBe(
'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//DateKeys//datekeys-go//ES\r\n' +
'CALSCALE:GREGORIAN\r\n' +
'BEGIN:VEVENT\r\n' +
'UID:0f1e2d3c-4b5a-4697-8877-665544332211\r\n' +
'DTSTAMP:20261007T120000Z\r\n' +
'DTSTART:20230823T155924Z\r\n' +
'DTEND:20230823T162924Z\r\n' +
'SUMMARY:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.dkc»\r\n' +
'DESCRIPTION:Desde este momento se puede abrir «carta\\; de\\, mamá\\\\.dkc».\r\n' +
' Hace falta el fichero .dkc y una de sus llaves\\, y la firma de drand de l\r\n' +
' a ronda 1000\\, que drand publica ahora: si un día ya no la sirve\\, un arc\r\n' +
' hivo de firmas o un servicio de caché tiene que haberla guardado. Las ins\r\n' +
' trucciones para abrirla sin DateKeys están en «carta\\; de\\, mamá\\\\.dkc.\r\n' +
' recuperacion.txt».\r\n' +
'TRANSP:TRANSPARENT\r\n' +
'BEGIN:VALARM\r\n' +
'ACTION:DISPLAY\r\n' +
'TRIGGER:PT0S\r\n' +
'DESCRIPTION:Ya se puede abrir la cápsula DateKeys «carta\\; de\\, mamá\\\\.d\r\n' +
' kc»\r\n' +
'END:VALARM\r\n' +
'END:VEVENT\r\n' +
'END:VCALENDAR\r\n',
);
});
it('folds every line at 75 octets', () => {
for (const line of reminderICS(base).split('\r\n')) {
expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
}
});
it('asks for a credential only with a key', () => {
const ics = reminderICS({ ...base, timeAndKey: false, name: 'carta.dkc' });
// Unfolded, a line of RFC 5545 is its pieces without the CRLF and the space.
expect(ics.replaceAll('\r\n ', '')).toContain('Hace falta el fichero .dkc\\, y la firma');
expect(ics).not.toContain('una de sus llaves');
expect(ics).toContain('DTEND:20230823T162924Z\r\n');
});
it('escapes a line feed and drops a carriage return of a name', () => {
const ics = reminderICS({ ...base, name: 'a\r\nb.dkc' });
expect(ics).toContain('SUMMARY:Ya se puede abrir la cápsula DateKeys «a\\nb.dkc»\r\n');
});
it('folds before a character that the 75th octet would cut', () => {
// SUMMARY and the start of the text take 49 octets: with 25 more, ñ
// takes octets 75 and 76, and the line folds before it, at 74.
const ics = reminderICS({ ...base, name: `${'x'.repeat(25)}ñ.dkc` });
expect(ics).toContain(`SUMMARY:Ya se puede abrir la cápsula DateKeys «${'x'.repeat(25)}\r\n ñ.dkc»\r\n`);
});
it('names the file after the capsule', () => {
expect(reminderName('carta.dkc')).toBe('carta.dkc.recordatorio.ics');
expect(REMINDER_SUFFIX).toBe('.recordatorio.ics');
});
it('makes a random UUID of version 4', () => {
const a = newUID();
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(newUID()).not.toBe(a);
});
});

@ -0,0 +1,100 @@
// The local reminder that /create offers next to a capsule, the MAY of spec
// §62.1 rule 26: an iCalendar event (RFC 5545) at round_time, which any
// calendar imports, with what opening the capsule will take. It is
// cmd/datekeys/reminder.go of datekeys-go, byte for byte. It holds the name
// of the capsule and its date, and nothing secret; a calendar that syncs
// with a server learns both.
/** What is appended to the name of the .dkc to name its reminder: carta.dkc.recordatorio.ics. */
export const REMINDER_SUFFIX = '.recordatorio.ics';
/** The name of the reminder of the capsule saved as `dkcName`. */
export function reminderName(dkcName: string): string {
return `${dkcName}${REMINDER_SUFFIX}`;
}
/** A reminder: the capsule, its round and round_time, and the event. */
export interface Reminder {
/** The file name of the capsule. */
readonly name: string;
readonly round: number;
/** round_time, in milliseconds since the epoch. */
readonly unlockMs: number;
/** Whether opening needs one of its credentials too. */
readonly timeAndKey: boolean;
/** A random UUID, so that the event names no capsule. */
readonly uid: string;
/** When the event was made, in milliseconds since the epoch. */
readonly stampMs: number;
}
/** A random UUID of version 4 (RFC 9562). */
export function newUID(): string {
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6]! & 0x0f) | 0x40;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
// A TEXT value of RFC 5545 (3.3.11): a backslash, a semicolon, a comma and
// a line feed escaped; a carriage return goes.
function calendarText(s: string): string {
return s.replace(/[\\;,\n\r]/g, (c) => (c === '\n' ? '\\n' : c === '\r' ? '' : `\\${c}`));
}
// A DATE-TIME of RFC 5545 in UTC, with a Z: 20230823T155924Z.
function calendarTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, '');
}
// A content line ended with CRLF and folded at 75 octets (RFC 5545, 3.1):
// each continuation starts with a space, and no UTF-8 sequence is cut.
function foldLine(line: string): string {
let rest = new TextEncoder().encode(line);
const parts: Uint8Array[] = [];
let limit = 75;
while (rest.length > limit) {
let cut = limit;
while (cut > 0 && (rest[cut]! & 0xc0) === 0x80) cut--;
parts.push(rest.subarray(0, cut));
rest = rest.subarray(cut);
limit = 74;
}
parts.push(rest);
const decoder = new TextDecoder();
return `${parts.map((p) => decoder.decode(p)).join('\r\n ')}\r\n`;
}
/** The calendar file of the reminder, in Spanish, as the annex is. */
export function reminderICS(r: Reminder): string {
const summary = `Ya se puede abrir la cápsula DateKeys «${r.name}»`;
const needs = r.timeAndKey ? 'el fichero .dkc y una de sus llaves' : 'el fichero .dkc';
const description =
`Desde este momento se puede abrir «${r.name}». Hace falta ${needs}, y la firma de drand de la ronda ${r.round}, ` +
'que drand publica ahora: si un día ya no la sirve, un archivo de firmas o un servicio de caché tiene que haberla guardado. ' +
`Las instrucciones para abrirla sin DateKeys están en «${r.name}.recuperacion.txt».`;
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//DateKeys//datekeys-go//ES',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${r.uid}`,
`DTSTAMP:${calendarTime(r.stampMs)}`,
`DTSTART:${calendarTime(r.unlockMs)}`,
`DTEND:${calendarTime(r.unlockMs + 30 * 60 * 1000)}`,
`SUMMARY:${calendarText(summary)}`,
`DESCRIPTION:${calendarText(description)}`,
'TRANSP:TRANSPARENT',
'BEGIN:VALARM',
'ACTION:DISPLAY',
'TRIGGER:PT0S',
`DESCRIPTION:${calendarText(summary)}`,
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
]
.map(foldLine)
.join('');
}

@ -44,6 +44,7 @@
} from '$lib/inspector/create-input.ts';
import { wordListLoader, WORDS_LANGUAGE } from '$lib/inspector/create-words.ts';
import { ANNEX_URL, annexName } from '$lib/inspector/annex.ts';
import { newUID, reminderICS, reminderName } from '$lib/inspector/reminder.ts';
import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
@ -438,6 +439,23 @@
return url;
}
// The local reminder of spec §62.1 rule 26 (MAY): a calendar event at the
// round time, with what opening will take. Its UID is random, so that it
// names no capsule.
function saveReminder(): void {
if (result === undefined) return;
const name = downloadName(dkcName, '.dkc', result.plan.names.dkc);
const ics = reminderICS({
name,
round: result.plan.dateKey.round,
unlockMs: result.plan.effectiveMs,
timeAndKey: result.plan.policy === TIME_AND_KEY,
uid: newUID(),
stampMs: Date.now(),
});
save(new Blob([ics], { type: 'text/calendar' }), reminderName(name));
}
function saveCapsule(): void {
if (result === undefined || capsuleGone) return;
save(result.capsule, downloadName(dkcName, '.dkc', result.plan.names.dkc));
@ -1417,10 +1435,12 @@
</p>
<p class="hint">
Guarda con la cápsula las instrucciones para abrirla sin DateKeys, por si ya no existe. Son las mismas para toda cápsula y
no dicen nada de la tuya.
no dicen nada de la tuya. El recordatorio es un evento para tu calendario a esa hora, con el nombre de la cápsula: si el
calendario se sincroniza con un servidor, este sabrá el nombre y la fecha.
</p>
<div class="actions">
<a class="button quiet" href={ANNEX_URL} download={annexName(downloadName(dkcName, '.dkc', r.plan.names.dkc))}>Descargar las instrucciones</a>
<button class="button quiet" type="button" onclick={saveReminder}>Descargar el recordatorio</button>
</div>
</section>

@ -51,6 +51,7 @@ export default defineConfig({
'src/lib/inspector/create-input.ts': { 100: true },
'src/lib/inspector/create-words.ts': { 100: true },
'src/lib/inspector/annex.ts': { 100: true },
'src/lib/inspector/reminder.ts': { 100: true },
'src/lib/inspector/creator.ts': { 100: true },
// Format 3 (plan of format 3 in datekeys-ts): the rules of the paths
// and texts of the head, the codec of BODY, of the security area and of

Loading…
Cancel
Save

Powered by TurnKey Linux.