21 KiB
Plan de implementación: datekeys-go
Implementación de referencia en Go de la DateKeys Protocol Specification v0.8.1.
Estado: borrador para revisión. Fecha: 25 de septiembre de 2026.
1. Objetivo y principios
Construir desde cero una librería Go que implemente el protocolo base tal como lo fija la v0.8.1, con estas reglas:
- La especificación manda. El código no añade semántica. Si el código descubre un problema en el spec, se abre un caso reproducible según la política de cambios de la sección 76.
- Cero criptografía propia. Solo
age,tlocky la verificación BLS dedrand. La librería aporta framing, CBOR, bindings, reglas de verificación y flujo. - Validar todo lo verificable localmente antes de tocar red o secretos. Sección 63.
- Fallar cerrado. Ningún plaintext parcial, ningún error enmascarado, ningún "verified: true" ajeno.
- Superficie pequeña y auditable. Pocas dependencias, todas pinneadas, builds reproducibles, y un mapa de trazabilidad spec ↔ código para la revisión externa.
- Nueva librería, no refactor. Lo aprovechable del prototipo se copia y se adapta; el repositorio actual no se modifica.
2. Alcance de la primera versión de la librería (v0.1.0)
Implementa:
- DateKey: resolución fecha → ronda,
dk1_canónico, vectores (secciones 14 a 19). - Provider Profile Quicknet: struct, CBOR canónico,
profile_hash, registro pinneado (secciones 10 a 13). - Adaptador drand: obtención de releases por relays, verificación BLS local, modo estricto (secciones 35, 45 a 52).
.dkc: prelude, PUBLIC_HEADER, CONTROL_CBOR,header_binding,time_onlyytime_and_key, flujo de cifrado y descifrado completo con SHOULD y MUST (secciones 20 a 39, 61 a 63)..dkk: framing, body, identity portable de un solo uso (secciones 40 a 44).- Extensiones genéricas con reglas de duplicados y orden canónico (sección 54).
- Catálogo de errores (sección 69). Límites de parser (57). Canonicidad CBOR y omisión de opcionales (58, 58.1).
- Fixtures, mutaciones, fuzzing y CLI mínima.
No implementa, a propósito:
- Servidor de Release API ni Release Queue. La librería define la interfaz de fuente de releases; el servidor actual podrá consumirla más adelante.
- Almacenamiento, entrega de
.dkk, servicios, extensiones concretas. - Cliente TypeScript. Vendrá después con la misma suite de fixtures.
3. Decisiones de diseño
3.1 Módulo y layout
Repositorio: Gitea propio, https://g.activething.com/go/DateKeys.git. Módulo: g.activething.com/go/DateKeys, la ruta que el propio Gitea anuncia en su etiqueta go-import. Si más adelante se quiere una ruta en datekeys.com que no dependa del servidor, es un cambio de una línea en go.mod y un sed en las importaciones, siempre antes de publicar. Go 1.26, con go.mod fijando la última versión de parche de la serie. No se usa GitHub para nada del proyecto; las dependencias alojadas allí se obtienen como módulos Go, igual que cualquier otra.
datekeys-go/
go.mod
LICENSE Apache-2.0 (código)
README.md SECURITY.md CONTRIBUTING.md TRADEMARKS.md CHANGELOG.md
spec/
DateKeys_Protocol_Specification_v0.8.1.md copia congelada
datekeys.cddl schema normativo de todos los CBOR
datekey/ DateKey, resolución, dk1_ canónico
profile/ Provider Profile, CBOR canónico, hash, registro pinneado, quicknet.go
provider/ interfaces Condition, Release, ReleaseSource, Verify
provider/drand/ relays HTTP, verificación BLS, modo estricto
codec/ CBOR determinista: Encode, Decode con comprobación por reencodificación, límites
codec/bech32/ codificación Bech32 para la forma humana de identities (ver 4.3)
agewrap/ identities y recipients envolventes de age: cardinalidad, sonda de inspección, stanza tlock
extension/ tipos y reglas de extensiones
capsule/ .dkc: framing, cabecera, control, binding, Encrypt, Inspect, Open
accesskey/ .dkk: framing, body, identity
errors.go sentinel errors del catálogo de la sección 69
internal/testkit/ generación de fixtures, escritor de cabeceras age malformadas, mutadores
testdata/fixtures/ fixtures oficiales y sus valores esperados
docs/traceability.md mapa sección del spec → paquete, función, test
cmd/datekeys/ CLI: encrypt, decrypt, inspect, datekey, profile
3.2 API pública, deliberadamente pequeña
// datekey
type DateKey struct { ProfileID string; Round uint64 }
func Resolve(p *profile.Profile, at time.Time) (DateKey, error) // §15, precisión completa
func Parse(s string) (DateKey, error) // §19, solo forma canónica
func (d DateKey) Compact() string // dk1_...
func (d DateKey) UnlockAt(p *profile.Profile) time.Time
// profile
type Profile struct { ID, Provider, Network string; ChainHash [32]byte; PublicKey []byte;
Period time.Duration; GenesisTime int64; Scheme string; GenesisSeed [32]byte }
func (p *Profile) CanonicalCBOR() []byte
func (p *Profile) Hash() [32]byte // §11
var Quicknet *Profile // §12, pinneado en el binario
type Registry interface { Lookup(id string) (*Profile, bool) }
// provider
type Condition struct { Round uint64 }
type Release struct { Round uint64; Signature []byte }
type ReleaseSource interface { Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error) }
func Verify(p *profile.Profile, c Condition, r Release) error // §51, BLS local siempre
// capsule
type Policy uint8 // TimeOnly = 0, TimeAndKey = 1
type EncryptOptions struct {
Profile *profile.Profile
UnlockAt time.Time
Policy Policy
Recipients []age.Recipient // X25519 de destinatarios conocidos
NewPortableKey bool // genera I_ACCESS y devuelve la .dkk
Critical, Noncritical []extension.Extension
}
type Result struct { DateKey datekey.DateKey; CapsuleID [16]byte; PortableKey *accesskey.AccessKey }
func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
type Inspection struct { /* prelude, cabecera, política, ronda, unlock, resultado de cada comprobación */ }
func Inspect(r io.ReadSeeker, reg profile.Registry) (*Inspection, error) // pasos 1 a 8, sin red ni secretos
type OpenOptions struct {
Registry profile.Registry
Source provider.ReleaseSource
Identities []age.Identity // X25519 propias del destinatario
AccessKey *accesskey.AccessKey // .dkk portable
Now func() time.Time // inyectable para tests
}
func Open(dst io.Writer, r io.ReadSeeker, opts OpenOptions) error // pasos 9 a 18
// accesskey
type AccessKey struct { CredentialID, CapsuleID [16]byte; Type string; Material []byte;
Verification *Verification; Critical, Noncritical []extension.Extension }
func Encode(w io.Writer, k *AccessKey) error
func Decode(r io.Reader) (*AccessKey, error)
func (k *AccessKey) Identity() (age.Identity, error)
Reglas de la API:
io.Readeryio.Writeren todo; el payload nunca se carga entero en memoria.Openrecibeio.ReadSeekerpara poder inspeccionar la cabecera de PAYLOAD_AGE en el offset16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LENantes de pedir el release. Si el lector no permite seek, se omite el SHOULD y se aplica el MUST al abrir.- El reloj se inyecta. Ningún paquete llama a
time.Nowdirectamente salvo la CLI. - Ningún tipo imprime secretos en
String(). Las identities se pasan como tipos deage, no como bytes sueltos, salvo enaccesskey.
3.3 Cómo se usan age y tlock
- PAYLOAD_AGE e INNER_ACCESS_AGE:
age.Encryptyage.Decryptcon recipients e identities X25519 de la API pública deage. - OUTER_TIME_AGE:
age.Encryptcon un recipient propio enagewrapque llama atlock.TimeLockytlock.CiphertextToBytes, ambos exportados, y emite el stanzatlock <ronda> <chainhash>con el mismo formato quetlock. Para abrir, una identity propia que comprueba el conjunto completo de stanzas, la ronda y el chain hash contra la DateKey y el perfil, y llama atlock.TimeUnlock, que verifica el beacon antes de descifrar.- Motivo:
tlock.New(...).Decryptusa una identity no exportada en la que no podemos imponer la cardinalidad ni evitar que todo error se convierta enErrTooEarly. Usando su núcleo exportado se mantiene la compatibilidad de stanza contley se gana control del flujo.
- Motivo:
- Cardinalidad MUST: en las tres identities envolventes, dentro de
Unwrap, que recibe todos los stanzas del fichero. Sin APIs internas. - Inspección SHOULD:
age.ExtractHeaderlee únicamente la cabecera del fichero, yage.DecryptHeadercon una identity sonda, cuyoUnwrapregistra los stanzas recibidos y devuelve un error centinela distinto deErrIncorrectIdentity, entrega los stanzas parseados poragesin descifrar nada ni usar secretos. Ambas funciones son API pública deage1.3. Para PAYLOAD_AGE se aplica en el offset del prelude. No hace falta parser propio.
4. Dependencias
| Dependencia | Versión | Uso | Nota |
|---|---|---|---|
filippo.io/age |
v1.3.2 | ficheros age, X25519, Stanza, Recipient, Identity |
única implementación de STREAM; no se reimplementa |
github.com/drand/tlock |
v1.2.0 | TimeLock, TimeUnlock, CiphertextToBytes, BytesToCiphertext |
no se usa tlock.New, Encrypt ni Decrypt |
github.com/drand/drand/v2 |
v2.1.7 | crypto.Scheme, VerifyBeacon, chain.Info |
arrastra gRPC y otros; govulncheck ya confirmó que no son alcanzables |
github.com/drand/kyber y kyber-bls12381 |
transitivas | pairing BLS12-381 | sobre kilic/bls12-381, archivado; vigilar y documentar en SECURITY.md |
github.com/fxamacker/cbor/v2 |
v2.9.4 | CBOR determinista | CoreDetEncOptions para codificar; límites en decodificación |
Sin otras dependencias en la librería. Tests con la biblioteca estándar. CLI con flag. HTTP con net/http.
4.1 Política de versiones
go.mod pinneado, go.sum verificado, go mod verify en CI, govulncheck en cada PR, Renovate (compatible con Gitea) solo para parches, con revisión manual de cualquier cambio en age, tlock, drand o kyber.
4.2 Canonicidad CBOR
Codificar con Core Deterministic Encoding. Al decodificar, además de los límites de fxamacker (sin longitudes indefinidas, sin tags, sin claves duplicadas, profundidad y tamaños acotados), se reencodifica y se compara byte a byte con la entrada. Cualquier diferencia es ErrNonCanonicalCBOR. Es el mismo principio que dk1_ y no depende de que una librería prometa rechazar todas las formas no canónicas.
4.3 Bech32
age solo acepta identities y recipients X25519 en Bech32 por su API pública, y su paquete Bech32 es internal. El .dkk guarda 32 bytes crudos por spec. Se necesita un codificador Bech32 de unas cien líneas para pasar de bytes a AGE-SECRET-KEY-1... y de age1... a bytes. Es codificación, no criptografía. Opciones: copiar el paquete interno de age con su licencia BSD y atribución, o reimplementar BIP-173 con vectores de prueba. Propuesta: copiar, para no divergir.
5. Qué se copia del prototipo
| Origen | Destino | Cambios |
|---|---|---|
pkg/datekeys/key.go: RoundForTime, TimeForRound, ParseDateKey, FromRound |
datekey/ |
quitar campos de API y el alias EncryptionKey; el struct de respuesta HTTP se queda en el servidor |
pkg/datekeys/key_test.go, FuzzRecipient |
datekey/ |
renombrar, ampliar con vectores de la sección 65 |
internal/quicknet/trust.go |
profile/quicknet.go |
añadir CBOR canónico y hash; conservar la autocomprobación de chain hash |
internal/drand/client.go y tests |
provider/drand/ |
carrera entre relays, respuestas malformadas, cancelación; adaptar a ReleaseSource |
pkg/datekeys/crypto.go |
no se copia | sustituido por agewrap sobre el núcleo exportado de tlock; se conserva la doble verificación BLS |
tests/integration/quicknet_test.go |
provider/drand/, build tag integration |
prueba en vivo contra Quicknet |
cmd/datekeys |
reescrito | misma ergonomía, nueva API |
internal/api, web-client |
no se tocan | consumirán la librería más adelante |
6. Estilo de código
- Identificadores, comentarios de código, mensajes de error y commits en inglés. Documentación de usuario en inglés con versión en español. Motivo: la revisión externa y la comunidad de
agey drand. gofmt,goimports,go vet,staticcheck,golangci-lintcon configuración corta:errcheck,govet,staticcheck,ineffassign,unparam,gosecen modo aviso.- Errores: un sentinel por entrada del catálogo de la sección 69, envueltos con
%wy contexto; nunca se sustituye un error por otro más cómodo. Los tests de mutación compruebanerrors.Is. - Parsers: límites antes de reservar memoria,
io.LimitReader, ningúnpanicalcanzable desde la entrada. Todo parser tiene un objetivo de fuzzing. - Sin estado global mutable. El registro de perfiles se pasa explícitamente; el registro por defecto contiene solo Quicknet.
- Comentarios de paquete y de función citan la sección del spec que implementan, por ejemplo
// Spec §26. - Secretos: no se registran en logs, no aparecen en
String(), se borran de buffers propios al terminar, sin prometer más de lo que Go garantiza. - Salida atómica en la CLI: fichero temporal en el mismo directorio y renombrado al final; nunca sobrescribir destinos existentes.
- Versionado semántico.
v0.xhasta que el spec sea v1.0. Sin promesa de estabilidad de API antes dev1.0.0.
7. Estrategia de tests
- Unitarios por paquete, tabla-driven, biblioteca estándar.
- Vectores de spec, generados por el código y guardados como golden files versionados: ronda (frontera, un segundo antes y después, fracción de segundo tras frontera, 2030-01-01, cercanos al genesis),
dk1_(objeto, JSON canónico, base64url, cadena),profile_hashde Quicknet. - Fixtures
.dkcy.dkk(secciones 67 y 68): generados una vez coninternal/testkitsobre una ronda ya publicada, con la firma BLS embebida en el fixture. Los tests verifican la firma contra la clave pinneada y descifran sin red. Se comparan todos los valores intermedios: cabecera, prelude, binding, control,I_PAYLOAD, plaintext y el error esperado en cada etapa. - Corpus de mutaciones (sección 64): las veinte mutaciones como funciones sobre un fixture válido; cada una afirma su sentinel error concreto.
- Escritor de cabeceras age malformadas en
testkit: añade stanzas o cambia tipos manteniendo el MAC válido, porque en el test se conoce la file key. Demuestra que la comprobación estructural rechaza lo que el MAC aceptaría. Es la prueba directa de la amenaza del creador. Implementación: según la especificación C2SP, el MAC de cabecera es HMAC-SHA-256 con clave HKDF-SHA-256 de la file key e infoheader, sobre el texto de la cabecera;testkitlo recalcula congolang.org/x/crypto/hkdf, ya presente en el grafo porage. Para abrir ficheros de test con una file key conocida se usaage.NewInjectedFileKeyIdentity, también API pública. - Propiedades:
Encode(Decode(x)) == xpara todo fixture; toda reordenación de claves o ensanchamiento de enteros se rechaza como no canónico;Parse(Compact(d)) == d. - Fuzzing nativo de Go: prelude, PUBLIC_HEADER, CONTROL_CBOR,
.dkk,dk1_, cabecera age vía sonda. Corpus sembrado con fixtures y mutaciones. Presupuesto corto en cada PR, largo en nightly. - Integración en vivo con build tag: cifrar hacia ahora más treinta segundos, esperar, abrir por relays reales. Nightly, no en cada PR.
- Interoperabilidad con herramientas ajenas: extraer SEALED_CONTROL de un fixture
time_onlyy abrirlo con la CLItleoficial; extraer PAYLOAD_AGE y abrirlo con la CLIageusandoI_PAYLOAD. Si ambas pasan, la afirmación "son ficheros age estándar" queda demostrada por terceros. - Cobertura: mínimo 90 % en
codec,capsule,accesskey,datekey,agewrap;-racesiempre. - CDDL:
spec/datekeys.cddlvalida los fixtures en un job opcional de CI con la herramientacddl; si no se quiere otra toolchain, se mantiene como documento normativo y se comprueba a mano en cada cambio de schema.
8. CI, releases y cadena de suministro
- Gitea Actions, con la misma sintaxis que GitHub Actions:
testen Linux y Windows con la versión estable de Go y la anterior, macOS cuando haya runner;lint;vulncongovulncheck;fuzz-short;integrationnightly por cron;sbomconcyclonedx-gomod. - Releases con
goreleaser, que publica en Gitea:-trimpath,CGO_ENABLED=0, checksums publicados, firma concosigncon clave propia, ya que la firma sin clave depende de un proveedor OIDC que Gitea no ofrece. SECURITY.mdcon canal de reporte, versiones soportadas y la dependencia dekilic/bls12-381declarada como riesgo conocido y vigilado.TRADEMARKS.md: "DateKeys" reservado; "implements the DateKey protocol" permitido.
9. Hitos y criterios de aceptación
M0. Cimientos. Esqueleto, go.mod pinneado, CI con lint y govulncheck, codec con canonicidad por reencodificación y límites, spec/datekeys.cddl inicial, profile con Quicknet, su CBOR y su hash como primer vector oficial.
Hecho cuando: el hash del perfil está congelado en un golden file y CI está verde en tres sistemas.
M1. Objetos. datekey copiado y ampliado; framing DKC1 y DKK1; codecs de PUBLIC_HEADER, CONTROL_CBOR y .dkk; header_binding; extension; catálogo de errores; fuzzing de todos los parsers.
Hecho cuando: los vectores de ronda y dk1_ están generados y versionados y el fuzzing lleva una noche sin fallos.
M2. time_only. agewrap con recipient e identity tlock estrictos, sonda de inspección, PAYLOAD_AGE, Encrypt, Inspect, Open con los 18 pasos; provider/drand copiado.
Hecho cuando: un fixture time_only sobre ronda pasada se abre offline, la prueba con tle y age externas pasa, y las mutaciones aplicables fallan con su error exacto.
M3. time_and_key. INNER_ACCESS_AGE con varios recipients, .dkk portable de un solo uso, Identities y AccessKey en Open.
Hecho cuando: fixtures time_and_key con uno y varios recipients se abren offline, la reutilización de I_ACCESS está prohibida en Encrypt y probada, y las veinte mutaciones están cubiertas.
M4. Conformidad. Suite de fixtures oficial, golden files, docs/traceability.md completo, integración nightly, SBOM y release firmada v0.1.0.
Hecho cuando: cada sección normativa de la 14 a la 69 apunta a al menos un test, y v0.1.0 se construye de forma reproducible.
M5. CLI. datekeys encrypt, decrypt, inspect, datekey resolve, profile hash. inspect ejecuta solo los pasos 1 a 8 y nunca pide un release ni usa secretos.
Hecho cuando: la CLI abre los fixtures y reproduce la ergonomía del prototipo con salida atómica.
Fuera de estos hitos: SDK TypeScript, migración del cliente web, servidor de Release API sobre la librería, segundo perfil evmnet.
10. Trazabilidad
docs/traceability.md mantiene una tabla sección → paquete → función → test. Se actualiza en el mismo PR que cualquier cambio de código normativo. Es el documento que se entrega al revisor externo junto con el spec, los fixtures y el corpus de mutaciones.
11. Decisiones a revisar
- Decidida: ruta
g.activething.com/go/DateKeys. Queda abierto si se pasa a una ruta endatekeys.comantes de publicar. Nota: el servidor ejecuta Gitea 1.18.3, sin Actions; los workflows de.gitea/workflowsquedan listos para Gitea 1.21 o superior con unact_runner, y hasta entoncesscripts/check.shes el control obligatorio antes de cada push. - Inglés para código, errores y commits.
- Licencia Apache-2.0 para el código, CC-BY-4.0 para el spec.
- Copiar el Bech32 interno de
agefrente a reimplementarlo. - Depender del módulo
drand/v2completo o extraer solo la verificación BLS a un paquete propio sobrekyber-bls12381. La primera opción es más simple; la segunda reduce el grafo y el ruido degovulncheck. - Incluir en la librería un cliente de la Release API de DateKeys además del de relays drand, o dejarlo para el servidor.
- Validación CDDL en CI con toolchain externa, o solo como documento.
- Si
Openexigeio.ReadSeekero aceptaio.Readery degrada el SHOULD.
12. Riesgos conocidos
kilic/bls12-381archivado bajodrandytlock. Mitigación: pinneado, vigilancia, plan de fork o de migración documentado enSECURITY.md.tlocksin etiqueta desde agosto de 2024. Mitigación: pinneado por versión; solo se usa su núcleo exportado.- Fixtures sobre rondas pasadas dependen de que la firma embebida sea auténtica. Mitigación: se verifica BLS en cada ejecución contra la clave pinneada.
- Desviación spec ↔ código. Mitigación: trazabilidad, política de cambios de la sección 76, y una segunda implementación en TypeScript como prueba de interoperabilidad.