A key of words: open a capsule with words instead of a .dkk

The author wanted a key that people can keep without files. A capsule
"solo con una llave" can now take words the person chooses, at least
six, on /create, and /inspect opens it with them. wordkey.ts derives
the X25519 identity with PBKDF2-SHA256 of Web Crypto, 600 000 rounds,
salted with the chain and the round of the capsule, after making case,
accents and extra spaces not matter; its public key is one more
recipient, so the format does not change. The writer wipes the private
half at once; the opener adds it to the identities it tries.

Checked in Chromium: a capsule created with "Perro luna casa verde
trén mar" and no .dkk opened with "perro LUNA casa verde tren mar",
with the release fetched from drand by the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 7 days ago
parent 2ec7102f1b
commit b617007054

@ -6,6 +6,11 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor. El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor.
### Llave de palabras y firma de drand
- `/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.
### Paso 8: revisión adversarial y rediseño de `/create` ### Paso 8: revisión adversarial y rediseño de `/create`
- Correcciones de la revisión: el payload llega a `age` en trozos de un chunk (`chunked`), el sumidero nunca se aborta tras el commit y recibe copias, el escritor copia cada trozo que lee y acepta fuentes escritas como clase, y las extensiones de tipos incorrectos son un `TypeError`. - Correcciones de la revisión: el payload llega a `age` en trozos de un chunk (`chunked`), el sumidero nunca se aborta tras el commit y recibe copias, el escritor copia cada trozo que lee y acepta fuentes escritas como clase, y las extensiones de tipos incorrectos son un `TypeError`.

@ -112,6 +112,8 @@
// The release from the relays of drand, only when the person asks for it: // The release from the relays of drand, only when the person asks for it:
// the one connection the page makes to another site (drand.ts). // the one connection the page makes to another site (drand.ts).
let asking = $state(false); let asking = $state(false);
// The words of a key of words, as the person types them.
let words = $state('');
let askNote = $state(''); let askNote = $state('');
async function askDrand(): Promise<void> { async function askDrand(): Promise<void> {
asking = true; asking = true;
@ -239,6 +241,7 @@
release: parsed.release, release: parsed.release,
...(timeAndKey ? { identities } : {}), ...(timeAndKey ? { identities } : {}),
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}), ...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
...(timeAndKey && words.trim() !== '' ? { words: { text: words, chainHash: report.profile!.chainHash, round } } : {}),
// A stale opening stops writing, so open aborts the file and stops. // 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() } }), ...(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 // Format 3: the files without an mtime take the time of the round in
@ -373,8 +376,9 @@
<fieldset> <fieldset>
<legend>La llave</legend> <legend>La llave</legend>
<p class="hint"> <p class="hint">
Esta cápsula se creó «solo con una llave»: además de la firma de la ronda, hace falta su llave <code>.dkk</code> o la Esta cápsula se creó «solo con una llave»: además de la firma de la ronda, hace falta su fichero <code>.dkk</code>,
clave secreta de <code>age</code> de una de las personas a las que se dio. No salen de este navegador. las palabras con las que se creó o la clave secreta de <code>age</code> de una de las personas a las que se dio. No
salen de este navegador.
</p> </p>
<div class="field"> <div class="field">
<label for="dkk-input">Fichero de la llave (.dkk)</label> <label for="dkk-input">Fichero de la llave (.dkk)</label>
@ -387,6 +391,11 @@
aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined} aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined}
/> />
</div> </div>
<div class="field">
<label for="words-input">O tus palabras</label>
<input id="words-input" type="text" bind:value={words} autocomplete="off" spellcheck="false" aria-describedby="words-hint" />
<p id="words-hint" class="hint">Las que se escribieron al crear la cápsula. Dan igual mayúsculas, acentos y espacios.</p>
</div>
<div class="field"> <div class="field">
<label for="ids-input">O tu clave secreta de age</label> <label for="ids-input">O tu clave secreta de age</label>
<textarea <textarea

@ -27,6 +27,7 @@ const input = (extra: Partial<CreateInput> = {}): CreateInput => ({
policy: TIME_ONLY, policy: TIME_ONLY,
recipients: '', recipients: '',
portable: true, portable: true,
words: '',
...extra, ...extra,
}); });
const recipient = (i: number): string => formatX25519Recipient(x25519PublicKey(sampleIdentity(i))); const recipient = (i: number): string => formatX25519Recipient(x25519PublicKey(sampleIdentity(i)));
@ -194,17 +195,24 @@ describe('planCapsule', () => {
expect(keyed('', true).ok).toBe(true); expect(keyed('', true).ok).toBe(true);
expect(keyed(lines(15), true).ok).toBe(true); expect(keyed(lines(15), true).ok).toBe(true);
expect(keyed(lines(16), false).ok).toBe(true); expect(keyed(lines(16), false).ok).toBe(true);
const many = `Una cápsula admite como mucho 16 credenciales: 15 destinatarios con la clave portable, o 16 sin ella.`; 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 destinatarios.`]); 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 destinatarios.`]); 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: `${recipient(1)}\nage1nope`, portable: true })).toEqual([ expect(problem({ policy: TIME_AND_KEY, recipients: `${recipient(1)}\nage1nope`, portable: true })).toEqual([
'recipients', 'recipients',
'La línea 2 no es un destinatario de age (age1…).', 'La línea 2 no es un destinatario de age (age1…).',
]); ]);
expect(problem({ policy: TIME_AND_KEY, recipients: '# nobody', portable: false })).toEqual([ expect(problem({ policy: TIME_AND_KEY, recipients: '# nobody', portable: false })).toEqual([
'portable', 'portable',
'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.', '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.']);
const timeOnly = planCapsule(input({ words: 'uno dos' }), GENESIS_MS);
expect(timeOnly.ok && timeOnly.plan.words).toEqual([]);
// time_only ignores both. // time_only ignores both.
const plain = planCapsule(input({ recipients: 'not a recipient', portable: true }), GENESIS_MS); const plain = planCapsule(input({ recipients: 'not a recipient', portable: true }), GENESIS_MS);
expect(plain.ok && [plain.plan.recipients, plain.plan.portable]).toEqual([[], false]); expect(plain.ok && [plain.plan.recipients, plain.plan.portable]).toEqual([[], false]);

@ -17,6 +17,7 @@ import { quicknet } from '../dkc/profile.ts';
import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts'; import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts';
import { MAX_CAPSULE_FILES } from './create-files.ts'; import { MAX_CAPSULE_FILES } from './create-files.ts';
import { formatByteCount, formatInteger, safeFileName } from './format.ts'; import { formatByteCount, formatInteger, safeFileName } from './format.ts';
import { MIN_WORDS, normalizeWords } from './wordkey.ts';
import { localToEpochMs } from './localtime.ts'; import { localToEpochMs } from './localtime.ts';
// The limits of the texts of a head, as pathrule.ts has them: that module // The limits of the texts of a head, as pathrule.ts has them: that module
@ -28,7 +29,7 @@ const MAX_AUTHOR_BYTES = 256;
export const SOON_MS = 3600_000; export const SOON_MS = 3600_000;
/** The inputs of the form. */ /** The inputs of the form. */
export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable'; export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable' | 'words';
/** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */ /** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */
export interface PlannedFile { export interface PlannedFile {
@ -65,6 +66,8 @@ export interface CreateInput {
/** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */ /** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */
readonly recipients: string; readonly recipients: string;
readonly portable: boolean; readonly portable: boolean;
/** For time_and_key: the words of a key of words (wordkey.ts), '' for none. */
readonly words: string;
} }
/** Everything the page shows before encrypting, and what encrypt takes. */ /** Everything the page shows before encrypting, and what encrypt takes. */
@ -84,6 +87,8 @@ export interface CapsulePlan {
/** The raw X25519 public keys of the recipients. */ /** The raw X25519 public keys of the recipients. */
readonly recipients: readonly Uint8Array[]; readonly recipients: readonly Uint8Array[];
readonly portable: boolean; readonly portable: boolean;
/** The words of a key of words, normalized; none for time_only. */
readonly words: readonly string[];
/** The files of the capsule, in the order of the list, and the texts of its head. */ /** The files of the capsule, in the order of the list, and the texts of its head. */
readonly files: readonly PlannedFile[]; readonly files: readonly PlannedFile[];
readonly comment: string; readonly comment: string;
@ -192,18 +197,27 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
const policy = input.policy; const policy = input.policy;
let recipients: Uint8Array[] = []; let recipients: Uint8Array[] = [];
let portable = false; let portable = false;
let words: string[] = [];
if (policy === TIME_AND_KEY) { if (policy === TIME_AND_KEY) {
const read = readRecipients(input.recipients); const read = readRecipients(input.recipients);
if (!read.ok) return fail('recipients', read.problem); if (!read.ok) return fail('recipients', read.problem);
recipients = read.keys; recipients = read.keys;
portable = input.portable; portable = input.portable;
if (recipients.length === 0 && !portable) { words = normalizeWords(input.words);
return fail('portable', 'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.'); if (words.length > 0 && words.length < MIN_WORDS) {
return fail('words', `Escribe al menos ${MIN_WORDS} palabras: con menos, cualquiera puede adivinarlas.`);
} }
if (recipients.length + (portable ? 1 : 0) > ACCESS_SLOTS) { const keys = recipients.length + (portable ? 1 : 0) + (words.length > 0 ? 1 : 0);
if (keys === 0) {
return fail(
'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.',
);
}
if (keys > ACCESS_SLOTS) {
return fail( return fail(
'recipients', 'recipients',
`Una cápsula admite como mucho ${ACCESS_SLOTS} credenciales: ${ACCESS_SLOTS - 1} destinatarios con la clave portable, o ${ACCESS_SLOTS} sin ella. Hay ${formatInteger(recipients.length)} destinatarios.`, `Una cápsula admite como mucho ${ACCESS_SLOTS} llaves, contando el fichero .dkk y las palabras. Hay ${formatInteger(recipients.length)} personas.`,
); );
} }
} }
@ -224,6 +238,7 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
policy, policy,
recipients, recipients,
portable, portable,
words,
files: list, files: list,
comment: headComment(input.comment), comment: headComment(input.comment),
author: input.author, author: input.author,

@ -15,6 +15,8 @@ import { suppliedRelease } from '../dkc/release.ts';
import { h, readJSON } from '../dkc/testing/testdata.ts'; import { h, readJSON } from '../dkc/testing/testdata.ts';
import { type CapsulePlan, chooseFiles, type CreateInput, type PlannedFile, planCapsule } from './create-input.ts'; import { type CapsulePlan, chooseFiles, type CreateInput, type PlannedFile, planCapsule } from './create-input.ts';
import { createCapsule, CreateStopped } from './creator.ts'; import { createCapsule, CreateStopped } from './creator.ts';
import { wordKey } from './wordkey.ts';
import { quicknet } from '../dkc/profile.ts';
import type { TempFile } from './tempfile.ts'; import type { TempFile } from './tempfile.ts';
const encoded = vi.hoisted(() => [] as Uint8Array[]); const encoded = vi.hoisted(() => [] as Uint8Array[]);
@ -44,7 +46,7 @@ const one = (size: number, mtime?: number) => chooseFiles([{ path: 'nota.txt', s
// A plan for round 1000, whose release is published. // A plan for round 1000, whose release is published.
function plan(extra: Partial<CreateInput> = {}, nowMs = GENESIS_MS): CapsulePlan { function plan(extra: Partial<CreateInput> = {}, nowMs = GENESIS_MS): CapsulePlan {
const r = planCapsule( const r = planCapsule(
{ files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, ...extra }, { files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, words: '', ...extra },
nowMs, nowMs,
); );
if (!r.ok) throw new Error(r.problem); if (!r.ok) throw new Error(r.problem);
@ -159,6 +161,16 @@ describe('createCapsule', () => {
expect((await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).files).toEqual([body]); expect((await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).files).toEqual([body]);
}); });
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 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);
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)]);
});
it('writes the modification time of a file, which the head records in seconds', async () => { it('writes the modification time of a file, which the head records in seconds', async () => {
const p = plan({ files: one(100, 1_790_769_600_500) }); const p = plan({ files: one(100, 1_790_769_600_500) });
const c = await createCapsule({ files: [blob(content(100))], plan: p, cancelled: () => false, now: genesis }); const c = await createCapsule({ files: [blob(content(100))], plan: p, cancelled: () => false, now: genesis });

@ -12,6 +12,8 @@ import { encryptFiles } from '../dkc/encrypt.ts';
import type { CapsulePlan } from './create-input.ts'; import type { CapsulePlan } from './create-input.ts';
import { systemClock } from './opener.ts'; import { systemClock } from './opener.ts';
import type { TempFile } from './tempfile.ts'; import type { TempFile } from './tempfile.ts';
import { wordKey } from './wordkey.ts';
import { x25519PublicKey } from '../dkc/x25519.ts';
export interface CreateRequest { export interface CreateRequest {
/** The person's files, one for each of plan.files and in its order, each read twice. */ /** The person's files, one for each of plan.files and in its order, each read twice. */
@ -114,6 +116,14 @@ 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) })); 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);
}
let res: Awaited<ReturnType<typeof encryptFiles>>; let res: Awaited<ReturnType<typeof encryptFiles>>;
try { try {
req.progress?.(1, 0, all); req.progress?.(1, 0, all);
@ -121,7 +131,7 @@ export async function createCapsule(req: CreateRequest): Promise<Created> {
profile: quicknet(), profile: quicknet(),
unlockAt: plan.requested, unlockAt: plan.requested,
policy: plan.policy, policy: plan.policy,
recipients: plan.recipients, recipients,
newPortableKey: plan.portable, newPortableKey: plan.portable,
...(plan.comment === '' ? {} : { comment: plan.comment }), ...(plan.comment === '' ? {} : { comment: plan.comment }),
...(plan.author === '' ? {} : { author: plan.author }), ...(plan.author === '' ? {} : { author: plan.author }),

@ -4,6 +4,10 @@ import { equalBytes, fromHex, type Instant, quicknet, roundTime, sha256, TIME_ON
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts'; import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { memoryFile } from '../dkc/testing/zip.ts'; import { memoryFile } from '../dkc/testing/zip.ts';
import { openCapsule, type OpenRequest, parseIdentities, PREVIEW_BYTES, systemClock } from './opener.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 type { TempFile } from './tempfile.ts'; import type { TempFile } from './tempfile.ts';
import { zipLayout } from './zip.ts'; import { zipLayout } from './zip.ts';
import { zipEntries } from './zipsink.ts'; import { zipEntries } from './zipsink.ts';
@ -246,3 +250,26 @@ describe('systemClock', () => {
expect(systemClock()).toEqual({ seconds: 0, nanos: 0 }); expect(systemClock()).toEqual({ seconds: 0, nanos: 0 });
}); });
}); });
describe('openCapsule with words', () => {
it('opens a capsule whose key came from words, whatever their case and accents, and no other words', async () => {
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)],
now: () => ({ seconds: 1692803367, nanos: 0 }),
});
const withWords = (text: string): OpenRequest => ({ ...f.request, capsule: res.dkc!, words: { text, chainHash, round } });
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);
});
});

@ -9,6 +9,7 @@
// demand with the Unicode tables of the paths. // demand with the Unicode tables of the paths.
import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts'; import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts';
import { normalizeWords, wordKey } from './wordkey.ts';
import { sha256Stream } from '../dkc/digest.ts'; import { sha256Stream } from '../dkc/digest.ts';
import type { Head } from '../dkc/head.ts'; import type { Head } from '../dkc/head.ts';
import { open, type Opened } from '../dkc/open.ts'; import { open, type Opened } from '../dkc/open.ts';
@ -30,6 +31,8 @@ export interface OpenRequest {
readonly identities?: string; readonly identities?: string;
/** A .dkk file, for time_and_key. */ /** A .dkk file, for time_and_key. */
readonly accessKey?: Blob; 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 };
/** /**
* Where the plaintext of a capsule of format 1 or 2 goes, and the files of * 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. * a format 3 capsule through a ZipSink; memory when omitted.
@ -159,6 +162,8 @@ export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
const parsed = parseIdentities(req.identities ?? ''); const parsed = parseIdentities(req.identities ?? '');
if (!parsed.ok) return { ...parsed, field: 'identities' }; if (!parsed.ok) return { ...parsed, field: 'identities' };
const ids = parsed.ids; 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));
try { try {
let accessKeyFile: Uint8Array | undefined; let accessKeyFile: Uint8Array | undefined;
if (req.accessKey !== undefined) { if (req.accessKey !== undefined) {

@ -0,0 +1,26 @@
// 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', () => {
const accented = ` ${String.fromCharCode(0xc1)}baco ${String.fromCharCode(0xc1)}RBOL\tni${String.fromCharCode(0xf1)}o `;
expect(normalizeWords(accented)).toEqual(['abaco', 'arbol', 'nino']);
expect(normalizeWords(' ')).toEqual([]);
});
});
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'));
expect(Buffer.from(await wordKey(words, CHAIN, 1001)).equals(Buffer.from(key))).toBe(false);
});
});

@ -0,0 +1,38 @@
// 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 combining marks that NFD splits from accented letters.
const MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g');
/** The words of a text: in lower case, without accents, split by spaces. */
export function normalizeWords(text: string): string[] {
return text
.normalize('NFD')
.replace(MARKS, '')
.toLowerCase()
.split(/\s+/)
.filter((w) => w !== '');
}
/**
* 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));
}

@ -92,6 +92,7 @@
let policy: Policy = $state(TIME_ONLY); let policy: Policy = $state(TIME_ONLY);
let recipients = $state(''); let recipients = $state('');
let portable = $state(true); let portable = $state(true);
let words = $state('');
// Read on mount, every second while the tab is visible and nothing is // 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 // being written, and when the person creates the capsule: the page is
// prerendered, so never at build time. // prerendered, so never at build time.
@ -125,6 +126,7 @@
zone: 'zone-input', zone: 'zone-input',
recipients: 'recipients-input', recipients: 'recipients-input',
portable: 'portable-input', portable: 'portable-input',
words: 'words-input',
}; };
// The rules of the paths and the texts, loaded with the first file or text. // The rules of the paths and the texts, loaded with the first file or text.
@ -151,7 +153,7 @@
// What happened with the last files chosen or dropped, shown by the list: // What happened with the last files chosen or dropped, shown by the list:
// the status line only reaches screen readers. // the status line only reaches screen readers.
let notice = $state(''); let notice = $state('');
const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable }, nowMs)); const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words }, nowMs));
const plan = $derived(planned.ok ? planned.plan : undefined); const plan = $derived(planned.ok ? planned.plan : undefined);
const commentCheck = $derived(checker?.commentProblem(comment)); const commentCheck = $derived(checker?.commentProblem(comment));
const authorCheck = $derived(checker?.authorProblem(author)); const authorCheck = $derived(checker?.authorProblem(author));
@ -474,7 +476,7 @@
// reader for the paths and the texts. // reader for the paths and the texts.
nowMs = Date.now(); nowMs = Date.now();
const files = includedEntries(entries); const files = includedEntries(entries);
const p = planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable }, nowMs); const p = planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words }, nowMs);
if (!p.ok && p.field === 'files') { if (!p.ok && p.field === 'files') {
await fail(p.problem, p.field); await fail(p.problem, p.field);
return; return;
@ -607,7 +609,7 @@
temporary: temp !== undefined, temporary: temp !== undefined,
...(inMemory === undefined ? {} : { inMemory }), ...(inMemory === undefined ? {} : { inMemory }),
capsuleId: made.capsuleId, capsuleId: made.capsuleId,
keyOnly: p.plan.portable && p.plan.recipients.length === 0, keyOnly: p.plan.portable && p.plan.recipients.length === 0 && p.plan.words.length === 0,
ms: made.ms, ms: made.ms,
nowMs: Date.now(), nowMs: Date.now(),
timeZone: viewerTimeZone(), timeZone: viewerTimeZone(),
@ -683,8 +685,12 @@
// The credentials of a time_and_key capsule, in words. // The credentials of a time_and_key capsule, in words.
function credentials(p: CapsulePlan): string { function credentials(p: CapsulePlan): string {
const n = p.recipients.length; const n = p.recipients.length;
const people = n === 0 ? [] : [n === 1 ? '1 destinatario' : `${n} destinatarios`]; const parts = [
return [...people, ...(p.portable ? ['la clave portable'] : [])].join(' y '); ...(p.portable ? ['el fichero .dkk'] : []),
...(p.words.length > 0 ? ['las palabras'] : []),
...(n === 0 ? [] : [n === 1 ? '1 persona con age' : `${n} personas con age`]),
];
return parts.length === 1 ? parts[0]! : `${parts.slice(0, -1).join(', ')} y ${parts.at(-1)}`;
} }
function seconds(ms: number): string { function seconds(ms: number): string {
@ -951,6 +957,23 @@
<input id="portable-input" type="checkbox" bind:checked={portable} aria-invalid={invalid('portable')} aria-describedby={described('portable')} /> <input id="portable-input" type="checkbox" bind:checked={portable} aria-invalid={invalid('portable')} aria-describedby={described('portable')} />
<span>Crear una llave: un fichero <code>.dkk</code> que abre esta cápsula y ninguna otra.</span> <span>Crear una llave: un fichero <code>.dkk</code> que abre esta cápsula y ninguna otra.</span>
</label> </label>
<div class="field">
<label for="words-input">O unas palabras que solo sepas tú <span class="optional">(opcional)</span></label>
<input
id="words-input"
type="text"
bind:value={words}
autocomplete="off"
spellcheck="false"
placeholder="seis palabras o más"
aria-invalid={invalid('words')}
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
conocida: pasada la fecha, quien tenga la cápsula puede probar palabras. Dan igual mayúsculas, acentos y espacios.
</p>
</div>
<details class="help" open={recipients.trim() !== ''}> <details class="help" open={recipients.trim() !== ''}>
<summary>Dar la llave a personas con <code>age</code></summary> <summary>Dar la llave a personas con <code>age</code></summary>
<div class="field"> <div class="field">
@ -971,8 +994,8 @@
{:else if recipientCheck?.ok === true} {:else if recipientCheck?.ok === true}
{recipientCheck.keys.length === 1 ? '1 persona.' : `${recipientCheck.keys.length} personas.`} {recipientCheck.keys.length === 1 ? '1 persona.' : `${recipientCheck.keys.length} personas.`}
{/if} {/if}
Cada una abrirá la cápsula con su clave secreta de <code>age</code>, que nunca se pega aquí. Como mucho 15 con la Cada una abrirá la cápsula con su clave secreta de <code>age</code>, que nunca se pega aquí. Como mucho 16 llaves en
llave, o 16 sin ella. Quien no tenga <code>age</code> la crea con <code>age-keygen</code> y te manda solo la línea total, contando el fichero y las palabras. Quien no tenga <code>age</code> la crea con <code>age-keygen</code> y te manda solo la línea
<code>age1…</code>. <code>age1…</code>.
</p> </p>
</div> </div>

@ -66,6 +66,7 @@ export default defineConfig({
'src/lib/inspector/create-files.ts': { 100: true }, 'src/lib/inspector/create-files.ts': { 100: true },
'src/lib/inspector/create-check.ts': { 100: true }, 'src/lib/inspector/create-check.ts': { 100: true },
'src/lib/inspector/drand.ts': { 100: true }, 'src/lib/inspector/drand.ts': { 100: true },
'src/lib/inspector/wordkey.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1). // The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.