Check the chain and the body of the sealed locator, as ParseInfo of Go

The vectors of Go are regenerated on datekeys-go 69dbb0c: only the cases
of those checks change. The writer of the extension refuses a DateKey of a
profile that is not pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 day ago
parent a670d9768f
commit 39672dc2e1

@ -4,6 +4,11 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
## Especificación 0.10, en la rama `v0.10` — sin versión
### El localizador sellado, comprobado antes de la fecha (06-10-2026)
- `parseInfo` comprueba el localizador sellado como `ParseInfo` de Go desde `69dbb0c`, a petición del autor: la cadena del stanza tlock en hexadecimal en minúsculas y, si la DateKey es de Quicknet, la de Quicknet; y que el cuerpo tras la cabecera `age` lleve un texto de 4096 bytes o un múltiplo, así que una cabecera sin cuerpo se rechaza. Cada caso es `ERR_EXTENSION_DATA_INVALID` con el texto de Go. `infoExtension` rechaza además una DateKey de un perfil que la librería no fija. El límite de 1 MiB al abrir el localizador se queda, por decisión del autor.
- Los vectores de Go del localizador se regeneran sobre ese commit: cambian solo los casos de esas comprobaciones.
### La especificación 0.13, aprobada (06-10-2026)
- El autor aprobó el 6 de octubre de 2026 el borrador v0.13, NAT64, tal como estaba: `datekeys-go` lo cierra con el tag `spec-v0.13` (`913dd60`). `SPEC_VERSION` pasa a `0.13`, `testdata` se sincroniza con ese tag, y `testing/mutation-texts.json` se regenera con Go: solo cambia el campo `spec` de cada fichero.

@ -110,12 +110,11 @@ Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran
### El localizador
El localizador sigue al paquete `locator` de Go también donde el autor tiene decisiones pendientes (apartado de la sesión del 5 y 6 de octubre de `../docs/HANDOFF.md`), para que las dos librerías den lo mismo hasta que Go cambie:
El localizador sigue al paquete `locator` de Go también en esto, que el autor decidió el 6 de octubre de 2026 dejar así:
- `parseInfo` no mira la cadena del stanza sellado, acepta una cabecera `age` sin cuerpo, e `infoExtension` no comprueba que el perfil esté pinneado;
- `openSealed` lee como mucho 1 MiB del texto del localizador, y un defecto posterior queda oculto tras el error de `unmarshalLocator`.
Un CID con un carácter de más cuyos bits son cero, y `https://[[2000::]/`, se rechazan desde que Go los rechaza (`e801e03` de `datekeys-go`): el §44.1 de la v0.12 ya pedía el CID en su forma canónica y un literal IPv6 con un solo par de corchetes.
`parseInfo` comprueba el localizador sellado tanto como se puede antes de la fecha, como Go desde `69dbb0c`: la cadena del stanza en hexadecimal en minúsculas, la de Quicknet si la DateKey es de Quicknet, y un cuerpo que lleva un texto de 4096 bytes o un múltiplo, así que una cabecera sin cuerpo se rechaza; `infoExtension` rechaza además una DateKey de un perfil que la librería no fija. Un CID con un carácter de más cuyos bits son cero, y `https://[[2000::]/`, se rechazan desde que Go los rechaza (`e801e03` de `datekeys-go`): el §44.1 de la v0.12 ya pedía el CID en su forma canónica y un literal IPv6 con un solo par de corchetes.
`checkResolvedIp` sigue la v0.13 (§44.1, cambio 1 del §76), como `locator.CheckResolvedIP` de Go: una dirección de NAT64 a la que resuelve un nombre, de `64:ff9b::/96` o del prefijo de la red que la aplicación le pasa en `nat64`, cuenta por la IPv4 que lleva dentro. `vectors.test.ts` corre los 42 casos de `resolved_ip.json`.

@ -411,6 +411,18 @@ describe('the types of Go, and what a caller gets wrong', () => {
expect(errorText(() => checkURI('https://a.org/\u0080'))).toBe("locator: an address with the character 'Â', which RFC 3986 does not allow");
});
it('refuses a sealed locator whose body holds no plaintext of 4096 bytes or a multiple, as checkSealed of Go', () => {
const base = fromHex(((v.sealed_bases as Json[])[0]!).sealed as string);
const dateKey = { profileId: 'datekeys:quicknet:v1', round: 1000 };
expect(infoExtension({ note: '', dateKey, sealed: base }).data).toBeDefined();
for (const sealed of [base.subarray(0, base.length - 1), Uint8Array.of(...base, 0)]) {
const end = base.length - (16 + 4096 + 16); // the body of a plaintext of 4096 bytes
expect(errorText(() => infoExtension({ note: '', dateKey, sealed }))).toBe(
`locator: self-check: a reader rejects this extension: locator: the body of the locator is ${sealed.length - end} bytes, which no plaintext of 4096 bytes or a multiple gives: ERR_EXTENSION_DATA_INVALID`,
);
}
});
it('infoExtension refuses a DateKey that is not canonical and a note that breaks its rules, with their errors', () => {
expect(errorText(() => infoExtension({ note: '', dateKey: { profileId: 'datekeys:quicknet:v1', round: 0 } }))).toBe(
'locator: Info.DateKey is not a canonical DateKey',

@ -42,6 +42,7 @@ import { DateKeysError, withContext } from './errors.ts';
import { CAPSULE_ID, type Extension, type ExtensionRegistry, newExtension, NOTE_ID } from './extension.ts';
import { type IpAddress, ipFromBytes, ipString, isIpv4, isIpv4In6, isIpv6, isPublicIp, parseIpAddress } from './ipaddr.ts';
import { checkNote, checkNoteData, MAX_NOTE_LEN } from './note.ts';
import { QUICKNET_CHAIN_HASH, QUICKNET_ID } from './profile.ts';
/** The most addresses of a locator (spec §44.1). */
export const MAX_ADDRESSES = 8;
@ -124,6 +125,9 @@ export function infoExtension(i: Info): Extension {
if (d === undefined || d.profileId !== i.dateKey.profileId || d.round !== i.dateKey.round) {
throw fail('Info.DateKey is not a canonical DateKey');
}
// A writer writes the DateKey of a capsule it wrote: of a profile that this
// library pins, whose chain the check of the locator then compares.
if (d.profileId !== QUICKNET_ID) throw fail(`Info.DateKey is of the profile ${goQuote(d.profileId)}, which this module does not pin`);
const s = i.sealed;
if (s !== undefined && (s.length < 1 || s.length > MAX_SEALED)) {
throw fail(`a sealed locator of ${s.length} bytes, not 1 to ${MAX_SEALED}`);
@ -202,22 +206,54 @@ export function parseInfo(x: Extension): Info {
d = undefined;
}
if (d === undefined || compactDateKey(d) !== dk) throw dataInvalid('compact_datekey is not a canonical DateKey');
if (sealed !== undefined && !sealedFor(sealed, d.round)) {
throw dataInvalid(`the locator is not an age file with one tlock stanza for round ${d.round}, the one of its DateKey`);
if (sealed !== undefined) {
const problem = sealedProblem(sealed, d);
if (problem !== undefined) throw dataInvalid(problem);
}
return { note, dateKey: d, sealed };
}
// Whether sealed is an age file whose header holds one tlock stanza with two
// arguments, the first of them round.
function sealedFor(sealed: Uint8Array, round: number): boolean {
let st: ReturnType<typeof ageStanzas>;
// checkSealed of Go: why the sealed locator does not have the form of spec
// §44.1, as far as it can be checked before the date, or undefined. An age
// file with one tlock stanza, whose arguments are the round of the DateKey d
// in decimal and a chain hash in lower-case hexadecimal (§28.1), the chain of
// the profile of d when this library pins it, Quicknet; and a body that holds
// a plaintext of 4096 bytes or a multiple, as every locator has.
function sealedProblem(sealed: Uint8Array, d: DateKey): string | undefined {
let st: ReturnType<typeof ageStanzas> | undefined;
try {
st = ageStanzas(sealed);
} catch {
return false;
st = undefined;
}
if (st === undefined || st.length !== 1 || st[0]!.type !== STANZA_TLOCK || st[0]!.args.length !== 2 || st[0]!.args[0] !== String(d.round)) {
return `the locator is not an age file with one tlock stanza for round ${d.round}, the one of its DateKey`;
}
const chain = st[0]!.args[1]!;
if (!/^[0-9a-f]{64}$/.test(chain)) return 'the tlock stanza of the locator has no chain hash in lower-case hexadecimal';
if (d.profileId === QUICKNET_ID && chain !== QUICKNET_CHAIN_HASH) {
return `the locator is sealed for the chain ${chain}, not for the one of the profile ${d.profileId} of its DateKey`;
}
// ageStanzas read the header up to its MAC line "--- ", so it ends.
const mac = sealed.findIndex((b, at) => b === 0x0a && sealed[at + 1] === 0x2d && sealed[at + 2] === 0x2d && sealed[at + 3] === 0x2d && sealed[at + 4] === 0x20);
const end = sealed.indexOf(0x0a, mac + 1) + 1;
if (end === sealed.length) return 'the locator is an age header without a body';
if (!sealedBodyLength(sealed.length - end)) {
return `the body of the locator is ${sealed.length - end} bytes, which no plaintext of 4096 bytes or a multiple gives`;
}
return undefined;
}
// sealedBodyLength of Go: whether n bytes after the age header are the body
// of a plaintext of 4096·k bytes, k from 1: the nonce of 16 bytes and the
// plaintext in chunks of 64 KiB, each with its tag of 16 bytes.
function sealedBodyLength(n: number): boolean {
for (let p = BLOCK; p <= MAX_SEALED; p += BLOCK) {
if (n === 16 + p + 16 * Math.ceil(p / 65536)) return true;
}
return st.length === 1 && st[0]!.type === STANZA_TLOCK && st[0]!.args.length === 2 && st[0]!.args[0] === String(round);
return false;
}
/**

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save

Powered by TurnKey Linux.