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
parent
2c305cf253
commit
f37da12504
@ -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('');
|
||||
}
|
||||
Loading…
Reference in new issue