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
parent
2ec7102f1b
commit
b617007054
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in new issue