Initial implementation of the DateKeys Protocol v0.8.1

Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v0.8.2
dev 2 weeks ago
commit 0bd38f18cf

7
.gitattributes vendored

@ -0,0 +1,7 @@
# LF everywhere, whatever core.autocrlf says (it is true on Windows by default).
* text=auto eol=lf
# Official fixtures are exact bytes: their SHA-256 is part of the test suite.
*.dkc binary
*.dkk binary
*.plaintext binary

@ -0,0 +1,18 @@
# Patch updates only. Any change to age, tlock, drand or kyber is reviewed by
# hand against SECURITY.md before merging, even when Dependabot proposes it.
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels: [dependencies]
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels: [dependencies]

@ -0,0 +1,135 @@
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }}, Go ${{ matrix.go }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: [stable, oldstable]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go }}
- run: go mod verify
- run: go vet ./...
- run: go test -race -count=1 ./...
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: at least 90 % in codec, capsule, accesskey, datekey and agewrap
shell: bash
run: |
set -euo pipefail
for pkg in codec capsule accesskey datekey agewrap; do
pct=$(go test -count=1 -cover "./$pkg" | sed -n 's/.*coverage: \([0-9.]*\)% of statements.*/\1/p')
echo "$pkg: $pct%"
awk -v p="$pct" 'BEGIN { exit !(p >= 90) }'
done
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.14.0
- name: gosec (advisory)
continue-on-error: true
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.14.0
args: --enable-only gosec
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
fuzz-short:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: every parser, 20 s each
shell: bash
run: ./scripts/fuzz.sh 20s
interop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: official age and tle command-line tools
run: |
go install filippo.io/age/cmd/age@v1.3.2
go install github.com/drand/tlock/cmd/tle@v1.2.0
go test -tags interop -count=1 -v ./capsule -run Interop
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- run: go run github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1.12.0 mod -licenses -json -output sbom.cdx.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: sbom.cdx.json
fixtures:
name: vectors reproduce and fixtures are frozen
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- run: |
go run ./internal/testkit/genfixtures -out testdata
git diff --exit-code testdata

@ -0,0 +1,55 @@
name: nightly
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
integration:
name: live Quicknet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- run: go test -tags integration -count=1 -v -timeout 10m ./capsule ./provider/drand -run Live
fuzz-long:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: every parser, 10 min each
shell: bash
env:
FUZZ_MINIMIZE: 10s
run: ./scripts/fuzz.sh 10m
- name: keep failing inputs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-corpus
path: "**/testdata/fuzz/**"
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...

@ -0,0 +1,32 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write # publish the GitHub release
id-token: write # keyless cosign signature
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- run: go test -count=1 ./...
- run: go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1.12.0
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored

@ -0,0 +1,9 @@
# Build outputs
/datekeys
/datekeys.exe
/dist/
*.test
# Coverage and profiles
*.out
*.cdx.json

@ -0,0 +1,28 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- ineffassign
- unparam
exclusions:
# Unchecked Close of read-only files, fmt.Fprint* to the terminal and
# os.Remove of temporary files, as in golangci-lint v1.
presets:
- std-error-handling
rules:
# Tests may ignore errors of set-up writes and helpers.
- path: _test\.go
linters: [errcheck, unparam]
formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/datekeys/datekeys-go

@ -0,0 +1,65 @@
# Reproducible release of the datekeys CLI (plan §8, spec §59).
version: 2
project_name: datekeys
before:
hooks:
- go mod verify
builds:
- id: datekeys
main: ./cmd/datekeys
binary: datekeys
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -buildid=
mod_timestamp: "{{ .CommitTimestamp }}"
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
archives:
- formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- README.es.md
- SECURITY.md
- TRADEMARKS.md
- CHANGELOG.md
checksum:
name_template: checksums.txt
algorithm: sha256
sboms:
- id: cyclonedx
artifacts: binary
cmd: cyclonedx-gomod
documents:
- "{{ .ArtifactName }}.cdx.json"
args: ["bin", "-json", "-output", "$document", "$artifact"]
signs:
# Keyless signature of the checksum file with the workflow's OIDC identity.
- cmd: cosign
artifacts: checksum
signature: "${artifact}.sig"
certificate: "${artifact}.pem"
args:
- sign-blob
- --output-signature=${signature}
- --output-certificate=${certificate}
- ${artifact}
- --yes
changelog:
disable: true
release:
prerelease: auto

@ -0,0 +1,31 @@
# Changelog
All notable changes to this module are documented here. The project follows
semantic versioning; `v0.x` versions make no API stability promise.
## Unreleased — v0.1.0
First implementation of the DateKeys Protocol Specification v0.8.1.
### Added
- `datekey`: local date → round resolution at full precision (§15), canonical
`dk1_` encoding and strict parsing (§18, §19).
- `profile`: Provider Profile Deterministic CBOR and `profile_hash` (§11), the
pinned Quicknet profile with its chain-hash self-check (§12), and pinned
registries (§13).
- `provider`: release sources and local BLS verification (§51);
`provider/drand`: racing public relays, verifying every answer (§48, §49, §52).
- `codec`: Deterministic CBOR with a re-encoding canonicality check (§58, §58.1).
- `extension`: the generic extension mechanism (§54).
- `agewrap`: strict tlock and X25519 age identities that enforce the stanza
rules (§27, §29, §32, §33, §35), and a secret-free header probe.
- `capsule`: `.dkc` framing, `Encrypt` for `time_only` and `time_and_key`
(§61, §62), `Inspect` (§63 steps 1–8) and `Open` (§63 steps 9–18).
- `accesskey`: `.dkk` encoding and decoding (§40–§44).
- `cmd/datekeys`: `encrypt`, `decrypt`, `inspect`, `datekey resolve`,
`profile hash`, with atomic, non-overwriting outputs.
- Official vectors (§65, §66), `.dkc`/`.dkk` fixtures (§67, §68), the mutation
corpus (§64), fuzz targets for every parser, interoperability tests with the
official `age` and `tle` CLIs, and live Quicknet integration tests.
- `spec/datekeys.cddl` and `docs/traceability.md`.

@ -0,0 +1,59 @@
# Contributing
Thank you for helping. This module is the reference implementation of a
specification, so a few rules matter more than usual.
## The specification decides
- Code adds no semantics. If an implementation question reveals a gap or a
problem in the specification, open an issue with a **reproducible case**: a
failing test, a fixture, a mutation or a fuzzing input (spec §76).
- Every change to normative code updates `docs/traceability.md` in the same
pull request, and `spec/datekeys.cddl` when a schema is affected.
- Official vectors and fixtures in `testdata/` are frozen. Changing one needs a
specification change first.
## No cryptography of our own
Only `age`, `tlock` and drand's BLS verification. New cryptographic
dependencies are not accepted without prior discussion.
## Style
- Identifiers, code comments, error messages and commit messages in English.
User documentation in English with a Spanish version.
- `gofmt`, `goimports`, `go vet`, `staticcheck` and `golangci-lint` (see
`.golangci.yml`) must pass.
- Package and function comments cite the section they implement, for example
`// Spec §26`.
- Every protocol failure wraps exactly one sentinel of `errors.go` with `%w`
and context. Never replace an error with a more convenient one.
- Parsers check limits before allocating, never panic on input, and have a
fuzz target.
- No mutable global state. The profile registry and the clock are passed
explicitly; only `cmd/datekeys` reads the wall clock.
- Secrets never appear in logs or `String()` output, and our own buffers are
wiped when done.
## Tests
```bash
go test -race ./...
FUZZ_PARALLEL=4 ./scripts/fuzz.sh 60s # every parser; each worker uses a 100 MB temp file
go test -tags interop ./capsule # needs the age and tle CLIs
go test -tags integration ./... # live Quicknet
```
Coverage must stay at or above 90 % for `codec`, `capsule`, `accesskey`,
`datekey` and `agewrap`.
## Fixtures
`go run ./internal/testkit/genfixtures -out testdata` regenerates the vectors
and creates missing fixtures. It never overwrites existing fixtures unless
`-force` is given, which is reserved for specification changes.
## Commits and releases
Semantic versioning; `v0.x` until the specification reaches v1.0. Each release
updates `CHANGELOG.md`.

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -0,0 +1,148 @@
# datekeys-go
Implementación de referencia en Go de la **DateKeys Protocol Specification
v0.8.1** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.1.md)).
[English version](README.md).
DateKeys cifra datos de forma que solo puedan abrirse a partir de un instante
elegido. La condición temporal procede del beacon de aleatoriedad **Quicknet**
de drand: los datos se sellan con cifrado timelock hacia una ronda futura, y la
firma BLS de esa ronda, que drand publica cuando llega, es la llave. Todo lo que
se puede verificar localmente se verifica localmente; relays, cachés y APIs son
transportes no confiables.
> **Estado: v0.x, pre-estándar.** La especificación es un borrador y la API
> puede cambiar antes de v1.0.0. El código aún no ha pasado una revisión
> criptográfica externa (spec §75). No lo uses para secretos de alto valor.
## Qué implementa
| Objeto | Spec | Paquete |
|---|---|---|
| DateKey: fecha → ronda, cadena canónica `dk1_…` | §14–§19 | [`datekey`](datekey) |
| Provider Profile, perfil Quicknet pinneado, `profile_hash` | §10–§13 | [`profile`](profile) |
| Fuentes de releases, verificación BLS local, relays drand | §45–§52 | [`provider`](provider), [`provider/drand`](provider/drand) |
| DateKeyCap `.dkc`: `time_only` y `time_and_key` | §20–§39, §61–§63 | [`capsule`](capsule) |
| DateKeys Access Key `.dkk` | §40–§44 | [`accesskey`](accesskey) |
| Extensiones | §54 | [`extension`](extension) |
| CBOR determinista | §58 | [`codec`](codec) |
| Errores normativos | §69 | [`errors.go`](errors.go) |
| CLI | — | [`cmd/datekeys`](cmd/datekeys) |
Aquí no se implementa criptografía. El cifrado es [age](https://age-encryption.org)
(`filippo.io/age`); el timelock es [tlock](https://github.com/drand/tlock)
(solo su núcleo exportado); la verificación BLS es la de drand. Este módulo
aporta framing, CBOR, bindings, reglas de verificación y flujo, y aplica las
reglas de stanzas del protocolo dentro de las identities de age, para que un
fichero nunca se acepte solo porque age haya podido desenvolver una clave.
No implementa, a propósito: el servidor y la cola de la Release API, el
almacenamiento y la entrega, extensiones concretas ni el cliente TypeScript
(plan §2).
## Una cápsula, en un dibujo
```text
.dkc = PRELUDE (16 B) || PUBLIC_HEADER (CBOR) || SEALED_CONTROL (age) || PAYLOAD_AGE (age, hasta EOF)
time_only: SEALED_CONTROL = age(tlock ronda R → CONTROL_CBOR)
time_and_key: SEALED_CONTROL = age(tlock ronda R → age(recipients X25519 → CONTROL_CBOR))
CONTROL_CBOR = { header_binding = SHA-256(PRELUDE || PUBLIC_HEADER), I_PAYLOAD, extensiones }
PAYLOAD_AGE = age(X25519 R_PAYLOAD → tus datos), en streaming
```
## CLI
```bash
go install github.com/datekeys/datekeys-go/cmd/datekeys@latest
```
```bash
datekeys datekey resolve -at 2030-01-01T00:00:00Z
datekeys encrypt -at 2030-01-01T00:00:00Z -in carta.txt -out carta.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk carta.dkk -in carta.txt -out carta.dkc
datekeys inspect -in carta.dkc
datekeys decrypt -in carta.dkc -out carta.txt -dkk carta.dkk
datekeys profile hash
```
`encrypt` nunca usa la red. `inspect` ejecuta solo las comprobaciones previas
al desbloqueo (spec §63, pasos 1 a 8): nunca pide un release ni usa secretos.
`decrypt` obtiene el release de relays públicos de drand, lo verifica
localmente y publica el plaintext solo cuando age lo ha autenticado entero.
Nunca se sobrescriben ficheros de salida.
## Librería
```go
reg, err := profile.Default() // perfil Quicknet pinneado, comprobado contra su profile_hash
// Cifrar: sin red, la ronda se resuelve localmente.
res, err := capsule.Encrypt(dst, src, capsule.EncryptOptions{
Profile: profile.Quicknet(),
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
Policy: capsule.TimeAndKey,
NewPortableKey: true, // res.PortableKey es la .dkk; se codifica con accesskey.Encode
Now: time.Now,
})
// Inspeccionar: pasos 1 a 8, sin red ni secretos.
in, err := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
// Abrir: pasos 1 a 18; el release se verifica localmente.
opened, err := capsule.Open(ctx, tmp, f, capsule.OpenOptions{
Registry: reg,
Source: drand.New(),
AccessKey: key, // o Identities: []age.Identity{...}
Now: time.Now,
})
if errors.Is(err, datekeys.ErrReleaseUnavailable) { /* todavía no */ }
```
`Open` escribe el plaintext en streaming; si falla, descarta lo escrito (spec
§56). Todo fallo del protocolo envuelve uno de los 17 errores normativos del
§69, así que `errors.Is` y `datekeys.Code(err)` lo identifican.
## Propiedades de seguridad y límites
- **Confidencialidad temporal** bajo el supuesto de umbral de drand. El
timelock de Quicknet **no es post-cuántico**: los ciphertexts guardados
durante años quedan expuestos a *harvest now, decrypt later* (spec §7.7, §53).
- **Sin confianza en servidores**: el perfil va pinneado en el binario, la ronda
se calcula localmente, los releases se verifican con BLS localmente y una
firma válida de otra ronda se rechaza (spec §13, §17, §51).
- **Integridad**: framing, cabecera, control y payload están autenticados;
cualquier cambio hace fallar la apertura (fixtures y corpus de mutaciones).
- **Sin autoría**: `time_only` da coherencia interna, no prueba de quién creó
la cápsula, ni antes ni después de madurar; `time_and_key` añade una barrera
de acceso, no una firma (spec §36.1).
- **Recuperar años después** exige el release histórico: de un relay drand que
aún lo sirva o de cualquier caché, verificado de nuevo localmente (spec §50).
Ver [SECURITY.md](SECURITY.md).
## Conformidad y tests
```bash
go test ./... # unitarios, vectores golden, fixtures, mutaciones
go test -race -cover ./...
go test -fuzz=FuzzInspect ./capsule # un objetivo de fuzzing cada vez
go test -tags interop ./capsule # las CLI oficiales age y tle abren nuestros ficheros
go test -tags integration ./capsule ./provider/drand # Quicknet en vivo
```
- `testdata/vectors`: vectores de `profile_hash`, fecha→ronda y `dk1_` (spec §65, §66).
- `testdata/fixtures`: fixtures oficiales `.dkc`/`.dkk` sobre rondas ya
publicadas, con la firma BLS embebida y todos los valores intermedios (spec
§67, §68); se descifran sin red.
- `capsule/mutation_test.go`: las 20 mutaciones del §64 y 25 más, cada una con
su error y su paso exactos, comprobando además que los fallos previos al
desbloqueo nunca provocan una petición de release.
- [`docs/traceability.md`](docs/traceability.md): sección del spec → código → test.
- [`spec/datekeys.cddl`](spec/datekeys.cddl): schemas CBOR.
## Licencia
Código: Apache-2.0 ([LICENSE](LICENSE)). Especificación: CC-BY-4.0
([spec/README.md](spec/README.md)). `codec/bech32` se copia de age bajo su
propia licencia. "DateKeys" es un nombre reservado: ver [TRADEMARKS.md](TRADEMARKS.md).

@ -0,0 +1,147 @@
# datekeys-go
Reference implementation in Go of the **DateKeys Protocol Specification
v0.8.1** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.1.md)).
[Versión en español](README.es.md).
DateKeys encrypts data so that it can only be opened after a chosen instant.
The time condition comes from the drand **Quicknet** randomness beacon: data is
sealed with timelock encryption to a future round, and the round's BLS
signature, published by drand when the round arrives, is the key. Everything
that can be verified locally is verified locally; relays, caches and APIs are
untrusted transports.
> **Status: v0.x, pre-standard.** The specification is a draft and the API may
> change before v1.0.0. The code has not had an external cryptographic review
> yet (spec §75). Do not rely on it for high-value secrets.
## What it implements
| Object | Spec | Package |
|---|---|---|
| DateKey: date → round, canonical `dk1_…` string | §14–§19 | [`datekey`](datekey) |
| Provider Profile, pinned Quicknet profile, `profile_hash` | §10–§13 | [`profile`](profile) |
| Release sources, local BLS verification, drand relays | §45–§52 | [`provider`](provider), [`provider/drand`](provider/drand) |
| DateKeyCap `.dkc`: `time_only` and `time_and_key` | §20–§39, §61–§63 | [`capsule`](capsule) |
| DateKeys Access Key `.dkk` | §40–§44 | [`accesskey`](accesskey) |
| Extensions | §54 | [`extension`](extension) |
| Deterministic CBOR | §58 | [`codec`](codec) |
| Normative errors | §69 | [`errors.go`](errors.go) |
| CLI | — | [`cmd/datekeys`](cmd/datekeys) |
No cryptography is implemented here. Encryption is [age](https://age-encryption.org)
(`filippo.io/age`); the timelock is [tlock](https://github.com/drand/tlock)
(its exported core only); BLS verification is drand's. This module adds
framing, CBOR, bindings, verification rules and the flow, and it enforces the
stanza rules of the protocol inside the age identities, so that a file is never
accepted just because age could unwrap a key.
Not implemented on purpose: the Release API server and queue, storage and
delivery, concrete extensions, and the TypeScript client (plan §2).
## A capsule, in one picture
```text
.dkc = PRELUDE (16 B) || PUBLIC_HEADER (CBOR) || SEALED_CONTROL (age) || PAYLOAD_AGE (age, to EOF)
time_only: SEALED_CONTROL = age(tlock round R → CONTROL_CBOR)
time_and_key: SEALED_CONTROL = age(tlock round R → age(X25519 recipients → CONTROL_CBOR))
CONTROL_CBOR = { header_binding = SHA-256(PRELUDE || PUBLIC_HEADER), I_PAYLOAD, extensions }
PAYLOAD_AGE = age(X25519 R_PAYLOAD → your data), streamed
```
## CLI
```bash
go install github.com/datekeys/datekeys-go/cmd/datekeys@latest
```
```bash
datekeys datekey resolve -at 2030-01-01T00:00:00Z
datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk letter.dkk -in letter.txt -out letter.dkc
datekeys inspect -in letter.dkc
datekeys decrypt -in letter.dkc -out letter.txt -dkk letter.dkk
datekeys profile hash
```
`encrypt` never touches the network. `inspect` runs only the pre-unlock checks
(spec §63 steps 1–8): it never requests a release and never uses a secret.
`decrypt` fetches the release from public drand relays, verifies it locally
and publishes the plaintext only after age authenticated all of it. Outputs are
never overwritten.
## Library
```go
reg, err := profile.Default() // pinned Quicknet profile, checked against its profile_hash
// Encrypt: no network, the round is resolved locally.
res, err := capsule.Encrypt(dst, src, capsule.EncryptOptions{
Profile: profile.Quicknet(),
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
Policy: capsule.TimeAndKey,
NewPortableKey: true, // res.PortableKey is the .dkk; encode it with accesskey.Encode
Now: time.Now,
})
// Inspect: steps 1–8, no network, no secrets.
in, err := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
// Open: steps 1–18; the release is verified locally.
opened, err := capsule.Open(ctx, tmp, f, capsule.OpenOptions{
Registry: reg,
Source: drand.New(),
AccessKey: key, // or Identities: []age.Identity{...}
Now: time.Now,
})
if errors.Is(err, datekeys.ErrReleaseUnavailable) { /* not yet */ }
```
`Open` streams the plaintext; on error, discard what was written (spec §56).
Every protocol failure wraps one of the 17 normative errors of spec §69, so
`errors.Is` and `datekeys.Code(err)` identify it.
## Security properties and limits
- **Time confidentiality** holds under drand's threshold assumption. The
Quicknet timelock is **not post-quantum**: ciphertexts kept for years are
exposed to harvest-now, decrypt-later (spec §7.7, §53).
- **No trust in servers**: the profile is pinned in the binary, the round is
computed locally, releases are BLS-verified locally, and a valid signature of
another round is rejected (spec §13, §17, §51).
- **Integrity**: framing, header, control and payload are all authenticated;
any change makes opening fail (fixtures and the mutation corpus).
- **No authorship**: `time_only` gives internal coherence, not proof of who
created a capsule, before or after it matures; `time_and_key` adds an access
barrier, not a signature (spec §36.1).
- **Recovery years later** needs the historical release: from a drand relay
that still serves it or from any cache, re-verified locally (spec §50).
See [SECURITY.md](SECURITY.md).
## Conformance and tests
```bash
go test ./... # unit, golden vectors, fixtures, mutation corpus
go test -race -cover ./...
go test -fuzz=FuzzInspect ./capsule # one fuzz target at a time
go test -tags interop ./capsule # official age and tle CLIs open our files
go test -tags integration ./capsule ./provider/drand # live Quicknet
```
- `testdata/vectors`: profile hash, date→round and `dk1_` vectors (spec §65, §66).
- `testdata/fixtures`: official `.dkc`/`.dkk` fixtures over published rounds,
with the BLS signature embedded and every intermediate value (spec §67, §68);
they decrypt offline.
- `capsule/mutation_test.go`: the 20 mutations of spec §64 and 25 more, each
with its exact error and step, and a check that pre-unlock failures never
cause a release request.
- [`docs/traceability.md`](docs/traceability.md): spec section → code → test.
- [`spec/datekeys.cddl`](spec/datekeys.cddl): CBOR schemas.
## License
Code: Apache-2.0 ([LICENSE](LICENSE)). Specification: CC-BY-4.0
([spec/README.md](spec/README.md)). `codec/bech32` is copied from age under its
own license. "DateKeys" is a reserved name: see [TRADEMARKS.md](TRADEMARKS.md).

@ -0,0 +1,86 @@
# Security policy
## Reporting a vulnerability
Please report vulnerabilities privately, not in public issues:
- GitHub private vulnerability reporting on this repository (Security → Report
a vulnerability), once the `datekeys` organisation hosts it.
- Until then, contact the maintainers privately and ask for an encrypted
channel.
Include a reproducible case: ideally a `.dkc` or `.dkk` file, or a test in the
style of `capsule/mutation_test.go`. We aim to acknowledge reports within
three working days.
## Supported versions
The module is pre-1.0 (`v0.x`). Only the latest `v0.x` release receives fixes.
## Scope and assumptions
In scope: every rule of the DateKeys Protocol Specification v0.8.1 this module
implements (see `docs/traceability.md`), the CLI, and the handling of
untrusted input (`.dkc`, `.dkk`, relay responses).
The protocol's own limits, which are not vulnerabilities of this module:
- The Quicknet timelock is not post-quantum; long-lived ciphertexts are exposed
to harvest-now, decrypt-later (spec §7.7, §53).
- Time confidentiality depends on drand's threshold assumption (spec §7.6).
- `time_only` and `time_and_key` do not authenticate the creator (spec §36.1).
- Opening a mature capsule years later needs the historical release (spec §50).
- A compromised device can copy plaintext or secrets (spec §7.8).
- Go cannot guarantee that secrets are erased from memory. The module wipes
its own buffers (`I_PAYLOAD`, `CONTROL_CBOR`, `.dkk` material) on a best
effort basis and promises no more.
## Cryptographic dependencies
No cryptography is implemented in this module. It depends on:
| Dependency | Role |
|---|---|
| `filippo.io/age` v1.3.2 | age files, X25519, STREAM, header MAC |
| `github.com/drand/tlock` v1.2.0 | `TimeLock`, `TimeUnlock`, ciphertext encoding |
| `github.com/drand/drand/v2` v2.1.7 | BLS verification (`crypto.Scheme`), chain-info hash |
| `github.com/drand/kyber`, `github.com/drand/kyber-bls12381` | BLS12-381 pairing |
| `github.com/fxamacker/cbor/v2` v2.9.4 | Deterministic CBOR |
All versions are pinned in `go.mod` and verified through `go.sum`. Changes to
`age`, `tlock`, `drand` or `kyber` are reviewed manually.
### Known risks under watch
- **`github.com/kilic/bls12-381` is archived.** `kyber-bls12381` builds on it,
and both `drand` and `tlock` depend on that stack. Mitigation: pinned
versions, `govulncheck` on every change and nightly, and this plan if a
vulnerability appears or the dependency becomes untenable: (1) move to a
maintained fork adopted by drand, or (2) extract BLS verification onto a
maintained BLS12-381 implementation, keeping the golden vectors and fixtures
as the acceptance test.
- **`tlock` has had no tagged release since August 2024.** Only its exported
core (`TimeLock`, `TimeUnlock`, `CiphertextToBytes`, `BytesToCiphertext`) is
used, pinned by version.
- **`drand/v2` brings gRPC and protobuf into the binary** through
`common/chain`, used for the chain-hash self-check of profiles. With the
`google.golang.org/grpc` v1.81.1 that `drand/v2` v2.1.7 selects,
`govulncheck` reported GO-2026-6348 and GO-2026-6061 as reachable, so
`go.mod` requires grpc v1.84.0, and `golang.org/x/crypto` v0.57.0 for
GO-2026-6354 and GO-2026-6355. With those, `govulncheck` v1.8.0 on
Go 1.26.8 finds no reachable vulnerability (25 September 2026); two
unreachable advisories without a released fix remain, GO-2026-6443 (grpc)
and GO-2026-5932 (x/crypto). Build with a patched Go toolchain: Go 1.26.0
itself has reachable standard-library advisories fixed in 1.26.1 and later.
Plan §11 item 5 (extracting BLS verification onto `kyber-bls12381` alone)
would remove gRPC from the graph entirely.
- **Fixtures over past rounds** rely on the embedded signatures being genuine;
every test run verifies them against the pinned public key.
## Supply chain
- Reproducible builds: `-trimpath`, `CGO_ENABLED=0`, pinned toolchain in CI.
- Releases publish SHA-256 checksums and a CycloneDX SBOM, and are signed with
cosign once the organisation's signing identity exists.
- The Quicknet root of trust is compiled into the binary and checked against
its pinned `profile_hash` (`4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4`).

@ -0,0 +1,20 @@
# Trademarks
"DateKeys" is a reserved name of the DateKeys project. The Apache-2.0 license
of the code and the CC-BY-4.0 license of the specification do not grant any
right to use it as a product, service or organisation name (Apache-2.0 §6).
Allowed without asking:
- Stating compatibility in plain words, for example "implements the DateKey
protocol", "reads and writes DateKeyCap (.dkc) files" or "compatible with
DateKeys v0.8.1", as long as it is true for the version named.
- Referring to this project, its specification or its file formats by name in
documentation, articles and talks.
Not allowed without written permission:
- Naming a product, service, package, domain or organisation "DateKeys" or a
confusingly similar name.
- Suggesting endorsement by, or affiliation with, the DateKeys project.
- Calling a modified or incompatible implementation "DateKeys".

@ -0,0 +1,243 @@
// Package accesskey implements the DateKeys Access Key, the portable .dkk
// credential (spec §38, §40-§44).
//
// A .dkk is a sensitive capability (spec §7.4). Its X25519 identity is stored
// as 32 raw bytes; the Bech32 AGE-SECRET-KEY-1... form is only an export
// format for humans (spec §38). No type in this package prints the material.
package accesskey
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/extension"
)
// Framing and schema constants (spec §40, §41).
const (
Magic = "DKK1"
FramingVersion = 1
PreludeSize = 12
// MaxBodyLen is the parser limit of spec §57, checked before allocating.
MaxBodyLen = 16 << 20
TypeTag = "datekeys-access-key"
SchemaVersion = 1
// TypeX25519 is the only access_type of V1 (spec §41).
TypeX25519 = "x25519"
idSize = 16
digestSize = 32
x25519Size = 32
)
// AccessKey is a decoded .dkk.
type AccessKey struct {
CredentialID [16]byte // key 2, random and opaque (spec §42)
CapsuleID [16]byte // key 3, the only capsule this credential is for (spec §38)
Type string // key 4, access_type
Material []byte // key 5, access_material: 32 raw X25519 identity bytes. SECRET.
// Verification is key 6, optional; nil when absent (spec §43, §58.1).
Verification *Verification
Critical []extension.Extension // key 7
Noncritical []extension.Extension // key 8
}
// Verification is verification_metadata (spec §43). It supports fast failure
// and UX only; it is not a security property.
type Verification struct {
CapsuleDigest []byte // key 0, SHA-256 of the exact .dkc bytes
}
type bodyWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CredentialID []byte `cbor:"2,keyasint"`
CapsuleID []byte `cbor:"3,keyasint"`
AccessType string `cbor:"4,keyasint"`
Material []byte `cbor:"5,keyasint"`
Verification *verificationWire `cbor:"6,keyasint,omitempty"`
Critical []extension.Wire `cbor:"7,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"8,keyasint,omitempty"`
}
type verificationWire struct {
CapsuleDigest []byte `cbor:"0,keyasint,omitempty"`
}
// String describes k without its material.
func (k AccessKey) String() string {
return fmt.Sprintf("AccessKey{credential_id=%x capsule_id=%x type=%s material=REDACTED}", k.CredentialID, k.CapsuleID, k.Type)
}
// GoString describes k without its material.
func (k AccessKey) GoString() string { return k.String() }
// Identity returns the age identity of an x25519 access key.
func (k *AccessKey) Identity() (age.Identity, error) {
if err := k.validateMaterial(); err != nil {
return nil, err
}
id, err := agewrap.X25519IdentityFromRaw(k.Material)
if err != nil {
return nil, fmt.Errorf("accesskey: %v: %w", err, datekeys.ErrAccessInvalid)
}
return id, nil
}
// Wipe overwrites the material in place. It is best effort: Go may have made
// copies that cannot be reached.
func (k *AccessKey) Wipe() { clear(k.Material) }
func (k *AccessKey) validateMaterial() error {
if k.Type != TypeX25519 {
return fmt.Errorf("accesskey: access_type %q is not supported by V1: %w", k.Type, datekeys.ErrAccessInvalid)
}
if len(k.Material) != x25519Size {
return fmt.Errorf("accesskey: x25519 access_material is %d bytes, want %d: %w", len(k.Material), x25519Size, datekeys.ErrAccessInvalid)
}
return nil
}
// MarshalBody returns BODY_CBOR, the Deterministic CBOR body of k (spec §41).
func (k *AccessKey) MarshalBody() ([]byte, error) {
if err := k.validateMaterial(); err != nil {
return nil, err
}
w := bodyWire{
Type: TypeTag,
Version: SchemaVersion,
CredentialID: k.CredentialID[:],
CapsuleID: k.CapsuleID[:],
AccessType: k.Type,
Material: k.Material,
}
if k.Verification != nil {
if len(k.Verification.CapsuleDigest) != digestSize {
// An empty map is not a canonical representation of absence (spec §43).
return nil, fmt.Errorf("accesskey: capsule_digest must be %d bytes: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
}
w.Verification = &verificationWire{CapsuleDigest: k.Verification.CapsuleDigest}
}
var err error
if w.Critical, err = extension.Encode(k.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(k.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
return nil, err
}
b, err := codec.Marshal(w)
if err != nil {
return nil, err
}
if len(b) > MaxBodyLen {
return nil, fmt.Errorf("accesskey: body of %d bytes exceeds %d", len(b), MaxBodyLen)
}
return b, nil
}
// Encode writes k as a complete .dkk: prelude and BODY_CBOR (spec §40).
func Encode(w io.Writer, k *AccessKey) error {
body, err := k.MarshalBody()
if err != nil {
return err
}
var pre [PreludeSize]byte
copy(pre[0:4], Magic)
pre[4] = FramingVersion
binary.BigEndian.PutUint32(pre[8:12], uint32(len(body)))
if _, err := w.Write(pre[:]); err != nil {
return err
}
_, err = w.Write(body)
return err
}
// Decode reads exactly one .dkk from r and validates its framing, its
// canonical body and its fields. Bytes after BODY_CBOR are rejected.
//
// Decode does not decide whether critical extensions are known; the consumer
// checks them against its extension.Registry (capsule.Open does).
func Decode(r io.Reader) (*AccessKey, error) {
var pre [PreludeSize]byte
n, err := io.ReadFull(r, pre[:])
if n < 4 || string(pre[0:4]) != Magic {
return nil, fmt.Errorf("accesskey: %w", datekeys.ErrInvalidMagic)
}
if err != nil {
return nil, fmt.Errorf("accesskey: truncated prelude: %w", datekeys.ErrIntegrity)
}
if pre[4] != FramingVersion {
return nil, fmt.Errorf("accesskey: framing version %d: %w", pre[4], datekeys.ErrUnsupportedVersion)
}
if pre[5] != 0 || pre[6] != 0 || pre[7] != 0 {
return nil, fmt.Errorf("accesskey: flags %#x, reserved %#x%02x: %w", pre[5], pre[6], pre[7], datekeys.ErrInvalidFlags)
}
bodyLen := binary.BigEndian.Uint32(pre[8:12])
if bodyLen > MaxBodyLen {
return nil, fmt.Errorf("accesskey: BODY_LEN %d exceeds the %d-byte limit: %w", bodyLen, MaxBodyLen, datekeys.ErrIntegrity)
}
// The buffer grows with the data actually read, so a short file that
// declares a large BODY_LEN does not force an allocation of that size.
var buf bytes.Buffer
if _, err := io.CopyN(&buf, r, int64(bodyLen)); err != nil {
return nil, fmt.Errorf("accesskey: truncated body: %w", datekeys.ErrIntegrity)
}
body := buf.Bytes()
var extra [1]byte
switch n, err := io.ReadFull(r, extra[:]); {
case n != 0:
return nil, fmt.Errorf("accesskey: data after BODY_CBOR: %w", datekeys.ErrIntegrity)
case !errors.Is(err, io.EOF):
return nil, fmt.Errorf("accesskey: reading after BODY_CBOR: %w", err)
}
return DecodeBody(body)
}
// DecodeBody validates and decodes BODY_CBOR.
func DecodeBody(body []byte) (*AccessKey, error) {
if err := codec.CheckSchema(body, TypeTag, SchemaVersion); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
var w bodyWire
if err := codec.Unmarshal(body, &w); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
if len(w.CredentialID) != idSize || len(w.CapsuleID) != idSize {
return nil, fmt.Errorf("accesskey: credential_id and capsule_id must be %d bytes: %w", idSize, datekeys.ErrNonCanonicalCBOR)
}
k := &AccessKey{Type: w.AccessType, Material: bytes.Clone(w.Material)}
copy(k.CredentialID[:], w.CredentialID)
copy(k.CapsuleID[:], w.CapsuleID)
if w.Verification != nil {
if len(w.Verification.CapsuleDigest) != digestSize {
return nil, fmt.Errorf("accesskey: verification_metadata must hold a %d-byte capsule_digest: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
}
k.Verification = &Verification{CapsuleDigest: bytes.Clone(w.Verification.CapsuleDigest)}
}
var err error
if k.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("accesskey: critical_extensions: %w", err)
}
if k.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
if err := k.validateMaterial(); err != nil {
return nil, err
}
clear(w.Material)
return k, nil
}

@ -0,0 +1,284 @@
package accesskey_test
import (
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/fxamacker/cbor/v2"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
const fixtures = "../testdata/fixtures"
func loadDKK(t *testing.T, name string) ([]byte, testkit.DKKFixture) {
t.Helper()
var f testkit.DKKFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".dkk.json"), &f); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(fixtures, f.File))
if err != nil {
t.Fatal(err)
}
return b, f
}
// Spec §68: parse, validate and use the official .dkk fixtures.
func TestFixtures(t *testing.T) {
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
t.Run(name, func(t *testing.T) {
b, f := loadDKK(t, name)
k, err := accesskey.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(k.CredentialID[:]) != f.CredentialID || hex.EncodeToString(k.CapsuleID[:]) != f.CapsuleID ||
k.Type != f.AccessType || hex.EncodeToString(k.Material) != f.Material ||
k.Verification == nil || hex.EncodeToString(k.Verification.CapsuleDigest) != f.CapsuleDigest ||
len(k.Critical)+len(k.Noncritical) != len(f.Extensions) {
t.Fatalf("decoded values differ from the fixture: %v", k)
}
// Encode(Decode(x)) == x.
var out bytes.Buffer
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), b) {
t.Fatal("re-encoding differs from the fixture bytes")
}
// Expected result: the identity opens the INNER_ACCESS_AGE of its capsule.
var cf testkit.DKCFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".json"), &cf); err != nil {
t.Fatal(err)
}
dkc, _ := os.ReadFile(filepath.Join(fixtures, f.Capsule))
parts, _ := testkit.Split(dkc)
timeID, _ := agewrap.NewTimeIdentity(testkitProfile(), cf.Release.Round, testkit.Release(cf.Release.Round))
inner := mustDecrypt(t, parts.Sealed, timeID)
id, err := k.Identity()
if err != nil {
t.Fatal(err)
}
acc, _ := agewrap.NewAccessIdentity(id)
if control := mustDecrypt(t, inner, acc); hex.EncodeToString(control) != cf.ControlCBOR {
t.Fatal("the .dkk does not yield the expected CONTROL_CBOR")
}
})
}
}
func TestSecretsAreNotPrinted(t *testing.T) {
b, f := loadDKK(t, "time_and_key_portable")
k, _ := accesskey.Decode(bytes.NewReader(b))
for _, format := range []string{"%v", "%+v", "%#v", "%s"} {
for _, v := range []any{k, *k} {
if s := fmt.Sprintf(format, v); strings.Contains(s, f.Material) || !strings.Contains(s, "REDACTED") {
t.Fatalf("%s leaks or hides nothing: %s", format, s)
}
}
}
}
func frame(body []byte) []byte {
pre := make([]byte, accesskey.PreludeSize)
copy(pre, accesskey.Magic)
pre[4] = accesskey.FramingVersion
binary.BigEndian.PutUint32(pre[8:], uint32(len(body)))
return append(pre, body...)
}
func TestDecodeRejects(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
body := good[accesskey.PreludeSize:]
var w map[uint64]any
if err := codec.Unmarshal(body, &w); err != nil {
t.Fatal(err)
}
with := func(edit func(m map[uint64]any)) []byte {
m := map[uint64]any{}
for k, v := range w {
m[k] = v
}
edit(m)
b, err := codec.Marshal(m)
if err != nil {
t.Fatal(err)
}
return frame(b)
}
set := func(b []byte, i int, v byte) []byte { c := bytes.Clone(b); c[i] = v; return c }
bigLen := bytes.Clone(good)
binary.BigEndian.PutUint32(bigLen[8:], accesskey.MaxBodyLen+1)
for _, tc := range []struct {
name string
in []byte
want error
}{
{"magic", set(good, 3, '2'), datekeys.ErrInvalidMagic},
{"a .dkc", append([]byte("DKC1"), good[4:]...), datekeys.ErrInvalidMagic},
{"empty", nil, datekeys.ErrInvalidMagic},
{"framing version", set(good, 4, 2), datekeys.ErrUnsupportedVersion},
{"flags", set(good, 5, 1), datekeys.ErrInvalidFlags},
{"reserved", set(good, 7, 1), datekeys.ErrInvalidFlags},
{"body length above the limit", bigLen, datekeys.ErrIntegrity},
{"truncated prelude", good[:10], datekeys.ErrIntegrity},
{"truncated body", good[:len(good)-1], datekeys.ErrIntegrity},
{"trailing data", append(bytes.Clone(good), 0), datekeys.ErrIntegrity},
{"schema version", with(func(m map[uint64]any) { m[1] = uint64(2) }), datekeys.ErrUnsupportedVersion},
{"type tag", with(func(m map[uint64]any) { m[0] = "datekeycap" }), datekeys.ErrNonCanonicalCBOR},
{"empty verification map", with(func(m map[uint64]any) { m[6] = map[uint64]any{} }), datekeys.ErrNonCanonicalCBOR},
{"empty extension array", with(func(m map[uint64]any) { m[8] = []any{} }), datekeys.ErrNonCanonicalCBOR},
{"null verification", with(func(m map[uint64]any) { m[6] = nil }), datekeys.ErrNonCanonicalCBOR},
{"unknown key", with(func(m map[uint64]any) { m[9] = "x" }), datekeys.ErrNonCanonicalCBOR},
{"short capsule_id", with(func(m map[uint64]any) { m[3] = make([]byte, 15) }), datekeys.ErrNonCanonicalCBOR},
{"short digest", with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 31)} }), datekeys.ErrNonCanonicalCBOR},
{"unknown access type", with(func(m map[uint64]any) { m[4] = "mlkem768" }), datekeys.ErrAccessInvalid},
{"short material", with(func(m map[uint64]any) { m[5] = make([]byte, 31) }), datekeys.ErrAccessInvalid},
{"non-canonical body", frame(append([]byte{0xb9, 0x00, 0x07}, body[1:]...)), datekeys.ErrNonCanonicalCBOR},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := accesskey.Decode(bytes.NewReader(tc.in)); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
}
}
func TestEncodeRejectsAbsenceAsEmptyMap(t *testing.T) {
id, _ := age.GenerateX25519Identity()
raw, _ := agewrap.RawX25519Identity(id)
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: raw, Verification: &accesskey.Verification{}}
if err := accesskey.Encode(io.Discard, k); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("empty verification map encoded: %v", err)
}
k.Verification = nil
k.Noncritical = []extension.Extension{{ID: "org.example.delivery", Version: 1}}
var b bytes.Buffer
if err := accesskey.Encode(&b, k); err != nil {
t.Fatal(err)
}
back, err := accesskey.Decode(&b)
if err != nil || back.Verification != nil || len(back.Noncritical) != 1 {
t.Fatalf("%v %v", back, err)
}
}
func TestIdentityWipeAndEncodeErrors(t *testing.T) {
b, _ := loadDKK(t, "time_and_key_portable")
k, _ := accesskey.Decode(bytes.NewReader(b))
other := *k
other.Type = "mlkem768"
if _, err := other.Identity(); !errors.Is(err, datekeys.ErrAccessInvalid) {
t.Fatalf("unsupported type: %v", err)
}
dup := []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
for name, edit := range map[string]func(k *accesskey.AccessKey){
"short material": func(k *accesskey.AccessKey) { k.Material = k.Material[:31] },
"repeated critical": func(k *accesskey.AccessKey) { k.Critical = dup },
"repeated noncritical": func(k *accesskey.AccessKey) { k.Noncritical = dup },
"both arrays": func(k *accesskey.AccessKey) { k.Critical, k.Noncritical = dup[:1], dup[1:] },
} {
c := *k
c.Material = bytes.Clone(k.Material)
edit(&c)
if err := accesskey.Encode(io.Discard, &c); err == nil {
t.Errorf("%s: encoded", name)
}
}
if err := accesskey.Encode(failingWriter{}, k); err == nil {
t.Fatal("write error ignored")
}
k.Wipe()
if !bytes.Equal(k.Material, make([]byte, 32)) {
t.Fatal("material not wiped")
}
}
func TestDecodeBodyExtensionRules(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
var m map[uint64]any
if err := codec.Unmarshal(good[accesskey.PreludeSize:], &m); err != nil {
t.Fatal(err)
}
ext := func(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
for name, edit := range map[string]func(m map[uint64]any){
"critical out of order": func(m map[uint64]any) { m[7] = []any{ext("b", 1), ext("a", 1)} },
"noncritical repeated": func(m map[uint64]any) { m[8] = []any{ext("a", 1), ext("a", 2)} },
"both arrays": func(m map[uint64]any) { m[7] = []any{ext("a", 1)}; m[8] = []any{ext("a", 1)} },
// Raw data is copied verbatim by the outer re-encoding, so the
// extension layer must reject 1 encoded in two bytes on its own.
"non-canonical ext data": func(m map[uint64]any) {
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbor.RawMessage{0x18, 0x01}}}
},
"empty critical array": func(m map[uint64]any) { m[7] = []any{} },
"extension id not string": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: uint64(1), 1: uint64(1)}} },
} {
c := map[uint64]any{}
for k, v := range m {
c[k] = v
}
edit(c)
b, err := codec.Marshal(c)
if err != nil {
t.Fatal(err)
}
if _, err := accesskey.DecodeBody(b); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
func FuzzDecode(f *testing.F) {
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
b, err := os.ReadFile(filepath.Join(fixtures, name+".dkk"))
if err == nil {
f.Add(b)
}
}
f.Add([]byte("DKK1\x01\x00\x00\x00\x00\x00\x00\x01\xa0"))
f.Fuzz(func(t *testing.T, in []byte) {
k, err := accesskey.Decode(bytes.NewReader(in))
if err != nil {
if datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
return
}
var out bytes.Buffer
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), in) {
t.Fatal("accepted a .dkk that does not re-encode to its input")
}
})
}
func testkitProfile() *profile.Profile { return profile.Quicknet() }
func mustDecrypt(t *testing.T, file []byte, id age.Identity) []byte {
t.Helper()
r, err := age.Decrypt(bytes.NewReader(file), id)
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return b
}

@ -0,0 +1,407 @@
// Package agewrap holds the age recipients and identities that DateKeys wraps
// around standard age files (spec §28-§37), plus the structural stanza rules
// every DateKeys age file must satisfy.
//
// The cryptography is age, tlock and drand's BLS verification. This package
// adds only the rules of the protocol:
//
// - OUTER_TIME_AGE holds exactly one tlock stanza for the expected round and
// the pinned chain hash (spec §32, §35, §63 step 11).
// - PAYLOAD_AGE holds exactly one X25519 stanza, for R_PAYLOAD (spec §29,
// §63 step 17).
// - INNER_ACCESS_AGE holds one or more stanzas, all X25519, one per
// recipient (spec §33, §63 step 13).
//
// The rules are enforced inside Identity.Unwrap, which age calls with the
// complete set of stanzas of the file, so that no file is accepted just
// because age managed to unwrap a file key (spec §27, §63). The same checks
// are exposed for the pre-unlock inspection, which reads the stanzas through a
// probe identity without decrypting anything or touching secrets.
package agewrap
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strconv"
"strings"
"filippo.io/age"
"github.com/drand/drand/v2/common"
"github.com/drand/drand/v2/crypto"
"github.com/drand/kyber"
"github.com/drand/tlock"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec/bech32"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// Stanza types of V1.
const (
StanzaTLock = "tlock"
StanzaX25519 = "X25519"
)
// FileKeySize is the size of every age file key: FK_PAYLOAD, FK_ACCESS and
// FK_TIME (spec §28).
const FileKeySize = 16
// ---------------------------------------------------------------------------
// Structural rules
// CheckTimeStanzas enforces the OUTER_TIME_AGE rule: exactly one stanza, of
// type tlock, whose round is the DateKey round and whose chain hash is the one
// of the pinned profile (spec §32, §35, §63 steps 5, 8 and 11).
func CheckTimeStanzas(stanzas []*age.Stanza, p *profile.Profile, round uint64) error {
if len(stanzas) != 1 {
return fmt.Errorf("agewrap: OUTER_TIME_AGE has %d stanzas, want exactly one tlock stanza: %w", len(stanzas), datekeys.ErrPolicyStructureMismatch)
}
s := stanzas[0]
if s.Type != StanzaTLock {
return fmt.Errorf("agewrap: OUTER_TIME_AGE stanza type %q, want %q: %w", s.Type, StanzaTLock, datekeys.ErrPolicyStructureMismatch)
}
if len(s.Args) != 2 {
return fmt.Errorf("agewrap: tlock stanza has %d arguments, want 2: %w", len(s.Args), datekeys.ErrPolicyStructureMismatch)
}
if want := strconv.FormatUint(round, 10); s.Args[0] != want {
return fmt.Errorf("agewrap: tlock stanza round %q, DateKey round %s: %w", s.Args[0], want, datekeys.ErrRoundMismatch)
}
if want := p.ChainHashHex(); s.Args[1] != want {
return fmt.Errorf("agewrap: tlock stanza chain hash %q, pinned profile %s uses %s: %w", s.Args[1], p.ID, want, datekeys.ErrProfileMismatch)
}
return nil
}
// CheckPayloadStanzas enforces the PAYLOAD_AGE rule: exactly one stanza, of
// type X25519 (spec §29, §63 steps 6 and 17).
func CheckPayloadStanzas(stanzas []*age.Stanza) error {
if len(stanzas) != 1 {
return fmt.Errorf("agewrap: PAYLOAD_AGE has %d stanzas, want exactly one X25519 stanza: %w", len(stanzas), datekeys.ErrPolicyStructureMismatch)
}
if t := stanzas[0].Type; t != StanzaX25519 {
return fmt.Errorf("agewrap: PAYLOAD_AGE stanza type %q, want %q: %w", t, StanzaX25519, datekeys.ErrPolicyStructureMismatch)
}
return nil
}
// CheckAccessStanzas enforces the INNER_ACCESS_AGE rule: one or more stanzas,
// all of type X25519 (spec §33, §36, §63 step 13). Two stanzas with the same
// ephemeral share would be two stanzas for one recipient and are rejected.
func CheckAccessStanzas(stanzas []*age.Stanza) error {
if len(stanzas) == 0 {
return fmt.Errorf("agewrap: INNER_ACCESS_AGE has no stanzas: %w", datekeys.ErrPolicyStructureMismatch)
}
seen := make(map[string]bool, len(stanzas))
for i, s := range stanzas {
if s.Type != StanzaX25519 {
return fmt.Errorf("agewrap: INNER_ACCESS_AGE stanza %d has type %q, want %q: %w", i, s.Type, StanzaX25519, datekeys.ErrPolicyStructureMismatch)
}
if len(s.Args) == 1 {
if seen[s.Args[0]] {
return fmt.Errorf("agewrap: INNER_ACCESS_AGE stanza %d repeats an ephemeral share: %w", i, datekeys.ErrPolicyStructureMismatch)
}
seen[s.Args[0]] = true
}
}
return nil
}
// ---------------------------------------------------------------------------
// Inspection probe
var errProbe = errors.New("agewrap: probe finished")
// probe records the stanzas age hands to Unwrap and stops decryption with an
// error that does not wrap age.ErrIncorrectIdentity, so age returns it as is.
type probe struct{ stanzas []*age.Stanza }
func (p *probe) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
p.stanzas = cloneStanzas(stanzas)
return nil, errProbe
}
// Stanzas parses the age header at the start of r with age itself and returns
// its recipient stanzas. It decrypts nothing and uses no secret: the header is
// extracted with age.ExtractHeader and handed to age.DecryptHeader with a
// probe identity (spec §27, §63 steps 5 and 6).
//
// The result is structural. Its authenticity is only established when the
// header MAC is verified while opening the file (spec §27).
func Stanzas(r io.Reader) ([]*age.Stanza, error) {
hdr, err := age.ExtractHeader(r)
if err != nil {
return nil, fmt.Errorf("agewrap: not a valid age file: %v: %w", err, datekeys.ErrIntegrity)
}
var p probe
if _, err := age.DecryptHeader(hdr, &p); !errors.Is(err, errProbe) {
return nil, fmt.Errorf("agewrap: unexpected result inspecting the age header: %v: %w", err, datekeys.ErrIntegrity)
}
return p.stanzas, nil
}
func cloneStanzas(in []*age.Stanza) []*age.Stanza {
out := make([]*age.Stanza, len(in))
for i, s := range in {
out[i] = &age.Stanza{Type: s.Type, Args: append([]string(nil), s.Args...), Body: bytes.Clone(s.Body)}
}
return out
}
// ---------------------------------------------------------------------------
// tlock recipient and identity (OUTER_TIME_AGE)
// TimeRecipient wraps the file key with tlock for one round of a pinned
// profile and emits the stanza "tlock <round> <chainhash>", byte-compatible
// with the stanza of the tlock library and the tle CLI (spec §32, §35). It
// uses only the exported core of tlock: TimeLock and CiphertextToBytes.
type TimeRecipient struct {
chainHash string
round uint64
scheme *crypto.Scheme
key kyber.Point
}
var _ age.RecipientWithLabels = (*TimeRecipient)(nil)
// NewTimeRecipient returns the tlock recipient of round under p, using only
// the pinned parameters of p (strict mode, spec §35).
func NewTimeRecipient(p *profile.Profile, round uint64) (*TimeRecipient, error) {
scheme, key, err := pinned(p)
if err != nil {
return nil, err
}
if round == 0 || round > p.MaxRound() {
return nil, fmt.Errorf("agewrap: round %d outside the range of %s: %w", round, p.ID, datekeys.ErrDateKeyInvalid)
}
return &TimeRecipient{chainHash: p.ChainHashHex(), round: round, scheme: scheme, key: key}, nil
}
// Wrap implements age.Recipient.
func (r *TimeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
ct, err := tlock.TimeLock(*r.scheme, r.key, r.round, fileKey)
if err != nil {
return nil, fmt.Errorf("agewrap: tlock: %w", err)
}
body, err := tlock.CiphertextToBytes(*r.scheme, ct)
if err != nil {
return nil, fmt.Errorf("agewrap: tlock ciphertext: %w", err)
}
return []*age.Stanza{{
Type: StanzaTLock,
Args: []string{strconv.FormatUint(r.round, 10), r.chainHash},
Body: body,
}}, nil
}
// WrapWithLabels implements age.RecipientWithLabels with a random label, so
// that age refuses to mix this recipient with any other one in the same file:
// OUTER_TIME_AGE must hold exactly one tlock stanza.
func (r *TimeRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
s, err := r.Wrap(fileKey)
if err != nil {
return nil, nil, err
}
var label [16]byte
_, _ = rand.Read(label[:]) // never fails since Go 1.24
return s, []string{"datekeys-tlock-" + hex.EncodeToString(label[:])}, nil
}
// TimeIdentity opens OUTER_TIME_AGE under the strict rules of spec §35 and
// §63 step 11. Unwrap validates the complete stanza set, verifies the release
// locally and calls tlock.TimeUnlock, which verifies the beacon again before
// decrypting. Every failure keeps its own normative error: none is turned
// into "too early".
type TimeIdentity struct {
profile *profile.Profile
round uint64
release provider.Release
scheme *crypto.Scheme
key kyber.Point
}
var _ age.Identity = (*TimeIdentity)(nil)
// NewTimeIdentity returns the identity that opens OUTER_TIME_AGE for round
// with release.
func NewTimeIdentity(p *profile.Profile, round uint64, release provider.Release) (*TimeIdentity, error) {
scheme, key, err := pinned(p)
if err != nil {
return nil, err
}
return &TimeIdentity{profile: p.Clone(), round: round, release: release, scheme: scheme, key: key}, nil
}
// Unwrap implements age.Identity.
func (i *TimeIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
if err := CheckTimeStanzas(stanzas, i.profile, i.round); err != nil {
return nil, err
}
if err := provider.Verify(i.profile, provider.Condition{Round: i.round}, i.release); err != nil {
return nil, err
}
ct, err := tlock.BytesToCiphertext(*i.scheme, stanzas[0].Body)
if err != nil {
return nil, fmt.Errorf("agewrap: malformed tlock stanza body: %v: %w", err, datekeys.ErrIntegrity)
}
beacon := common.Beacon{Round: i.release.Round, Signature: i.release.Signature}
fileKey, err := tlock.TimeUnlock(*i.scheme, i.key, beacon, ct)
if err != nil {
return nil, fmt.Errorf("agewrap: tlock unwrap failed: %v: %w", err, datekeys.ErrIntegrity)
}
if len(fileKey) != FileKeySize {
return nil, fmt.Errorf("agewrap: tlock stanza wraps a %d-byte file key: %w", len(fileKey), datekeys.ErrIntegrity)
}
return fileKey, nil
}
func pinned(p *profile.Profile) (*crypto.Scheme, kyber.Point, error) {
scheme, err := p.DrandScheme()
if err != nil {
return nil, nil, err
}
key := scheme.KeyGroup.Point()
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
return nil, nil, fmt.Errorf("agewrap: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
}
if key.Equal(key.Null()) {
return nil, nil, fmt.Errorf("agewrap: pinned public key of %s is the identity element: %w", p.ID, datekeys.ErrUnknownProfile)
}
return scheme, key, nil
}
// ---------------------------------------------------------------------------
// X25519 identities (PAYLOAD_AGE and INNER_ACCESS_AGE)
// PayloadIdentity opens PAYLOAD_AGE with I_PAYLOAD (spec §29, §30.1, §63 step
// 17). It rejects the file unless it holds exactly one X25519 stanza and that
// stanza is for R_PAYLOAD.
type PayloadIdentity struct {
id *age.X25519Identity
}
var _ age.Identity = (*PayloadIdentity)(nil)
// NewPayloadIdentity returns the identity for the raw 32-byte I_PAYLOAD.
func NewPayloadIdentity(raw []byte) (*PayloadIdentity, error) {
id, err := X25519IdentityFromRaw(raw)
if err != nil {
return nil, err
}
return &PayloadIdentity{id: id}, nil
}
// Unwrap implements age.Identity.
func (i *PayloadIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
if err := CheckPayloadStanzas(stanzas); err != nil {
return nil, err
}
fileKey, err := i.id.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
// CONTROL_A + PAYLOAD_AGE_B: I_PAYLOAD_A cannot unwrap FK_PAYLOAD_B (spec §30.1).
return nil, fmt.Errorf("agewrap: PAYLOAD_AGE is not encrypted to this control's R_PAYLOAD: %w", datekeys.ErrIntegrity)
}
if err != nil {
return nil, fmt.Errorf("agewrap: malformed PAYLOAD_AGE stanza: %v: %w", err, datekeys.ErrIntegrity)
}
return fileKey, nil
}
// AccessIdentity opens INNER_ACCESS_AGE with the caller's X25519 identities,
// including the one of a portable .dkk (spec §33, §38, §63 step 13). It
// validates the complete stanza set first, and rejects the file if one
// identity unwraps more than one stanza, which would be two stanzas for the
// same recipient.
type AccessIdentity struct {
ids []age.Identity
}
var _ age.Identity = (*AccessIdentity)(nil)
// NewAccessIdentity returns an AccessIdentity trying ids in order. At least
// one identity is required.
func NewAccessIdentity(ids ...age.Identity) (*AccessIdentity, error) {
var clean []age.Identity
for _, id := range ids {
if id != nil {
clean = append(clean, id)
}
}
if len(clean) == 0 {
return nil, fmt.Errorf("agewrap: time_and_key needs an access identity: %w", datekeys.ErrAccessRequired)
}
return &AccessIdentity{ids: clean}, nil
}
// Unwrap implements age.Identity.
func (a *AccessIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
if err := CheckAccessStanzas(stanzas); err != nil {
return nil, err
}
for _, id := range a.ids {
var fileKey []byte
matches := 0
for _, s := range stanzas {
fk, err := id.Unwrap([]*age.Stanza{s})
if errors.Is(err, age.ErrIncorrectIdentity) {
continue
}
if err != nil {
return nil, fmt.Errorf("agewrap: malformed INNER_ACCESS_AGE stanza: %v: %w", err, datekeys.ErrIntegrity)
}
matches++
if fileKey == nil {
fileKey = fk
}
}
if matches > 1 {
return nil, fmt.Errorf("agewrap: one identity opens %d INNER_ACCESS_AGE stanzas, want one per recipient: %w", matches, datekeys.ErrPolicyStructureMismatch)
}
if matches == 1 {
return fileKey, nil
}
}
return nil, fmt.Errorf("agewrap: no supplied identity is a recipient of INNER_ACCESS_AGE: %w", datekeys.ErrAccessInvalid)
}
// ---------------------------------------------------------------------------
// Raw X25519 keys
// X25519IdentityFromRaw converts 32 raw identity bytes, the canonical form
// inside CONTROL_CBOR and .dkk (spec §31, §38), to an age identity.
func X25519IdentityFromRaw(raw []byte) (*age.X25519Identity, error) {
if len(raw) != 32 {
return nil, fmt.Errorf("agewrap: X25519 identity is %d bytes, want 32: %w", len(raw), datekeys.ErrIntegrity)
}
s, err := bech32.Encode("AGE-SECRET-KEY-", raw)
if err != nil {
return nil, fmt.Errorf("agewrap: encode identity: %v: %w", err, datekeys.ErrIntegrity)
}
id, err := age.ParseX25519Identity(strings.ToUpper(s))
if err != nil {
return nil, fmt.Errorf("agewrap: parse identity: %v: %w", err, datekeys.ErrIntegrity)
}
return id, nil
}
// RawX25519Identity returns the 32 raw bytes of an age X25519 identity.
func RawX25519Identity(id *age.X25519Identity) ([]byte, error) {
hrp, raw, err := bech32.Decode(id.String())
if err != nil || hrp != "AGE-SECRET-KEY-" || len(raw) != 32 {
return nil, fmt.Errorf("agewrap: unexpected age identity encoding")
}
return raw, nil
}
// RawX25519Recipient returns the 32 raw bytes of an age X25519 recipient.
func RawX25519Recipient(r *age.X25519Recipient) ([]byte, error) {
hrp, raw, err := bech32.Decode(r.String())
if err != nil || hrp != "age" || len(raw) != 32 {
return nil, fmt.Errorf("agewrap: unexpected age recipient encoding")
}
return raw, nil
}

@ -0,0 +1,379 @@
package agewrap_test
import (
"bytes"
"errors"
"io"
"strings"
"testing"
"time"
"filippo.io/age"
"github.com/drand/drand/v2/crypto"
"github.com/drand/kyber"
"github.com/drand/tlock"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// tlockNetwork adapts the pinned profile to tlock.Network, to run the
// official tlock code path against our stanzas.
type tlockNetwork struct {
p *profile.Profile
release provider.Release
}
func (n tlockNetwork) ChainHash() string { return n.p.ChainHashHex() }
func (n tlockNetwork) Current(time.Time) uint64 { return 1 << 40 }
func (n tlockNetwork) SwitchChainHash(string) error { return errors.New("forbidden") }
func (n tlockNetwork) Scheme() crypto.Scheme {
s, _ := n.p.DrandScheme()
return *s
}
func (n tlockNetwork) PublicKey() kyber.Point {
s, _ := n.p.DrandScheme()
k := s.KeyGroup.Point()
_ = k.UnmarshalBinary(n.p.PublicKey)
return k
}
func (n tlockNetwork) Signature(round uint64) ([]byte, error) {
if round != n.release.Round {
return nil, errors.New("unknown round")
}
return n.release.Signature, nil
}
func encrypt(t *testing.T, plaintext []byte, r ...age.Recipient) []byte {
t.Helper()
var b bytes.Buffer
w, err := age.Encrypt(&b, r...)
if err != nil {
t.Fatal(err)
}
w.Write(plaintext)
if err := w.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func decrypt(file []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(file), id)
if err != nil {
return nil, err
}
return io.ReadAll(r)
}
func TestTimeRecipientStanzaAndRoundTrip(t *testing.T) {
p := profile.Quicknet()
rec, err := agewrap.NewTimeRecipient(p, 1000)
if err != nil {
t.Fatal(err)
}
file := encrypt(t, []byte("control"), rec)
st, err := agewrap.Stanzas(bytes.NewReader(file))
if err != nil {
t.Fatal(err)
}
if len(st) != 1 || st[0].Type != "tlock" || len(st[0].Args) != 2 || st[0].Args[0] != "1000" || st[0].Args[1] != p.ChainHashHex() || len(st[0].Body) != 128 {
t.Fatalf("stanza %+v", st)
}
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
if got, err := decrypt(file, id); err != nil || string(got) != "control" {
t.Fatalf("round trip: %q %v", got, err)
}
}
// The stanza is the one of the tlock library and the tle CLI, in both
// directions (spec §32, plan §3.3).
func TestInteroperabilityWithTlockLibrary(t *testing.T) {
p := profile.Quicknet()
net := tlockNetwork{p: p, release: testkit.Release(1000)}
rec, _ := agewrap.NewTimeRecipient(p, 1000)
ours := encrypt(t, []byte("from datekeys"), rec)
var out bytes.Buffer
if err := tlock.New(net).Strict().Decrypt(&out, bytes.NewReader(ours)); err != nil || out.String() != "from datekeys" {
t.Fatalf("tlock cannot open our file: %v", err)
}
var theirs bytes.Buffer
if err := tlock.New(net).Strict().Encrypt(&theirs, strings.NewReader("from tlock"), 1000); err != nil {
t.Fatal(err)
}
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
if got, err := decrypt(theirs.Bytes(), id); err != nil || string(got) != "from tlock" {
t.Fatalf("we cannot open a tlock file: %v", err)
}
}
func TestTimeRecipientCannotBeMixed(t *testing.T) {
p := profile.Quicknet()
a, _ := agewrap.NewTimeRecipient(p, 1000)
b, _ := agewrap.NewTimeRecipient(p, 1000)
x, _ := age.GenerateX25519Identity()
for _, rs := range [][]age.Recipient{{a, x.Recipient()}, {a, b}} {
if _, err := age.Encrypt(io.Discard, rs...); err == nil {
t.Fatal("tlock recipient mixed with another recipient")
}
}
}
// Every rule is enforced in Unwrap even when the header MAC is valid, which
// is what a malicious creator produces (spec §27, §63).
func TestTimeIdentityStrictness(t *testing.T) {
p := profile.Quicknet()
rec, _ := agewrap.NewTimeRecipient(p, 1000)
file, fk, err := testkit.Encrypt([]byte("control"), rec)
if err != nil {
t.Fatal(err)
}
rewrite := func(edit func([]*age.Stanza) []*age.Stanza) []byte {
out, err := testkit.RewriteAge(file, fk, edit)
if err != nil {
t.Fatal(err)
}
// The rewritten header is authentic for age: the injected file key opens it.
if _, err := decrypt(out, age.NewInjectedFileKeyIdentity(fk)); err != nil {
t.Fatalf("rewritten file is not a valid age file: %v", err)
}
return out
}
backdoor, backdoorID, _ := testkit.X25519Stanza(fk)
cases := []struct {
name string
file []byte
want error
}{
{"extra X25519 stanza", rewrite(func(s []*age.Stanza) []*age.Stanza { return append(s, backdoor) }), datekeys.ErrPolicyStructureMismatch},
{"stanza type changed", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Type = "tlock2"; return s }), datekeys.ErrPolicyStructureMismatch},
{"third argument", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args = append(s[0].Args, "x"); return s }), datekeys.ErrPolicyStructureMismatch},
{"other round", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }), datekeys.ErrRoundMismatch},
{"round with leading zero", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "01000"; return s }), datekeys.ErrRoundMismatch},
{"other chain hash", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[1] = strings.Repeat("0", 64); return s }), datekeys.ErrProfileMismatch},
{"uppercase chain hash", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[1] = strings.ToUpper(s[0].Args[1]); return s }), datekeys.ErrProfileMismatch},
{"corrupted tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body[100] ^= 1; return s }), datekeys.ErrIntegrity},
{"truncated tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = s[0].Body[:127]; return s }), datekeys.ErrIntegrity},
}
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := decrypt(tc.file, id); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
}
// Without the DateKeys rule, the backdoor stanza would open the file.
if got, err := decrypt(cases[0].file, backdoorID); err != nil || string(got) != "control" {
t.Fatalf("backdoor model broken: %v", err)
}
}
func TestTimeIdentityRelease(t *testing.T) {
p := profile.Quicknet()
rec, _ := agewrap.NewTimeRecipient(p, 1000)
file := encrypt(t, []byte("control"), rec)
for _, tc := range []struct {
name string
rel provider.Release
want error
}{
{"release of another round", testkit.Release(1001), datekeys.ErrRoundMismatch},
{"relabelled signature", provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}, datekeys.ErrReleaseInvalid},
{"empty signature", provider.Release{Round: 1000}, datekeys.ErrReleaseInvalid},
} {
id, _ := agewrap.NewTimeIdentity(p, 1000, tc.rel)
if _, err := decrypt(file, id); !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
// An identity for another round refuses the stanza before using the release.
id, _ := agewrap.NewTimeIdentity(p, 1001, testkit.Release(1001))
if _, err := decrypt(file, id); !errors.Is(err, datekeys.ErrRoundMismatch) {
t.Fatalf("identity for round 1001: %v", err)
}
}
func TestPayloadIdentityStrictness(t *testing.T) {
iPayload, _ := age.GenerateX25519Identity()
raw, err := agewrap.RawX25519Identity(iPayload)
if err != nil {
t.Fatal(err)
}
id, err := agewrap.NewPayloadIdentity(raw)
if err != nil {
t.Fatal(err)
}
file, fk, _ := testkit.Encrypt([]byte("payload"), iPayload.Recipient())
if got, err := decrypt(file, id); err != nil || string(got) != "payload" {
t.Fatalf("round trip: %v", err)
}
backdoor, _, _ := testkit.X25519Stanza(fk)
extra, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, backdoor) })
// Plain age accepts the file with I_PAYLOAD: the MAC is valid.
if _, err := decrypt(extra, iPayload); err != nil {
t.Fatalf("model broken: %v", err)
}
if _, err := decrypt(extra, id); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("extra stanza in PAYLOAD_AGE: %v", err)
}
other, _ := age.GenerateX25519Identity()
if _, err := decrypt(encrypt(t, []byte("x"), other.Recipient()), id); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("payload of another control: %v", err)
}
pw, _ := age.NewScryptRecipient("password")
pw.SetWorkFactor(10)
if _, err := decrypt(encrypt(t, []byte("x"), pw), id); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("scrypt payload: %v", err)
}
}
func TestAccessIdentityStrictness(t *testing.T) {
a, _ := age.GenerateX25519Identity()
b, _ := age.GenerateX25519Identity()
file, fk, _ := testkit.Encrypt([]byte("control"), a.Recipient(), b.Recipient())
for _, id := range []*age.X25519Identity{a, b} {
acc, _ := agewrap.NewAccessIdentity(id)
if got, err := decrypt(file, acc); err != nil || string(got) != "control" {
t.Fatalf("recipient cannot open: %v", err)
}
}
// A non-X25519 stanza is rejected although plain age would accept the file.
odd := &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)}
withOdd, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, odd) })
if _, err := decrypt(withOdd, a); err != nil {
t.Fatalf("model broken: %v", err)
}
acc, _ := agewrap.NewAccessIdentity(a)
if _, err := decrypt(withOdd, acc); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("non-X25519 stanza: %v", err)
}
// Two stanzas for the same recipient.
dup, _ := a.Recipient().Wrap(fk)
withDup, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, dup[0]) })
if _, err := decrypt(withDup, acc); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("two stanzas for one recipient: %v", err)
}
stranger, _ := age.GenerateX25519Identity()
accS, _ := agewrap.NewAccessIdentity(stranger)
if _, err := decrypt(file, accS); !errors.Is(err, datekeys.ErrAccessInvalid) {
t.Fatalf("stranger: %v", err)
}
if _, err := agewrap.NewAccessIdentity(); !errors.Is(err, datekeys.ErrAccessRequired) {
t.Fatalf("no identity: %v", err)
}
}
func TestStanzasProbe(t *testing.T) {
if _, err := agewrap.Stanzas(strings.NewReader("not age")); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("garbage: %v", err)
}
x, _ := age.GenerateX25519Identity()
file := encrypt(t, []byte("x"), x.Recipient())
st, err := agewrap.Stanzas(bytes.NewReader(file))
if err != nil || len(st) != 1 || st[0].Type != "X25519" {
t.Fatalf("%+v %v", st, err)
}
// Probing never needs a secret and leaves the stanzas untouched for age.
if _, err := decrypt(file, x); err != nil {
t.Fatal(err)
}
}
func TestRawKeys(t *testing.T) {
id, _ := age.GenerateX25519Identity()
raw, err := agewrap.RawX25519Identity(id)
if err != nil || len(raw) != 32 {
t.Fatal(err)
}
back, err := agewrap.X25519IdentityFromRaw(raw)
if err != nil || back.String() != id.String() {
t.Fatal("identity round trip")
}
pub, err := agewrap.RawX25519Recipient(id.Recipient())
if err != nil || len(pub) != 32 {
t.Fatal(err)
}
if _, err := agewrap.X25519IdentityFromRaw(raw[:31]); err == nil {
t.Fatal("31-byte identity accepted")
}
}
func TestConstructorsRejectInvalidInput(t *testing.T) {
p := profile.Quicknet()
for _, round := range []uint64{0, p.MaxRound() + 1} {
if _, err := agewrap.NewTimeRecipient(p, round); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Errorf("round %d: %v", round, err)
}
}
for name, edit := range map[string]func(p *profile.Profile){
"unknown scheme": func(p *profile.Profile) { p.Scheme = "nope" },
"public key not a point": func(p *profile.Profile) { p.PublicKey = bytes.Repeat([]byte{0xff}, 96) },
"identity element": func(p *profile.Profile) { p.PublicKey = append([]byte{0xc0}, make([]byte, 95)...) },
} {
bad := profile.Quicknet()
edit(bad)
if _, err := agewrap.NewTimeRecipient(bad, 1000); !errors.Is(err, datekeys.ErrUnknownProfile) {
t.Errorf("recipient, %s: %v", name, err)
}
if _, err := agewrap.NewTimeIdentity(bad, 1000, testkit.Release(1000)); !errors.Is(err, datekeys.ErrUnknownProfile) {
t.Errorf("identity, %s: %v", name, err)
}
}
if _, err := agewrap.NewPayloadIdentity(make([]byte, 31)); err == nil {
t.Fatal("31-byte I_PAYLOAD accepted")
}
}
func TestMalformedX25519Stanzas(t *testing.T) {
x, _ := age.GenerateX25519Identity()
file, fk, _ := testkit.Encrypt([]byte("data"), x.Recipient())
malformed, err := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza {
s[0].Args = append(s[0].Args, "extra")
return s
})
if err != nil {
t.Fatal(err)
}
raw, _ := agewrap.RawX25519Identity(x)
pid, _ := agewrap.NewPayloadIdentity(raw)
if _, err := decrypt(malformed, pid); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("payload: %v", err)
}
acc, _ := agewrap.NewAccessIdentity(x)
if _, err := decrypt(malformed, acc); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("access: %v", err)
}
st, _ := agewrap.Stanzas(bytes.NewReader(file))
if err := agewrap.CheckAccessStanzas(append(st, st[0])); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("repeated stanza: %v", err)
}
if err := agewrap.CheckAccessStanzas(nil); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("no stanza: %v", err)
}
if err := agewrap.CheckPayloadStanzas(append(st, st[0])); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
t.Fatalf("two payload stanzas: %v", err)
}
}
func FuzzStanzas(f *testing.F) {
x, _ := age.GenerateX25519Identity()
var b bytes.Buffer
w, _ := age.Encrypt(&b, x.Recipient())
w.Close()
f.Add(b.Bytes())
f.Add([]byte("age-encryption.org/v1\n-> X25519 a\n\n--- AAAA\n"))
f.Fuzz(func(t *testing.T, in []byte) {
st, err := agewrap.Stanzas(bytes.NewReader(in))
if err != nil && !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("unexpected error class: %v", err)
}
_ = agewrap.CheckPayloadStanzas(st)
_ = agewrap.CheckAccessStanzas(st)
})
}

@ -0,0 +1,263 @@
package capsule_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"filippo.io/age"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
const fixtureDir = "../testdata/fixtures"
var fixtureNames = []string{"time_only", "time_only_extensions", "time_and_key_portable", "time_and_key_recipients", "empty_payload"}
type fixture struct {
testkit.DKCFixture
dkc []byte
plaintext []byte
release provider.Release
dkk *accesskey.AccessKey
ids []age.Identity
}
func loadFixture(t testing.TB, name string) *fixture {
t.Helper()
f := &fixture{}
if err := testkit.ReadJSON(filepath.Join(fixtureDir, name+".json"), &f.DKCFixture); err != nil {
t.Fatal(err)
}
var err error
if f.dkc, err = os.ReadFile(filepath.Join(fixtureDir, f.File)); err != nil {
t.Fatal(err)
}
if f.plaintext, err = os.ReadFile(filepath.Join(fixtureDir, f.PlaintextFile)); err != nil {
t.Fatal(err)
}
sig, err := hex.DecodeString(f.Release.Signature)
if err != nil {
t.Fatal(err)
}
f.release = provider.Release{Round: f.Release.Round, Signature: sig}
if f.AccessKeyFile != "" {
b, err := os.ReadFile(filepath.Join(fixtureDir, f.AccessKeyFile))
if err != nil {
t.Fatal(err)
}
if f.dkk, err = accesskey.Decode(bytes.NewReader(b)); err != nil {
t.Fatal(err)
}
}
for _, s := range f.Identities {
id, err := age.ParseX25519Identity(s)
if err != nil {
t.Fatal(err)
}
f.ids = append(f.ids, id)
}
return f
}
func (f *fixture) unlock(t testing.TB) time.Time {
u, err := time.Parse(time.RFC3339, f.UnlockAt)
if err != nil {
t.Fatal(err)
}
return u
}
func (f *fixture) openOptions(t testing.TB) capsule.OpenOptions {
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(f.release), Now: testkit.Fixed(f.unlock(t))}
if f.AccessPolicy == "time_and_key" {
o.AccessKey = f.dkk
}
return o
}
func decryptAge(t *testing.T, file []byte, id age.Identity) []byte {
t.Helper()
r, err := age.Decrypt(bytes.NewReader(file), id)
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return b
}
func stanzaList(in []capsule.StanzaInfo) []testkit.FixtureStanza {
out := make([]testkit.FixtureStanza, len(in))
for i, s := range in {
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
}
return out
}
// Spec §67: conformance is shown by decrypting and verifying each official
// fixture and comparing every intermediate value and the plaintext.
func TestConformanceFixtures(t *testing.T) {
p := profile.Quicknet()
for _, name := range fixtureNames {
t.Run(name, func(t *testing.T) {
f := loadFixture(t, name)
if sum := sha256.Sum256(f.dkc); hex.EncodeToString(sum[:]) != f.SHA256 {
t.Fatal("fixture bytes changed")
}
if sum := sha256.Sum256(f.plaintext); hex.EncodeToString(sum[:]) != f.PlaintextSHA256 {
t.Fatal("plaintext file changed")
}
if err := provider.Verify(p, provider.Condition{Round: f.release.Round}, f.release); err != nil {
t.Fatalf("embedded release: %v", err)
}
// PRELUDE, PUBLIC_HEADER and the pre-unlock view (steps 1 to 8).
parts, err := testkit.Split(f.dkc)
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(parts.Prelude) != f.Prelude || hex.EncodeToString(parts.Header) != f.PublicHeader {
t.Fatal("PRELUDE or PUBLIC_HEADER differ")
}
in, err := capsule.Inspect(bytes.NewReader(f.dkc), capsule.InspectOptions{Registry: testkit.Registry()})
if err != nil {
t.Fatal(err)
}
if in.Header.DateKey.Compact() != f.DateKey || in.Header.CapsuleIDHex() != f.CapsuleID ||
in.Header.Policy.String() != f.AccessPolicy || in.UnlockAt.Format(time.RFC3339) != f.UnlockAt {
t.Fatalf("inspection differs: %+v", in.Header)
}
if !reflect.DeepEqual(stanzaList(in.OuterStanzas), f.OuterStanzas) || !reflect.DeepEqual(stanzaList(in.PayloadStanzas), f.PayloadStanzas) {
t.Fatal("stanzas differ")
}
for _, c := range in.Checks {
if !c.OK || c.Step > 8 {
t.Fatalf("unexpected inspection check %+v", c)
}
}
var pre [capsule.PreludeSize]byte
copy(pre[:], parts.Prelude)
if b := capsule.HeaderBinding(pre, parts.Header); hex.EncodeToString(b[:]) != f.HeaderBinding {
t.Fatal("header_binding differs")
}
h, err := capsule.DecodeHeader(parts.Header)
if err != nil {
t.Fatal(err)
}
if re, _ := capsule.EncodeHeader(h); !bytes.Equal(re, parts.Header) {
t.Fatal("EncodeHeader(DecodeHeader(x)) != x")
}
if len(h.Critical)+len(h.Noncritical) != len(f.HeaderExtensions) {
t.Fatal("header extensions differ")
}
// Opening layer by layer: OUTER_TIME_AGE, INNER_ACCESS_AGE, CONTROL_CBOR.
timeID, _ := agewrap.NewTimeIdentity(p, f.release.Round, f.release)
inner := decryptAge(t, parts.Sealed, timeID)
control := inner
if f.Structure == "time_and_key" {
st, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
t.Fatal(err)
}
got := make([]testkit.FixtureStanza, len(st))
for i, s := range st {
got[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
}
if !reflect.DeepEqual(got, f.InnerStanzas) {
t.Fatal("INNER_ACCESS_AGE stanzas differ")
}
kid, err := f.dkk.Identity()
if err != nil {
t.Fatal(err)
}
acc, _ := agewrap.NewAccessIdentity(kid)
control = decryptAge(t, inner, acc)
}
if hex.EncodeToString(control) != f.ControlCBOR {
t.Fatal("CONTROL_CBOR differs")
}
ctrl, err := capsule.DecodeControl(control)
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(ctrl.PayloadIdentity[:]) != f.PayloadIdentity || hex.EncodeToString(ctrl.HeaderBinding[:]) != f.HeaderBinding {
t.Fatal("I_PAYLOAD or header_binding in CONTROL_CBOR differ")
}
if re, _ := capsule.EncodeControl(ctrl); !bytes.Equal(re, control) {
t.Fatal("EncodeControl(DecodeControl(x)) != x")
}
if len(ctrl.Critical)+len(ctrl.Noncritical) != len(f.ControlExt) {
t.Fatal("control extensions differ")
}
payloadID, _ := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:])
if got := decryptAge(t, parts.Payload, payloadID); !bytes.Equal(got, f.plaintext) {
t.Fatal("PAYLOAD_AGE plaintext differs")
}
// The complete flow through the public API, stage by stage.
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out.Bytes(), f.plaintext) {
t.Fatal("plaintext differs")
}
var stages []testkit.FixtureStage
for _, c := range opened.Inspection.Checks {
stages = append(stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
}
if !reflect.DeepEqual(stages, f.Stages) {
t.Fatalf("stages differ:\n got %+v\nwant %+v", stages, f.Stages)
}
if len(opened.ControlNoncritical) != len(ctrl.Noncritical) {
t.Fatal("Open does not report the control extensions")
}
})
}
}
// Every known recipient of a multi-recipient fixture opens it on its own.
func TestFixtureRecipients(t *testing.T) {
f := loadFixture(t, "time_and_key_recipients")
if len(f.ids) != 2 {
t.Fatal("fixture should have two known recipients")
}
for i, id := range f.ids {
o := f.openOptions(t)
o.AccessKey = nil
o.Identities = []age.Identity{id}
var out bytes.Buffer
if _, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), o); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
t.Fatalf("recipient %d: %v", i, err)
}
}
}
// A non-seekable reader works too: only the capsule_digest shortcut is skipped.
func TestOpenFromPlainReader(t *testing.T) {
for _, name := range []string{"time_only", "time_and_key_portable"} {
f := loadFixture(t, name)
var out bytes.Buffer
r := struct{ io.Reader }{bytes.NewReader(f.dkc)}
if _, err := capsule.Open(context.Background(), &out, r, f.openOptions(t)); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
t.Fatalf("%s: %v", name, err)
}
}
}

@ -0,0 +1,284 @@
package capsule
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/profile"
)
// EncryptOptions configures Encrypt.
type EncryptOptions struct {
// Profile is the pinned Provider Profile. Required.
Profile *profile.Profile
// UnlockAt is the requested instant. It resolves locally to the first
// round at or after it (spec §15) and must be after Now.
UnlockAt time.Time
// Policy is time_only or time_and_key (spec §25).
Policy Policy
// Recipients are the X25519 recipients of known holders, for
// time_and_key (spec §33, §37, §39). Only *age.X25519Recipient is
// accepted: INNER_ACCESS_AGE must hold X25519 stanzas only.
Recipients []age.Recipient
// NewPortableKey generates a fresh I_ACCESS for this capsule only and
// returns it as a .dkk (spec §38). An I_ACCESS is never reused: Encrypt
// accepts no existing one.
NewPortableKey bool
// Critical and Noncritical are the PUBLIC_HEADER extensions (visible to
// anyone holding the .dkc).
Critical, Noncritical []extension.Extension
// ControlCritical and ControlNoncritical are the CONTROL_CBOR extensions,
// sealed with the control.
ControlCritical, ControlNoncritical []extension.Extension
// Now is the clock. Required: no package of this module reads the wall
// clock on its own.
Now func() time.Time
}
// Result describes a capsule written by Encrypt.
type Result struct {
DateKey datekey.DateKey
UnlockAt time.Time // effective round time, never before the requested instant
CapsuleID [CapsuleIDSize]byte
// PortableKey is the .dkk generated when NewPortableKey is set. Encode it
// with accesskey.Encode and treat it as a sensitive capability.
PortableKey *accesskey.AccessKey
}
// Encrypt writes a .dkc for the payload read from src (spec §61 for
// time_only, §62 for time_and_key). It needs no network: the round is
// resolved locally and tlock uses only the pinned public key.
//
// PAYLOAD_AGE is streamed after the small, in-memory SEALED_CONTROL, so the
// payload is never held in memory. On error dst may hold a partial capsule
// that must be discarded.
func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error) {
p := opts.Profile
if p == nil {
return nil, errors.New("capsule: EncryptOptions.Profile is required")
}
if opts.Now == nil {
return nil, errors.New("capsule: EncryptOptions.Now is required")
}
if err := p.Validate(); err != nil {
return nil, err
}
if !opts.UnlockAt.After(opts.Now()) {
return nil, fmt.Errorf("capsule: unlock time %s is not in the future", opts.UnlockAt.UTC().Format(time.RFC3339Nano))
}
// Step 1: resolve the DateKey locally.
dk, err := datekey.Resolve(p, opts.UnlockAt)
if err != nil {
return nil, err
}
unlock := dk.UnlockAt(p)
// Spec §17: round_time(round) >= requested_unlock_at, never earlier.
if unlock.Before(opts.UnlockAt) {
return nil, fmt.Errorf("capsule: resolved round %d opens before the requested time: %w", dk.Round, datekeys.ErrRoundMismatch)
}
access, portable, err := accessRecipients(opts)
if err != nil {
return nil, err
}
var portableRaw []byte
if portable != nil {
if portableRaw, err = agewrap.RawX25519Identity(portable); err != nil {
return nil, err
}
defer clear(portableRaw)
}
// Step 2: capsule_id, 16 random bytes (spec §21).
var capsuleID [CapsuleIDSize]byte
_, _ = rand.Read(capsuleID[:]) // never fails since Go 1.24
// Step 3: I_PAYLOAD, a fresh X25519 identity (spec §29).
payloadID, err := age.GenerateX25519Identity()
if err != nil {
return nil, err
}
payloadRaw, err := agewrap.RawX25519Identity(payloadID)
if err != nil {
return nil, err
}
defer clear(payloadRaw)
// Step 5: PUBLIC_HEADER.
header := &Header{CapsuleID: capsuleID, DateKey: dk, Policy: opts.Policy, Critical: opts.Critical, Noncritical: opts.Noncritical}
headerBytes, err := EncodeHeader(header)
if err != nil {
return nil, err
}
if len(headerBytes) > MaxPublicHeaderLen {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER of %d bytes exceeds %d", len(headerBytes), MaxPublicHeaderLen)
}
timeRecipient, err := agewrap.NewTimeRecipient(p, dk.Round)
if err != nil {
return nil, err
}
seal := func(control []byte) ([]byte, error) {
plaintext := control
if opts.Policy == TimeAndKey {
// INNER_ACCESS_AGE: FK_ACCESS wrapped for every access recipient.
innerAge, err := encryptAll(control, access...)
if err != nil {
return nil, err
}
stanzas, err := agewrap.Stanzas(bytes.NewReader(innerAge))
if err != nil {
return nil, err
}
if err := agewrap.CheckAccessStanzas(stanzas); err != nil || len(stanzas) != len(access) {
return nil, fmt.Errorf("capsule: INNER_ACCESS_AGE self-check failed: %w", datekeys.ErrPolicyStructureMismatch)
}
plaintext = innerAge
}
// OUTER_TIME_AGE: FK_TIME wrapped with tlock for the DateKey round.
return encryptAll(plaintext, timeRecipient)
}
// Steps 6 to 10. PRELUDE carries SEALED_CONTROL_LEN and header_binding
// covers PRELUDE, so the length is measured first by sealing a control of
// identical size with a zero binding and a zero identity. age output
// lengths depend only on plaintext length and stanza shapes; the real
// seal is checked to have the same length.
ctrl := &Control{Critical: opts.ControlCritical, Noncritical: opts.ControlNoncritical}
draft, err := EncodeControl(ctrl)
if err != nil {
return nil, err
}
draftSealed, err := seal(draft)
if err != nil {
return nil, err
}
if len(draftSealed) > MaxSealedControlLen {
return nil, fmt.Errorf("capsule: SEALED_CONTROL of %d bytes exceeds %d", len(draftSealed), MaxSealedControlLen)
}
prelude := Prelude{PublicHeaderLen: uint32(len(headerBytes)), SealedControlLen: uint32(len(draftSealed))}
preludeBytes := prelude.Bytes()
// Step 7: header_binding = SHA-256(PRELUDE || PUBLIC_HEADER_BYTES).
ctrl.HeaderBinding = HeaderBinding(preludeBytes, headerBytes)
copy(ctrl.PayloadIdentity[:], payloadRaw)
defer clear(ctrl.PayloadIdentity[:])
// Step 8: CONTROL_CBOR.
controlBytes, err := EncodeControl(ctrl)
if err != nil {
return nil, err
}
defer clear(controlBytes)
// Steps 9 and 10: SEALED_CONTROL = OUTER_TIME_AGE.
sealed, err := seal(controlBytes)
if err != nil {
return nil, err
}
if len(sealed) != len(draftSealed) {
return nil, fmt.Errorf("capsule: internal error: SEALED_CONTROL is %d bytes, measured %d", len(sealed), len(draftSealed))
}
// Step 11: PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE.
digest := sha256.New()
w := io.MultiWriter(dst, digest)
for _, b := range [][]byte{preludeBytes[:], headerBytes, sealed} {
if _, err := w.Write(b); err != nil {
return nil, err
}
}
// Step 4: PAYLOAD_AGE, a standard age file for R_PAYLOAD (FK_PAYLOAD is
// generated by age). It is written last and streamed.
aw, err := age.Encrypt(w, payloadID.Recipient())
if err != nil {
return nil, err
}
if _, err := io.Copy(aw, src); err != nil {
return nil, err
}
if err := aw.Close(); err != nil {
return nil, err
}
res := &Result{DateKey: dk, UnlockAt: unlock, CapsuleID: capsuleID}
if portable != nil {
// Step 13: the portable identity as 32 raw bytes in a .dkk.
k := &accesskey.AccessKey{
CapsuleID: capsuleID,
Type: accesskey.TypeX25519,
Material: bytes.Clone(portableRaw),
Verification: &accesskey.Verification{CapsuleDigest: digest.Sum(nil)},
}
_, _ = rand.Read(k.CredentialID[:]) // spec §42; never fails since Go 1.24
res.PortableKey = k
}
return res, nil
}
// accessRecipients validates the policy options and returns the recipients of
// INNER_ACCESS_AGE, including R_ACCESS when a portable key is requested.
func accessRecipients(opts EncryptOptions) ([]age.Recipient, *age.X25519Identity, error) {
switch opts.Policy {
case TimeOnly:
if len(opts.Recipients) != 0 || opts.NewPortableKey {
return nil, nil, errors.New("capsule: time_only takes no recipients and no portable key")
}
return nil, nil, nil
case TimeAndKey:
default:
return nil, nil, fmt.Errorf("capsule: unknown access policy %d", opts.Policy)
}
var out []age.Recipient
seen := make(map[string]bool)
for i, r := range opts.Recipients {
x, ok := r.(*age.X25519Recipient)
if !ok || x == nil {
return nil, nil, fmt.Errorf("capsule: recipient %d is %T; time_and_key accepts X25519 recipients only", i, r)
}
if seen[x.String()] {
return nil, nil, fmt.Errorf("capsule: recipient %s listed twice; INNER_ACCESS_AGE holds one stanza per recipient", x)
}
seen[x.String()] = true
out = append(out, x)
}
var portable *age.X25519Identity
if opts.NewPortableKey {
var err error
if portable, err = age.GenerateX25519Identity(); err != nil {
return nil, nil, err
}
out = append(out, portable.Recipient())
}
if len(out) == 0 {
return nil, nil, errors.New("capsule: time_and_key needs at least one recipient or a portable key")
}
return out, portable, nil
}
// encryptAll produces a complete in-memory age file.
func encryptAll(plaintext []byte, recipients ...age.Recipient) ([]byte, error) {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, recipients...)
if err != nil {
return nil, err
}
_, writeErr := w.Write(plaintext)
if err := errors.Join(writeErr, w.Close()); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

@ -0,0 +1,249 @@
package capsule_test
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func past(t *testing.T, round uint64) capsule.EncryptOptions {
t.Helper()
p := profile.Quicknet()
unlock, err := datekey.RoundTime(p, round)
if err != nil {
t.Fatal(err)
}
return capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}
}
func open(t *testing.T, dkc []byte, o capsule.OpenOptions) ([]byte, error) {
t.Helper()
var out bytes.Buffer
_, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o)
return out.Bytes(), err
}
func defaultOpen(round uint64) capsule.OpenOptions {
return capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(round)), Now: time.Now}
}
func TestEncryptRoundTripBothPolicies(t *testing.T) {
msg := strings.Repeat("0123456789abcdef", 20000) // several STREAM chunks
for _, tc := range []struct {
name string
setup func(o *capsule.EncryptOptions) []age.Identity
}{
{"time_only", func(o *capsule.EncryptOptions) []age.Identity { return nil }},
{"time_and_key, portable", func(o *capsule.EncryptOptions) []age.Identity {
o.Policy, o.NewPortableKey = capsule.TimeAndKey, true
return nil
}},
{"time_and_key, three recipients", func(o *capsule.EncryptOptions) []age.Identity {
o.Policy = capsule.TimeAndKey
var ids []age.Identity
for range 3 {
id, _ := age.GenerateX25519Identity()
o.Recipients = append(o.Recipients, id.Recipient())
ids = append(ids, id)
}
return ids
}},
} {
t.Run(tc.name, func(t *testing.T) {
opts := past(t, 1000)
ids := tc.setup(&opts)
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
if err != nil {
t.Fatal(err)
}
if res.DateKey.Round != 1000 || !res.UnlockAt.Equal(opts.UnlockAt) {
t.Fatalf("result %+v", res)
}
o := defaultOpen(1000)
o.Identities = ids
if res.PortableKey != nil {
o.AccessKey = res.PortableKey
}
got, err := open(t, dkc.Bytes(), o)
if err != nil || string(got) != msg {
t.Fatalf("open: %v", err)
}
for i, id := range ids {
o := defaultOpen(1000)
o.Identities = []age.Identity{id}
if got, err := open(t, dkc.Bytes(), o); err != nil || string(got) != msg {
t.Fatalf("recipient %d: %v", i, err)
}
}
})
}
}
func TestEncryptRejectsInvalidOptions(t *testing.T) {
x, _ := age.GenerateX25519Identity()
scrypt, _ := age.NewScryptRecipient("pw")
for _, tc := range []struct {
name string
edit func(o *capsule.EncryptOptions)
}{
{"no profile", func(o *capsule.EncryptOptions) { o.Profile = nil }},
{"no clock", func(o *capsule.EncryptOptions) { o.Now = nil }},
{"unlock time in the past", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt.Add(time.Second)) }},
{"unlock time equal to now", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt) }},
{"time_only with recipients", func(o *capsule.EncryptOptions) { o.Recipients = []age.Recipient{x.Recipient()} }},
{"time_only with a portable key", func(o *capsule.EncryptOptions) { o.NewPortableKey = true }},
{"time_and_key without recipients", func(o *capsule.EncryptOptions) { o.Policy = capsule.TimeAndKey }},
{"non-X25519 recipient", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{scrypt}
}},
{"recipient listed twice", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{x.Recipient(), x.Recipient()}
}},
{"unknown policy", func(o *capsule.EncryptOptions) { o.Policy = 7 }},
{"invalid profile", func(o *capsule.EncryptOptions) { o.Profile.ChainHash[0] ^= 1 }},
{"duplicate header extension", func(o *capsule.EncryptOptions) {
o.Noncritical = []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
}},
{"extension both critical and noncritical", func(o *capsule.EncryptOptions) {
o.ControlCritical = []extension.Extension{{ID: "a", Version: 1}}
o.ControlNoncritical = []extension.Extension{{ID: "a", Version: 1}}
}},
} {
t.Run(tc.name, func(t *testing.T) {
opts := past(t, 1000)
tc.edit(&opts)
var dkc bytes.Buffer
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil {
t.Fatal("accepted")
}
if dkc.Len() != 0 {
t.Fatal("wrote output before validating the options")
}
})
}
}
// Spec §38: an I_ACCESS is generated for one capsule only and never reused.
func TestPortableKeysAreNeverReused(t *testing.T) {
var dkcs [2][]byte
var keys [2]*accesskey.AccessKey
for i := range 2 {
opts := past(t, 1000)
opts.Policy, opts.NewPortableKey = capsule.TimeAndKey, true
var b bytes.Buffer
res, err := capsule.Encrypt(&b, strings.NewReader("x"), opts)
if err != nil {
t.Fatal(err)
}
dkcs[i], keys[i] = b.Bytes(), res.PortableKey
}
if bytes.Equal(keys[0].Material, keys[1].Material) || keys[0].CredentialID == keys[1].CredentialID || keys[0].CapsuleID == keys[1].CapsuleID {
t.Fatal("two capsules share an I_ACCESS, credential_id or capsule_id")
}
// The .dkk of capsule A is refused for capsule B before any request, and
// its identity cannot open B's access layer either.
o := defaultOpen(1000)
o.AccessKey = keys[0]
src := testkit.NewSource(testkit.Release(1000))
o.Source = src
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) || src.Calls != 0 {
t.Fatalf("foreign .dkk: %v (requests: %d)", err, src.Calls)
}
id, _ := keys[0].Identity()
o = defaultOpen(1000)
o.Identities = []age.Identity{id}
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) {
t.Fatalf("foreign identity: %v", err)
}
}
func TestFutureCapsuleStaysLockedWithoutRequests(t *testing.T) {
p := profile.Quicknet()
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
opts := capsule.EncryptOptions{Profile: p, UnlockAt: now.Add(time.Hour), Now: testkit.Fixed(now)}
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader("secret"), opts)
if err != nil {
t.Fatal(err)
}
if res.UnlockAt.Before(opts.UnlockAt) || res.UnlockAt.Sub(opts.UnlockAt) >= p.Period {
t.Fatalf("unsafe rounding: %s for %s", res.UnlockAt, opts.UnlockAt)
}
src := testkit.NewSource()
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: src, Now: testkit.Fixed(now)}
if _, err := open(t, dkc.Bytes(), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) || src.Calls != 0 {
t.Fatalf("locked capsule: %v (requests: %d)", err, src.Calls)
}
// Inspection works on a locked capsule and reports its condition.
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
if err != nil || in.Header.DateKey != res.DateKey || !in.UnlockAt.Equal(res.UnlockAt) {
t.Fatalf("inspect: %+v %v", in, err)
}
}
func TestExtensionsRoundTrip(t *testing.T) {
hExt, _ := extension.New("org.example.public", 1, []any{"a", uint64(1)})
cExt, _ := extension.New("org.example.sealed", 3, map[string]any{"k": []byte{1, 2}})
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{hExt}
opts.ControlNoncritical = []extension.Extension{cExt}
var dkc bytes.Buffer
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err != nil {
t.Fatal(err)
}
in, _ := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
if len(in.Header.Noncritical) != 1 || !bytes.Equal(in.Header.Noncritical[0].Data, hExt.Data) {
t.Fatal("header extension lost")
}
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), defaultOpen(1000))
if err != nil || len(opened.ControlNoncritical) != 1 || !bytes.Equal(opened.ControlNoncritical[0].Data, cExt.Data) {
t.Fatalf("control extension lost: %v", err)
}
}
func TestOpenRequiresOptions(t *testing.T) {
f := loadFixture(t, "time_only")
for name, o := range map[string]capsule.OpenOptions{
"no source": {Registry: testkit.Registry(), Now: time.Now},
"no clock": {Registry: testkit.Registry(), Source: testkit.NewSource()},
"no registry": {Source: testkit.NewSource(), Now: time.Now},
} {
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(f.dkc), o); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestEncryptWriteError(t *testing.T) {
opts := past(t, 1000)
if _, err := capsule.Encrypt(failingWriter{}, strings.NewReader("x"), opts); err == nil {
t.Fatal("write error ignored")
}
if _, err := capsule.Encrypt(io.Discard, failingReader{}, opts); err == nil {
t.Fatal("read error ignored")
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read error") }

@ -0,0 +1,87 @@
package capsule_test
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// The published Quicknet signature of round 1000. In real use the release
// comes from drand.New(), which verifies it the same way.
var round1000, _ = hex.DecodeString("b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39")
func Example() {
reg, err := profile.Default()
if err != nil {
panic(err)
}
p := profile.Quicknet()
// A clock stopped at the Quicknet genesis makes round 1000
// (2023-08-23T15:59:24Z) "the future", so the example runs offline.
genesis := func() time.Time { return time.Unix(p.GenesisTime, 0) }
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader("hello from the past"), capsule.EncryptOptions{
Profile: p,
UnlockAt: time.Date(2023, 8, 23, 15, 59, 24, 0, time.UTC),
Policy: capsule.TimeAndKey,
NewPortableKey: true,
Now: genesis,
})
if err != nil {
panic(err)
}
var dkk bytes.Buffer
if err := accesskey.Encode(&dkk, res.PortableKey); err != nil {
panic(err)
}
fmt.Println("round", res.DateKey.Round, "unlocks at", res.UnlockAt.Format(time.RFC3339))
// Before the round: no request is made.
key, _ := accesskey.Decode(&dkk)
src := provider.ReleaseSourceFunc(func(_ context.Context, _ *profile.Profile, c provider.Condition) (provider.Release, error) {
return provider.Release{Round: c.Round, Signature: round1000}, nil
})
opts := capsule.OpenOptions{Registry: reg, Source: src, AccessKey: key, Now: genesis}
_, err = capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), opts)
fmt.Println("too early:", errors.Is(err, datekeys.ErrReleaseUnavailable))
// After the round: the release is verified locally and the capsule opens.
opts.Now = time.Now
var plain bytes.Buffer
if _, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), opts); err != nil {
panic(err)
}
fmt.Println(plain.String())
// Output:
// round 1000 unlocks at 2023-08-23T15:59:24Z
// too early: true
// hello from the past
}
func ExampleInspect() {
reg, _ := profile.Default()
p := profile.Quicknet()
var dkc bytes.Buffer
_, _ = capsule.Encrypt(&dkc, strings.NewReader("x"), capsule.EncryptOptions{
Profile: p,
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
Now: func() time.Time { return time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC) },
})
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: reg})
if err != nil {
panic(err)
}
fmt.Println(in.Header.Policy, in.Header.DateKey.Round, in.UnlockAt.Format(time.RFC3339), len(in.Checks), "checks passed")
// Output: time_only 66884212 2030-01-01T00:00:00Z 8 checks passed
}

@ -0,0 +1,314 @@
// Package capsule implements the DateKeyCap .dkc container (spec §20-§39):
// framing, PUBLIC_HEADER, CONTROL_CBOR, header_binding, the time_only and
// time_and_key constructions, and the encryption (spec §61, §62) and
// decryption (spec §63) flows.
//
// A .dkc is PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE, where
// SEALED_CONTROL and PAYLOAD_AGE are complete standard age files and the
// payload runs to EOF (spec §22, §28-§34).
package capsule
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
)
// Framing constants (spec §22, §23) and parser limits (spec §57).
const (
Magic = "DKC1"
FramingVersion = 1
PreludeSize = 16
MaxPublicHeaderLen = 1 << 20 // 1 MiB
MaxSealedControlLen = 64 << 20 // 64 MiB
HeaderTypeTag = "datekeycap"
HeaderVersion = 1
ControlTypeTag = "datekeys-control"
ControlVersion = 1
CapsuleIDSize = 16
)
// Policy is the declared access policy of PUBLIC_HEADER (spec §25).
type Policy uint8
// Access policies of V1.
const (
TimeOnly Policy = 0
TimeAndKey Policy = 1
)
func (p Policy) String() string {
switch p {
case TimeOnly:
return "time_only"
case TimeAndKey:
return "time_and_key"
}
return fmt.Sprintf("policy(%d)", uint8(p))
}
// ParsePolicy parses "time_only" or "time_and_key".
func ParsePolicy(s string) (Policy, error) {
switch s {
case "time_only":
return TimeOnly, nil
case "time_and_key":
return TimeAndKey, nil
}
return 0, fmt.Errorf("capsule: unknown access policy %q", s)
}
func (p Policy) valid() bool { return p == TimeOnly || p == TimeAndKey }
// ---------------------------------------------------------------------------
// PRELUDE
// Prelude is the fixed 16-byte PRELUDE (spec §22, §23).
type Prelude struct {
PublicHeaderLen uint32
SealedControlLen uint32
}
// Bytes returns the exact 16 prelude bytes, the ones covered by
// header_binding.
func (p Prelude) Bytes() [PreludeSize]byte {
var b [PreludeSize]byte
copy(b[0:4], Magic)
b[4] = FramingVersion
// FLAGS (b[5]) and RESERVED (b[6:8]) are zero in V1.
binary.BigEndian.PutUint32(b[8:12], p.PublicHeaderLen)
binary.BigEndian.PutUint32(b[12:16], p.SealedControlLen)
return b
}
// PayloadOffset is where PAYLOAD_AGE starts:
// 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN (spec §63).
func (p Prelude) PayloadOffset() int64 {
return PreludeSize + int64(p.PublicHeaderLen) + int64(p.SealedControlLen)
}
// ParsePrelude validates the prelude (spec §22, §63 step 2): magic, version,
// FLAGS == 0, RESERVED == 0 and the length limits of spec §57.
func ParsePrelude(b []byte) (Prelude, error) {
if len(b) < 4 || string(b[0:4]) != Magic {
return Prelude{}, fmt.Errorf("capsule: %w", datekeys.ErrInvalidMagic)
}
if len(b) < PreludeSize {
return Prelude{}, fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity)
}
if b[4] != FramingVersion {
return Prelude{}, fmt.Errorf("capsule: framing version %d: %w", b[4], datekeys.ErrUnsupportedVersion)
}
if b[5] != 0 || b[6] != 0 || b[7] != 0 {
return Prelude{}, fmt.Errorf("capsule: flags %#x, reserved %#02x%02x: %w", b[5], b[6], b[7], datekeys.ErrInvalidFlags)
}
p := Prelude{
PublicHeaderLen: binary.BigEndian.Uint32(b[8:12]),
SealedControlLen: binary.BigEndian.Uint32(b[12:16]),
}
if p.PublicHeaderLen == 0 || p.PublicHeaderLen > MaxPublicHeaderLen {
return Prelude{}, fmt.Errorf("capsule: PUBLIC_HEADER_LEN %d outside 1..%d: %w", p.PublicHeaderLen, MaxPublicHeaderLen, datekeys.ErrIntegrity)
}
if p.SealedControlLen == 0 || p.SealedControlLen > MaxSealedControlLen {
return Prelude{}, fmt.Errorf("capsule: SEALED_CONTROL_LEN %d outside 1..%d: %w", p.SealedControlLen, MaxSealedControlLen, datekeys.ErrIntegrity)
}
return p, nil
}
// HeaderBinding returns SHA-256(PRELUDE || PUBLIC_HEADER_BYTES) over the exact
// stored bytes; the header is never re-serialized for it (spec §26).
func HeaderBinding(prelude [PreludeSize]byte, publicHeader []byte) [32]byte {
h := sha256.New()
h.Write(prelude[:])
h.Write(publicHeader)
var out [32]byte
h.Sum(out[:0])
return out
}
// ---------------------------------------------------------------------------
// PUBLIC_HEADER
// Header is PUBLIC_HEADER (spec §24). There is no separate profile_id: the
// profile comes from the DateKey, the single source of truth.
type Header struct {
CapsuleID [CapsuleIDSize]byte // key 2
DateKey datekey.DateKey // key 3, canonical dk1_
Policy Policy // key 4, access_policy
Critical []extension.Extension // key 5
Noncritical []extension.Extension // key 6
}
type headerWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CapsuleID []byte `cbor:"2,keyasint"`
DateKey string `cbor:"3,keyasint"`
Policy uint64 `cbor:"4,keyasint"`
Critical []extension.Wire `cbor:"5,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"6,keyasint,omitempty"`
}
// CapsuleIDHex returns the capsule_id in hexadecimal.
func (h *Header) CapsuleIDHex() string { return hex.EncodeToString(h.CapsuleID[:]) }
// EncodeHeader returns the Deterministic CBOR bytes of h.
func EncodeHeader(h *Header) ([]byte, error) {
compact := h.DateKey.Compact()
if compact == "" {
return nil, fmt.Errorf("capsule: invalid DateKey: %w", datekeys.ErrDateKeyInvalid)
}
if !h.Policy.valid() {
return nil, fmt.Errorf("capsule: unknown access policy %d", h.Policy)
}
w := headerWire{
Type: HeaderTypeTag,
Version: HeaderVersion,
CapsuleID: h.CapsuleID[:],
DateKey: compact,
Policy: uint64(h.Policy),
}
var err error
if w.Critical, err = extension.Encode(h.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(h.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
return nil, err
}
return codec.Marshal(w)
}
// DecodeHeader validates and decodes PUBLIC_HEADER bytes (spec §24, §27,
// §63 step 4): canonical CBOR, the schema, a 16-byte capsule_id, a canonical
// DateKey, a V1 access policy and well-formed extension arrays. Whether the
// profile is pinned and the critical extensions known is decided by the
// caller.
func DecodeHeader(b []byte) (*Header, error) {
if err := codec.CheckSchema(b, HeaderTypeTag, HeaderVersion); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
var w headerWire
if err := codec.Unmarshal(b, &w); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
if len(w.CapsuleID) != CapsuleIDSize {
return nil, fmt.Errorf("capsule: capsule_id is %d bytes, want %d: %w", len(w.CapsuleID), CapsuleIDSize, datekeys.ErrNonCanonicalCBOR)
}
dk, err := datekey.Parse(w.DateKey)
if err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
if w.Policy > 1 {
return nil, fmt.Errorf("capsule: access_policy %d is not defined in V1: %w", w.Policy, datekeys.ErrNonCanonicalCBOR)
}
h := &Header{DateKey: dk, Policy: Policy(w.Policy)}
copy(h.CapsuleID[:], w.CapsuleID)
if h.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER critical_extensions: %w", err)
}
if h.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
return h, nil
}
// ---------------------------------------------------------------------------
// CONTROL_CBOR
// Control is CONTROL_CBOR (spec §31). PayloadIdentity is I_PAYLOAD, a secret.
type Control struct {
HeaderBinding [32]byte // key 2
PayloadIdentity [32]byte // key 3, raw X25519 identity bytes. SECRET.
Critical []extension.Extension // key 4
Noncritical []extension.Extension // key 5
}
type controlWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
HeaderBinding []byte `cbor:"2,keyasint"`
PayloadIdentity []byte `cbor:"3,keyasint"`
Critical []extension.Wire `cbor:"4,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"5,keyasint,omitempty"`
}
// String describes c without I_PAYLOAD.
func (c Control) String() string {
return fmt.Sprintf("Control{header_binding=%x payload_identity=REDACTED}", c.HeaderBinding)
}
// GoString describes c without I_PAYLOAD.
func (c Control) GoString() string { return c.String() }
// EncodeControl returns the Deterministic CBOR bytes of c. The caller must
// wipe the result: it contains I_PAYLOAD.
func EncodeControl(c *Control) ([]byte, error) {
w := controlWire{
Type: ControlTypeTag,
Version: ControlVersion,
HeaderBinding: c.HeaderBinding[:],
PayloadIdentity: c.PayloadIdentity[:],
}
var err error
if w.Critical, err = extension.Encode(c.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(c.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
return nil, err
}
return codec.Marshal(w)
}
// DecodeControl validates and decodes CONTROL_CBOR (spec §31, §63 step 14).
// A non-canonical encoding is rejected even though CONTROL_CBOR is not hashed.
func DecodeControl(b []byte) (*Control, error) {
if err := codec.CheckSchema(b, ControlTypeTag, ControlVersion); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
var w controlWire
if err := codec.Unmarshal(b, &w); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
defer clear(w.PayloadIdentity)
if len(w.HeaderBinding) != 32 || len(w.PayloadIdentity) != 32 {
return nil, fmt.Errorf("capsule: header_binding and payload_identity must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
}
c := &Control{}
copy(c.HeaderBinding[:], w.HeaderBinding)
copy(c.PayloadIdentity[:], w.PayloadIdentity)
var err error
if c.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR critical_extensions: %w", err)
}
if c.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
return c, nil
}
// looksLikeAge reports whether b starts with the age v1 intro line.
func looksLikeAge(b []byte) bool {
return bytes.HasPrefix(b, []byte("age-encryption.org/v1\n"))
}

@ -0,0 +1,192 @@
package capsule_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func TestPolicyNames(t *testing.T) {
for _, p := range []capsule.Policy{capsule.TimeOnly, capsule.TimeAndKey} {
got, err := capsule.ParsePolicy(p.String())
if err != nil || got != p {
t.Fatalf("%s: %v", p, err)
}
}
if _, err := capsule.ParsePolicy("time_or_key"); err == nil {
t.Fatal("unknown policy parsed")
}
if capsule.Policy(9).String() != "policy(9)" {
t.Fatal("unknown policy name")
}
}
func TestControlIsNotPrinted(t *testing.T) {
c := capsule.Control{}
for i := range c.PayloadIdentity {
c.PayloadIdentity[i] = 0xab
}
for _, s := range []string{fmt.Sprint(c), fmt.Sprintf("%+v", &c), fmt.Sprintf("%#v", c)} {
if strings.Contains(s, "abab") || !strings.Contains(s, "REDACTED") {
t.Fatalf("I_PAYLOAD printed: %s", s)
}
}
}
func TestEncodeHeaderAndControlReject(t *testing.T) {
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
dup := []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
for name, h := range map[string]capsule.Header{
"invalid DateKey": {},
"unknown policy": {DateKey: dk, Policy: 5},
"repeated critical": {DateKey: dk, Critical: dup},
"repeated noncritical": {DateKey: dk, Noncritical: dup},
"both arrays": {DateKey: dk, Critical: dup[:1], Noncritical: dup[1:]},
} {
if _, err := capsule.EncodeHeader(&h); err == nil {
t.Errorf("%s: accepted", name)
}
}
for name, c := range map[string]capsule.Control{
"repeated critical": {Critical: dup},
"repeated noncritical": {Noncritical: dup},
"both arrays": {Critical: dup[:1], Noncritical: dup[1:]},
} {
if _, err := capsule.EncodeControl(&c); err == nil {
t.Errorf("control %s: accepted", name)
}
}
}
func marshal(t *testing.T, m map[uint64]any) []byte {
t.Helper()
b, err := codec.Marshal(m)
if err != nil {
t.Fatal(err)
}
return b
}
func ext(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
func TestDecodeHeaderRejects(t *testing.T) {
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
base := func() map[uint64]any {
return map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: dk, 4: uint64(0)}
}
for _, tc := range []struct {
name string
edit func(m map[uint64]any)
want error
}{
{"short capsule_id", func(m map[uint64]any) { m[2] = make([]byte, 15) }, datekeys.ErrNonCanonicalCBOR},
{"invalid DateKey", func(m map[uint64]any) { m[3] = "dk1_x" }, datekeys.ErrDateKeyInvalid},
{"type tag", func(m map[uint64]any) { m[0] = capsule.ControlTypeTag }, datekeys.ErrNonCanonicalCBOR},
{"critical out of order", func(m map[uint64]any) { m[5] = []any{ext("b", 1), ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
{"noncritical repeated", func(m map[uint64]any) { m[6] = []any{ext("a", 1), ext("a", 2)} }, datekeys.ErrNonCanonicalCBOR},
{"both arrays", func(m map[uint64]any) { m[5] = []any{ext("a", 1)}; m[6] = []any{ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
} {
m := base()
tc.edit(m)
if _, err := capsule.DecodeHeader(marshal(t, m)); !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
if _, err := capsule.DecodeHeader(marshal(t, base())); err != nil {
t.Fatalf("valid header rejected: %v", err)
}
}
func TestDecodeControlRejects(t *testing.T) {
base := func() map[uint64]any {
return map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(1), 2: make([]byte, 32), 3: make([]byte, 32)}
}
for _, tc := range []struct {
name string
edit func(m map[uint64]any)
want error
}{
{"schema version", func(m map[uint64]any) { m[1] = uint64(2) }, datekeys.ErrUnsupportedVersion},
{"type tag", func(m map[uint64]any) { m[0] = capsule.HeaderTypeTag }, datekeys.ErrNonCanonicalCBOR},
{"short binding", func(m map[uint64]any) { m[2] = make([]byte, 31) }, datekeys.ErrNonCanonicalCBOR},
{"long identity", func(m map[uint64]any) { m[3] = make([]byte, 33) }, datekeys.ErrNonCanonicalCBOR},
{"unknown key", func(m map[uint64]any) { m[6] = "x" }, datekeys.ErrNonCanonicalCBOR},
{"critical repeated", func(m map[uint64]any) { m[4] = []any{ext("a", 1), ext("a", 2)} }, datekeys.ErrNonCanonicalCBOR},
{"noncritical out of order", func(m map[uint64]any) { m[5] = []any{ext("b", 1), ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
{"both arrays", func(m map[uint64]any) { m[4] = []any{ext("a", 1)}; m[5] = []any{ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
} {
m := base()
tc.edit(m)
if _, err := capsule.DecodeControl(marshal(t, m)); !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
if _, err := capsule.DecodeControl([]byte("age-encryption.org/v1\n")); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("garbage control: %v", err)
}
}
func TestHeaderLimit(t *testing.T) {
big, err := extension.New("org.example.big", 1, bytes.Repeat([]byte{1}, capsule.MaxPublicHeaderLen))
if err != nil {
t.Fatal(err)
}
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{big}
var dkc bytes.Buffer
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil || dkc.Len() != 0 {
t.Fatalf("PUBLIC_HEADER above 1 MiB accepted: %v", err)
}
}
// A .dkk whose critical extension the application does not know is refused
// before any request.
func TestAccessKeyCriticalExtension(t *testing.T) {
f := loadFixture(t, "time_and_key_portable")
k := *f.dkk
k.Critical = []extension.Extension{{ID: "org.example.must-understand", Version: 1}}
o := f.openOptions(t)
o.AccessKey = &k
src := testkit.NewSource(f.release)
o.Source = src
if _, err := open(t, f.dkc, o); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) || src.Calls != 0 {
t.Fatalf("got %v (requests %d)", err, src.Calls)
}
o.Extensions = extension.Set{"org.example.must-understand": {1}}
if got, err := open(t, f.dkc, o); err != nil || !bytes.Equal(got, f.plaintext) {
t.Fatalf("known .dkk extension rejected: %v", err)
}
}
type brokenSeeker struct {
*bytes.Reader
seeks int
}
func (b *brokenSeeker) Seek(off int64, whence int) (int64, error) {
b.seeks++
if b.seeks > 1 {
return 0, errors.New("seek failed")
}
return b.Reader.Seek(off, whence)
}
func TestCapsuleDigestSeekFailure(t *testing.T) {
f := loadFixture(t, "time_and_key_portable")
r := &brokenSeeker{Reader: bytes.NewReader(f.dkc)}
if _, err := capsule.Open(context.Background(), io.Discard, r, f.openOptions(t)); err == nil {
t.Fatal("seek failure ignored")
}
}

@ -0,0 +1,127 @@
package capsule_test
import (
"bytes"
"context"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"testing"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/internal/testkit"
)
func seedFixtures(f *testing.F, part func(testkit.Parts) []byte) {
for _, name := range fixtureNames {
b, err := os.ReadFile(filepath.Join(fixtureDir, name+".dkc"))
if err != nil {
f.Fatal(err)
}
p, err := testkit.Split(b)
if err != nil {
f.Fatal(err)
}
f.Add(part(p))
}
}
// whole keeps the first 512 bytes of the payload: the pre-unlock checks read
// only its age header, and small inputs keep the fuzzer fast.
func whole(p testkit.Parts) []byte {
payload := p.Payload
if len(payload) > 512 {
payload = payload[:512]
}
return testkit.Join(p.Prelude, p.Header, p.Sealed, payload)
}
func FuzzParsePrelude(f *testing.F) {
seedFixtures(f, func(p testkit.Parts) []byte { return p.Prelude })
f.Fuzz(func(t *testing.T, b []byte) {
p, err := capsule.ParsePrelude(b)
if err != nil {
if datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
return
}
got := p.Bytes()
if !bytes.Equal(got[:], b[:capsule.PreludeSize]) {
t.Fatal("accepted a prelude that does not re-encode to its input")
}
})
}
func FuzzDecodeHeader(f *testing.F) {
seedFixtures(f, func(p testkit.Parts) []byte { return p.Header })
f.Fuzz(func(t *testing.T, b []byte) {
h, err := capsule.DecodeHeader(b)
if err != nil {
if datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
return
}
re, err := capsule.EncodeHeader(h)
if err != nil || !bytes.Equal(re, b) {
t.Fatal("accepted a PUBLIC_HEADER that does not re-encode to its input")
}
})
}
func FuzzDecodeControl(f *testing.F) {
for _, name := range fixtureNames {
fx := loadFixture(f, name)
b, _ := hexDecode(fx.ControlCBOR)
f.Add(b)
}
f.Fuzz(func(t *testing.T, b []byte) {
c, err := capsule.DecodeControl(b)
if err != nil {
if datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
return
}
re, err := capsule.EncodeControl(c)
if err != nil || !bytes.Equal(re, b) {
t.Fatal("accepted a CONTROL_CBOR that does not re-encode to its input")
}
})
}
// FuzzInspect feeds whole capsules to the pre-unlock validation, and to Open
// with a source that never has the release: a capsule that Inspect rejects
// must not cause a request, and nothing may pass the release step. The
// cryptographic steps after it are exercised by the mutation corpus; keeping
// them out of this target keeps it fast.
func FuzzInspect(f *testing.F) {
seedFixtures(f, whole)
reg := testkit.Registry()
far := testkit.Fixed(testkit.Genesis().AddDate(5, 0, 0))
f.Fuzz(func(t *testing.T, b []byte) {
_, err := capsule.Inspect(bytes.NewReader(b), capsule.InspectOptions{Registry: reg})
if err != nil && datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
src := testkit.NewSource()
o := capsule.OpenOptions{Registry: reg, Source: src, Now: far}
_, openErr := capsule.Open(context.Background(), io.Discard, bytes.NewReader(b), o)
switch {
case openErr == nil:
t.Fatal("opened without a release")
case datekeys.Code(openErr) == "":
t.Fatalf("error without a normative code: %v", openErr)
case err != nil && src.Calls != 0:
t.Fatal("a capsule rejected by Inspect caused a release request")
case err == nil && !errors.Is(openErr, datekeys.ErrReleaseUnavailable) && !errors.Is(openErr, datekeys.ErrAccessRequired):
t.Fatalf("a capsule accepted by Inspect failed before the release step: %v", openErr)
}
})
}
func hexDecode(s string) ([]byte, error) { return hex.DecodeString(s) }

@ -0,0 +1,211 @@
package capsule
import (
"bytes"
"errors"
"fmt"
"io"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/profile"
)
// InspectOptions configures Inspect.
type InspectOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the critical extensions the application implements.
// Nil knows none, the state of the base protocol V1.
Extensions extension.Registry
}
// CheckResult records one step of the flow of spec §63.
type CheckResult struct {
Step int `json:"step"`
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
// Error is the normative code of a failed step, for example
// "ERR_ROUND_MISMATCH".
Error string `json:"error,omitempty"`
}
// StanzaInfo is the visible part of an age recipient stanza.
type StanzaInfo struct {
Type string `json:"type"`
Args []string `json:"args"`
}
// Inspection is the result of the pre-unlock validation, steps 1 to 8 of
// spec §63. It is produced without network access and without secrets.
type Inspection struct {
Prelude Prelude
PublicHeader []byte // exact PUBLIC_HEADER bytes
Header *Header
Profile *profile.Profile
UnlockAt time.Time // effective round time of the DateKey
PayloadOffset int64
OuterStanzas []StanzaInfo // OUTER_TIME_AGE
PayloadStanzas []StanzaInfo // PAYLOAD_AGE
Checks []CheckResult
}
func (in *Inspection) pass(step int, name, detail string) {
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, OK: true, Detail: detail})
}
func (in *Inspection) fail(step int, name string, err error) error {
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, Detail: err.Error(), Error: datekeys.Code(err)})
return err
}
// parsed carries what Open needs after the inspection.
type parsed struct {
prelude [PreludeSize]byte
sealed []byte // OUTER_TIME_AGE
payload io.Reader // positioned at the start of PAYLOAD_AGE
}
// Inspect runs steps 1 to 8 of spec §63 on the .dkc read from r: framing,
// canonical PUBLIC_HEADER, canonical DateKey, pinned profile, known critical
// extensions, the stanza structure of OUTER_TIME_AGE and PAYLOAD_AGE, and the
// round and chain hash of the tlock stanza. It never contacts a release
// source and never uses a secret, so an invalid capsule is rejected before it
// can cause an observable query (spec §27, §63).
//
// Inspect reads the prelude, the header, SEALED_CONTROL and the age header of
// the payload; it does not read the rest of the payload. On failure it
// returns the partial Inspection together with the error.
func Inspect(r io.Reader, opts InspectOptions) (*Inspection, error) {
in, _, err := inspect(r, opts)
return in, err
}
func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) {
in := &Inspection{}
if opts.Registry == nil {
return in, nil, errors.New("capsule: InspectOptions.Registry is required")
}
// Steps 1 and 2: parse DKC1 and validate the prelude.
var pre [PreludeSize]byte
n, err := io.ReadFull(r, pre[:])
if err != nil && n >= 4 && string(pre[:4]) == Magic {
return in, nil, in.fail(1, "parse DKC1", fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity))
}
prelude, err := ParsePrelude(pre[:n])
if errors.Is(err, datekeys.ErrInvalidMagic) {
return in, nil, in.fail(1, "parse DKC1", err)
}
in.pass(1, "parse DKC1", "magic DKC1")
if err != nil {
return in, nil, in.fail(2, "prelude", err)
}
in.Prelude = prelude
in.PayloadOffset = prelude.PayloadOffset()
in.pass(2, "prelude", fmt.Sprintf("DKC1 v%d, PUBLIC_HEADER_LEN=%d, SEALED_CONTROL_LEN=%d",
FramingVersion, prelude.PublicHeaderLen, prelude.SealedControlLen))
// Step 3: read the exact PUBLIC_HEADER bytes.
hb, err := readExactly(r, int64(prelude.PublicHeaderLen))
if err != nil {
return in, nil, in.fail(3, "public header", fmt.Errorf("capsule: truncated PUBLIC_HEADER: %w", datekeys.ErrIntegrity))
}
in.PublicHeader = hb
in.pass(3, "public header", fmt.Sprintf("%d bytes", len(hb)))
// Step 4: canonical CBOR, canonical DateKey, pinned profile, known
// critical extensions.
h, err := DecodeHeader(hb)
if err != nil {
return in, nil, in.fail(4, "header validation", err)
}
in.Header = h
p, ok := opts.Registry.Lookup(h.DateKey.ProfileID)
if !ok {
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: profile %q is not pinned: %w", h.DateKey.ProfileID, datekeys.ErrUnknownProfile))
}
in.Profile = p
if err := extension.CheckCritical(h.Critical, opts.Extensions); err != nil {
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: PUBLIC_HEADER: %w", err))
}
in.pass(4, "header validation", fmt.Sprintf("capsule_id=%s datekey=%s policy=%s profile=%s",
h.CapsuleIDHex(), h.DateKey.Compact(), h.Policy, p.ID))
// Step 5: OUTER_TIME_AGE holds exactly one stanza, of type tlock.
sealed, err := readExactly(r, int64(prelude.SealedControlLen))
if err != nil {
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: truncated SEALED_CONTROL: %w", datekeys.ErrIntegrity))
}
outer, err := agewrap.Stanzas(bytes.NewReader(sealed))
if err != nil {
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: SEALED_CONTROL: %w", err))
}
in.OuterStanzas = infos(outer)
if len(outer) != 1 || outer[0].Type != agewrap.StanzaTLock {
err := fmt.Errorf("capsule: OUTER_TIME_AGE must hold exactly one tlock stanza, found %d: %w", len(outer), datekeys.ErrPolicyStructureMismatch)
return in, nil, in.fail(5, "sealed control structure", err)
}
in.pass(5, "sealed control structure", "one tlock stanza")
// Step 6: PAYLOAD_AGE holds exactly one stanza, of type X25519. Only its
// age header is read; the bytes consumed are replayed for decryption.
var captured bytes.Buffer
payloadStanzas, err := agewrap.Stanzas(io.TeeReader(r, &captured))
if err != nil {
return in, nil, in.fail(6, "payload structure", fmt.Errorf("capsule: PAYLOAD_AGE: %w", err))
}
in.PayloadStanzas = infos(payloadStanzas)
if err := agewrap.CheckPayloadStanzas(payloadStanzas); err != nil {
return in, nil, in.fail(6, "payload structure", err)
}
in.pass(6, "payload structure", "one X25519 stanza")
// Step 7: resolve and verify the time condition locally.
if err := h.DateKey.Validate(p); err != nil {
return in, nil, in.fail(7, "condition", err)
}
unlock, err := datekey.RoundTime(p, h.DateKey.Round)
if err != nil {
return in, nil, in.fail(7, "condition", err)
}
in.UnlockAt = unlock
in.pass(7, "condition", fmt.Sprintf("round %d, unlock at %s", h.DateKey.Round, unlock.Format(time.RFC3339)))
// Step 8: the tlock stanza names the DateKey round and the pinned chain.
if err := agewrap.CheckTimeStanzas(outer, p, h.DateKey.Round); err != nil {
return in, nil, in.fail(8, "tlock stanza", err)
}
in.pass(8, "tlock stanza", fmt.Sprintf("round %d, chain %s", h.DateKey.Round, p.ChainHashHex()))
return in, &parsed{
prelude: pre,
sealed: sealed,
payload: io.MultiReader(bytes.NewReader(captured.Bytes()), r),
}, nil
}
// readExactly reads n bytes. The buffer grows with the data actually read, so
// a short file that declares a large length (within the §57 limits) does not
// force an allocation of that size.
func readExactly(r io.Reader, n int64) ([]byte, error) {
var b bytes.Buffer
if _, err := io.CopyN(&b, r, n); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func infos(stanzas []*age.Stanza) []StanzaInfo {
out := make([]StanzaInfo, len(stanzas))
for i, s := range stanzas {
out[i] = StanzaInfo{Type: s.Type, Args: s.Args}
}
return out
}

@ -0,0 +1,99 @@
//go:build interop
// Interoperability with third-party tools (plan §7.9): SEALED_CONTROL and
// PAYLOAD_AGE of an official fixture are standard age files, opened here by
// the official age and tle command-line tools.
//
// go install filippo.io/age/cmd/age@v1.3.2
// go install github.com/drand/tlock/cmd/tle@v1.2.0
// go test -tags interop ./capsule -run Interop
//
// DATEKEYS_AGE and DATEKEYS_TLE may point at the binaries. The tle part
// fetches round 1000 from the public drand relay.
package capsule_test
import (
"bytes"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/internal/testkit"
)
func tool(t *testing.T, env, name string) string {
t.Helper()
if p := os.Getenv(env); p != "" {
return p
}
p, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s not found; install it or set %s", name, env)
}
return p
}
func TestInteropAgeOpensPayload(t *testing.T) {
bin := tool(t, "DATEKEYS_AGE", "age")
f := loadFixture(t, "time_only")
parts, _ := testkit.Split(f.dkc)
raw, _ := hex.DecodeString(f.PayloadIdentity)
id, err := agewrap.X25519IdentityFromRaw(raw)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "payload.age"), parts.Payload, 0o600)
os.WriteFile(filepath.Join(dir, "id.txt"), []byte(id.String()+"\n"), 0o600)
out := filepath.Join(dir, "plain")
cmd := exec.Command(bin, "-d", "-i", filepath.Join(dir, "id.txt"), "-o", out, filepath.Join(dir, "payload.age"))
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("age: %v\n%s", err, b)
}
if got, _ := os.ReadFile(out); !bytes.Equal(got, f.plaintext) {
t.Fatal("age produced a different plaintext")
}
}
func TestInteropAgeEncryptsPayloadWeOpen(t *testing.T) {
bin := tool(t, "DATEKEYS_AGE", "age")
id, _ := age.GenerateX25519Identity()
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "in"), []byte("written by age"), 0o600)
out := filepath.Join(dir, "out.age")
if b, err := exec.Command(bin, "-r", id.Recipient().String(), "-o", out, filepath.Join(dir, "in")).CombinedOutput(); err != nil {
t.Fatalf("age: %v\n%s", err, b)
}
file, _ := os.ReadFile(out)
raw, _ := agewrap.RawX25519Identity(id)
pid, _ := agewrap.NewPayloadIdentity(raw)
if got := decryptAge(t, file, pid); string(got) != "written by age" {
t.Fatal("plaintext differs")
}
}
func TestInteropTleOpensSealedControl(t *testing.T) {
bin := tool(t, "DATEKEYS_TLE", "tle")
f := loadFixture(t, "time_only")
parts, _ := testkit.Split(f.dkc)
dir := t.TempDir()
in := filepath.Join(dir, "sealed.age")
os.WriteFile(in, parts.Sealed, 0o600)
out := filepath.Join(dir, "control.cbor")
cmd := exec.Command(bin, "-d", "-o", out, in)
if b, err := cmd.CombinedOutput(); err != nil {
if strings.Contains(string(b), "dial") || strings.Contains(string(b), "no such host") {
t.Skipf("tle needs network access: %s", b)
}
t.Fatalf("tle: %v\n%s", err, b)
}
if got, _ := os.ReadFile(out); hex.EncodeToString(got) != f.ControlCBOR {
t.Fatal("tle produced a different CONTROL_CBOR")
}
}

@ -0,0 +1,67 @@
//go:build integration
// Live integration against public Quicknet relays (plan §7.8), run nightly:
//
// go test -tags integration ./capsule ./provider/drand
package capsule_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider/drand"
)
// Encrypt to now + 30 s, check that the capsule stays locked without any
// request, wait, and open it with a release fetched from real relays.
func TestLiveLifecycle(t *testing.T) {
p := profile.Quicknet()
reg, err := profile.Default()
if err != nil {
t.Fatal(err)
}
holder, _ := age.GenerateX25519Identity()
for _, policy := range []capsule.Policy{capsule.TimeOnly, capsule.TimeAndKey} {
t.Run(policy.String(), func(t *testing.T) {
unlock := time.Now().Add(30 * time.Second)
opts := capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Policy: policy, Now: time.Now}
if policy == capsule.TimeAndKey {
opts.Recipients = []age.Recipient{holder.Recipient()}
}
const msg = "DateKeys live integration: this stays on the local machine."
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
if err != nil {
t.Fatal(err)
}
o := capsule.OpenOptions{Registry: reg, Source: drand.New(), Now: time.Now, Identities: []age.Identity{holder}}
if _, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("opened before the round: %v", err)
}
t.Logf("locked for round %d until %s", res.DateKey.Round, res.UnlockAt.Format(time.RFC3339))
time.Sleep(time.Until(res.UnlockAt) + 2*time.Second)
var out bytes.Buffer
deadline := time.Now().Add(30 * time.Second)
for {
_, err = capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), o)
if err == nil || !errors.Is(err, datekeys.ErrReleaseUnavailable) || time.Now().After(deadline) {
break
}
out.Reset()
time.Sleep(time.Second)
}
if err != nil || out.String() != msg {
t.Fatalf("open after the round: %v", err)
}
})
}
}

@ -0,0 +1,437 @@
package capsule_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"io"
"strings"
"testing"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// mutation is one entry of the mutation corpus (spec §64): a function over a
// valid fixture that must fail with one exact normative error at one step.
type mutation struct {
name string
// spec is true for the twenty mutations listed in spec §64.
spec bool
make func(t *testing.T, env *env) (dkc []byte, opts capsule.OpenOptions)
want *datekeys.Error
step int
// network reports whether the failure may happen after a release was
// requested. Failures of steps 1 to 8 and of the access pre-checks must
// not cause any request (spec §27, §63).
network bool
}
type env struct {
to, tk *fixture // time_only and time_and_key_portable fixtures
toParts testkit.Parts
tkParts testkit.Parts
sibling []byte // another time_only capsule for the same round
stranger *age.X25519Identity
}
func newEnv(t *testing.T) *env {
e := &env{to: loadFixture(t, "time_only"), tk: loadFixture(t, "time_and_key_portable")}
var err error
if e.toParts, err = testkit.Split(e.to.dkc); err != nil {
t.Fatal(err)
}
if e.tkParts, err = testkit.Split(e.tk.dkc); err != nil {
t.Fatal(err)
}
var b bytes.Buffer
p := profile.Quicknet()
unlock, _ := datekey.RoundTime(p, 1000)
if _, err := capsule.Encrypt(&b, strings.NewReader("sibling"), capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}); err != nil {
t.Fatal(err)
}
e.sibling = b.Bytes()
e.stranger, _ = age.GenerateX25519Identity()
return e
}
func withSource(o capsule.OpenOptions, s provider.ReleaseSource) capsule.OpenOptions {
o.Source = s
return o
}
func set(b []byte, i int, v byte) []byte {
c := bytes.Clone(b)
c[i] = v
return c
}
func xorLast(b []byte) []byte {
c := bytes.Clone(b)
c[len(c)-1] ^= 0x01
return c
}
// build returns a capsule made by testkit.Build and the options to open it.
func build(t *testing.T, b testkit.Build) ([]byte, capsule.OpenOptions) {
t.Helper()
if b.Plaintext == nil {
b.Plaintext = []byte("malicious creator")
}
out, err := b.Make()
if err != nil {
t.Fatal(err)
}
return out.DKC, capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(1000)), Now: testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0))}
}
func headerWithDateKey(t *testing.T, e *env, dk string) []byte {
t.Helper()
h, err := capsule.DecodeHeader(e.toParts.Header)
if err != nil {
t.Fatal(err)
}
raw, err := testkit.RawHeader(h.CapsuleID, dk, 0)
if err != nil {
t.Fatal(err)
}
return testkit.Reframe(e.toParts.Prelude, raw, e.toParts.Sealed, e.toParts.Payload)
}
func policyByte(t *testing.T, header []byte) int {
// The access_policy entry is the last one of a header without extensions: 0x04 <value>.
i := len(header) - 2
if header[i] != 0x04 {
t.Fatalf("unexpected header layout %x", header[i:])
}
return i + 1
}
var mutations = []mutation{
// ---- The twenty mutations of spec §64 -------------------------------
{name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", spec: true, want: datekeys.ErrHeaderBinding, step: 15, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
b, _ := testkit.Split(e.sibling)
return testkit.Reframe(e.toParts.Prelude, e.toParts.Header, b.Sealed, b.Payload), e.to.openOptions(t)
}},
{name: "SEALED_CONTROL_A + PAYLOAD_AGE_B", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
b, _ := testkit.Split(e.sibling)
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, b.Payload), e.to.openOptions(t)
}},
{name: "DateKey A + release of round B", spec: true, want: datekeys.ErrRoundMismatch, step: 10, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
src := provider.ReleaseSourceFunc(func(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
return testkit.Release(1001), nil
})
return e.to.dkc, withSource(e.to.openOptions(t), src)
}},
{name: "chain hash changed", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
p := profile.Quicknet()
other := strings.Repeat("ab", 32)
return bytes.Replace(e.to.dkc, []byte(p.ChainHashHex()), []byte(other), 1), e.to.openOptions(t)
}},
{name: "version changed", spec: true, want: datekeys.ErrUnsupportedVersion, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 4, 2), e.to.openOptions(t)
}},
{name: "flags != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 5, 0x80), e.to.openOptions(t)
}},
{name: "reserved != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 7, 1), e.to.openOptions(t)
}},
{name: "payload truncated", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc[:len(e.to.dkc)-1], e.to.openOptions(t)
}},
{name: "payload age modified", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return xorLast(e.to.dkc), e.to.openOptions(t)
}},
{name: "control modified", spec: true, want: datekeys.ErrIntegrity, step: 11, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return testkit.Join(e.toParts.Prelude, e.toParts.Header, xorLast(e.toParts.Sealed), e.toParts.Payload), e.to.openOptions(t)
}},
{name: "non-canonical dk1_ JSON", spec: true, want: datekeys.ErrDateKeyNonCanonical, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dk := datekey.Prefix + b64(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`)
return headerWithDateKey(t, e, dk), e.to.openOptions(t)
}},
{name: "unknown profile", spec: true, want: datekeys.ErrUnknownProfile, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dk := datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}
return headerWithDateKey(t, e, dk.Compact()), e.to.openOptions(t)
}},
{name: "release of another round", spec: true, want: datekeys.ErrReleaseInvalid, step: 10, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
forged := provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource(forged))
}},
{name: "access_policy=time_only with time_and_key structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := set(e.tkParts.Header, policyByte(t, e.tkParts.Header), 0)
return testkit.Join(e.tkParts.Prelude, h, e.tkParts.Sealed, e.tkParts.Payload), e.tk.openOptions(t)
}},
{name: "access_policy=time_and_key with time_only structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := set(e.toParts.Header, policyByte(t, e.toParts.Header), 1)
o := e.to.openOptions(t)
o.Identities = []age.Identity{e.stranger}
return testkit.Join(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), o
}},
{name: "extra stanza in OUTER_TIME_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 5,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := testkit.X25519Stanza(fk)
return append(s, extra)
}})
}},
{name: "extra stanza in PAYLOAD_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 6,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditPayload: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := testkit.X25519Stanza(fk)
return append(s, extra)
}})
}},
{name: "non-X25519 stanza in INNER_ACCESS_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
return append(s, &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)})
}})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
{name: "tlock stanza round differs from DateKey.round", spec: true, want: datekeys.ErrRoundMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }})
}},
{name: "tlock stanza chain hash differs from the pinned profile", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza {
s[0].Args[1] = "dbd506d6ef76e5f386f41c651dcb808c5bcbd75471cc4eafa3f4df7ad4e4c493" // drand default chain
return s
}})
}},
// ---- Further cases ----------------------------------------------------
{name: "magic", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 0, 'X'), e.to.openOptions(t)
}},
{name: "a .dkk offered as a .dkc", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return append([]byte("DKK1"), e.to.dkc[4:]...), e.to.openOptions(t)
}},
{name: "empty file", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return nil, e.to.openOptions(t) }},
{name: "truncated prelude", want: datekeys.ErrIntegrity, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return e.to.dkc[:10], e.to.openOptions(t) }},
{name: "PUBLIC_HEADER_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
c := bytes.Clone(e.to.dkc)
binary.BigEndian.PutUint32(c[8:12], capsule.MaxPublicHeaderLen+1)
return c, e.to.openOptions(t)
}},
{name: "SEALED_CONTROL_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
c := bytes.Clone(e.to.dkc)
binary.BigEndian.PutUint32(c[12:16], capsule.MaxSealedControlLen+1)
return c, e.to.openOptions(t)
}},
{name: "truncated inside SEALED_CONTROL", want: datekeys.ErrIntegrity, step: 5,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc[:len(e.toParts.Prelude)+len(e.toParts.Header)+10], e.to.openOptions(t)
}},
{name: "header schema version changed", want: datekeys.ErrUnsupportedVersion, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
// a5 00 6a "datekeycap" 01 <version>
return set(e.to.dkc, capsule.PreludeSize+14, 2), e.to.openOptions(t)
}},
{name: "unknown key in PUBLIC_HEADER", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := append(bytes.Clone(e.toParts.Header), 0x07, 0x00)
h[0]++ // one more map entry
return testkit.Reframe(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
}},
{name: "undefined access_policy", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return testkit.Join(e.toParts.Prelude, set(e.toParts.Header, policyByte(t, e.toParts.Header), 2), e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
}},
{name: "unknown critical PUBLIC_HEADER extension", want: datekeys.ErrExtensionCriticalUnknown, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{HeaderCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
}},
{name: "unknown critical CONTROL_CBOR extension", want: datekeys.ErrExtensionCriticalUnknown, step: 14, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{ControlCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
}},
{name: "time_and_key without credentials", want: datekeys.ErrAccessRequired, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
o.AccessKey = nil
return e.tk.dkc, o
}},
{name: ".dkk of another capsule", want: datekeys.ErrAccessInvalid, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
other := loadFixture(t, "time_and_key_recipients")
o.AccessKey = other.dkk
return e.tk.dkc, o
}},
{name: "capsule_digest of the .dkk does not match", want: datekeys.ErrAccessInvalid, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return xorLast(e.tk.dkc), e.tk.openOptions(t)
}},
{name: "identity that is not a recipient", want: datekeys.ErrAccessInvalid, step: 13, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
o.AccessKey = nil
o.Identities = []age.Identity{e.stranger}
return e.tk.dkc, o
}},
{name: "round not reached yet", want: datekeys.ErrReleaseUnavailable, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.to.openOptions(t)
o.Now = testkit.Fixed(e.to.unlock(t).Add(-1))
return e.to.dkc, o
}},
{name: "release source unavailable", want: datekeys.ErrReleaseUnavailable, step: 9, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource())
}},
{name: "trailing data after PAYLOAD_AGE", want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return append(bytes.Clone(e.to.dkc), 0), e.to.openOptions(t)
}},
{name: "payload stanza body modified", want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
n, err := testkit.HeaderLen(e.toParts.Payload)
if err != nil {
t.Fatal(err)
}
// Flip a byte of the wrapped file key: the last body line before "---".
i := bytes.LastIndex(e.toParts.Payload[:n], []byte("\n---")) - 10
c := byte('A')
if e.toParts.Payload[i] == 'A' {
c = 'B'
}
p := set(e.toParts.Payload, i, c)
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, p), e.to.openOptions(t)
}},
{name: "tlock round edited by a third party", want: datekeys.ErrRoundMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return bytes.Replace(e.to.dkc, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1), e.to.openOptions(t)
}},
{name: "empty registry", want: datekeys.ErrUnknownProfile, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.to.openOptions(t)
o.Registry, _ = profile.NewRegistry()
return e.to.dkc, o
}},
{name: "time_only declared, time_and_key built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{Declared: capsule.TimeOnly, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{e.stranger.Recipient()}})
}},
{name: "time_and_key declared, time_only built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeOnly})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
{name: "two INNER_ACCESS_AGE stanzas for one recipient", want: datekeys.ErrPolicyStructureMismatch, step: 13, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
again, _ := e.stranger.Recipient().Wrap(fk)
return append(s, again[0])
}})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
}
func TestMutationCorpus(t *testing.T) {
e := newEnv(t)
n := 0
for _, m := range mutations {
if m.spec {
n++
}
t.Run(m.name, func(t *testing.T) {
dkc, o := m.make(t, e)
counter := &countingSource{inner: o.Source}
o.Source = counter
opened, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), o)
if err == nil {
t.Fatal("mutation accepted")
}
if !errors.Is(err, m.want) {
t.Fatalf("got %v, want %v", err, m.want)
}
if !m.network && counter.calls != 0 {
t.Fatalf("an invalid capsule caused %d release requests", counter.calls)
}
if m.step != 0 {
checks := checksOf(opened, dkc, o)
last := checks[len(checks)-1]
if last.OK || last.Step != m.step || last.Error != m.want.Code() {
t.Fatalf("failed at %+v, want step %d", last, m.step)
}
}
})
}
if n != 20 {
t.Fatalf("spec §64 lists 20 mutations, the corpus has %d", n)
}
}
// checksOf returns the checks recorded for a failed Open; failures inside the
// inspection return no Opened, so the inspection is repeated for them.
func checksOf(opened *capsule.Opened, dkc []byte, o capsule.OpenOptions) []capsule.CheckResult {
if opened != nil {
return opened.Inspection.Checks
}
in, _ := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: o.Registry, Extensions: o.Extensions})
return in.Checks
}
type countingSource struct {
inner provider.ReleaseSource
calls int
}
func (c *countingSource) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
c.calls++
return c.inner.Fetch(ctx, p, cond)
}
// Known critical extensions are accepted when the application declares them.
func TestKnownCriticalExtensions(t *testing.T) {
crit := []extension.Extension{{ID: "org.example.must-understand", Version: 1}}
for _, b := range []testkit.Build{{HeaderCritical: crit}, {ControlCritical: crit}} {
dkc, o := build(t, b)
o.Extensions = extension.Set{"org.example.must-understand": {1}}
var out bytes.Buffer
if _, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o); err != nil || out.String() != "malicious creator" {
t.Fatalf("known critical extension rejected: %v", err)
}
}
}
func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }

@ -0,0 +1,273 @@
package capsule
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"io"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// OpenOptions configures Open.
type OpenOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the critical extensions the application implements.
Extensions extension.Registry
// Source fetches the release. Required. Its answer is always verified
// locally.
Source provider.ReleaseSource
// Identities are the caller's own X25519 identities, for time_and_key
// capsules encrypted to known recipients.
Identities []age.Identity
// AccessKey is a portable .dkk, for time_and_key capsules.
AccessKey *accesskey.AccessKey
// Now is the clock. Required: no package of this module reads the wall
// clock on its own. Open does not ask Source for a round whose time has
// not been reached.
Now func() time.Time
}
// Opened describes a capsule that Open decrypted completely.
type Opened struct {
Inspection *Inspection
Release provider.Release
// ControlCritical and ControlNoncritical are the extensions of the sealed
// CONTROL_CBOR, only visible after opening.
ControlCritical []extension.Extension
ControlNoncritical []extension.Extension
}
// Open runs the complete decryption flow of spec §63 and streams the
// plaintext to dst.
//
// Everything verifiable locally is checked before a release is requested or a
// secret is used (steps 1 to 8, the presence and capsule binding of access
// credentials, and the .dkk capsule_digest when r is seekable). The stanza
// rules are enforced again, as a MUST, by the identities that open each age
// file (steps 11, 13 and 17).
//
// Open returns nil only after age has authenticated the whole payload
// (step 18). On error, dst may hold a partial plaintext that MUST be
// discarded: write to a temporary file and publish it only on success
// (spec §56), as the datekeys CLI does.
func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*Opened, error) {
if opts.Source == nil {
return nil, errors.New("capsule: OpenOptions.Source is required")
}
if opts.Now == nil {
return nil, errors.New("capsule: OpenOptions.Now is required")
}
start, seekable := int64(0), false
if s, ok := r.(io.Seeker); ok {
if pos, err := s.Seek(0, io.SeekCurrent); err == nil {
start, seekable = pos, true
}
}
// Steps 1 to 8.
in, st, err := inspect(r, InspectOptions{Registry: opts.Registry, Extensions: opts.Extensions})
out := &Opened{Inspection: in}
if err != nil {
return out, err
}
h, p := in.Header, in.Profile
// Access credentials are checked before any network request.
var ids []age.Identity
if h.Policy == TimeAndKey {
ids = append(ids, opts.Identities...)
if k := opts.AccessKey; k != nil {
if err := checkAccessKey(k, h, opts.Extensions); err != nil {
return out, in.fail(9, "access credential", err)
}
if k.Verification != nil && seekable {
payload, err := checkCapsuleDigest(r.(io.ReadSeeker), start, in.PayloadOffset, k.Verification.CapsuleDigest)
if err != nil {
return out, in.fail(9, "access credential", err)
}
st.payload = payload
}
id, err := k.Identity()
if err != nil {
return out, in.fail(9, "access credential", err)
}
ids = append(ids, id)
}
if len(ids) == 0 {
return out, in.fail(9, "access credential", fmt.Errorf("capsule: time_and_key capsule and no identity or .dkk supplied: %w", datekeys.ErrAccessRequired))
}
in.pass(9, "access credential", fmt.Sprintf("%d identities to try", len(ids)))
}
// Step 9: obtain the release, never before its round time.
cond := provider.Condition{Round: h.DateKey.Round}
if now := opts.Now(); now.Before(in.UnlockAt) {
err := fmt.Errorf("capsule: round %d is published at %s, it is %s: %w", cond.Round,
in.UnlockAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
return out, in.fail(9, "release", err)
}
release, err := opts.Source.Fetch(ctx, p, cond)
if err != nil {
if datekeys.Code(err) == "" {
err = fmt.Errorf("capsule: %v: %w", err, datekeys.ErrReleaseUnavailable)
}
return out, in.fail(9, "release", err)
}
in.pass(9, "release", fmt.Sprintf("round %d obtained", release.Round))
// Step 10: verify the release locally.
if err := provider.Verify(p, cond, release); err != nil {
return out, in.fail(10, "release verification", err)
}
out.Release = release
in.pass(10, "release verification", "BLS signature valid under the pinned key")
// Step 11: open OUTER_TIME_AGE with the strict tlock identity.
timeID, err := agewrap.NewTimeIdentity(p, cond.Round, release)
if err != nil {
return out, in.fail(11, "open sealed control", err)
}
inner, err := decryptAll(st.sealed, timeID)
if err != nil {
return out, in.fail(11, "open sealed control", err)
}
defer clear(inner)
in.pass(11, "open sealed control", "one tlock stanza, header MAC valid")
// Step 12: the structure must match access_policy (spec §36).
var controlBytes []byte
switch h.Policy {
case TimeOnly:
if looksLikeAge(inner) {
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_only capsule seals an age file: %w", datekeys.ErrPolicyStructureMismatch))
}
controlBytes = inner
in.pass(12, "policy structure", "time_only: CONTROL_CBOR sealed directly")
case TimeAndKey:
stanzas, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_and_key capsule does not seal an age file: %w", datekeys.ErrPolicyStructureMismatch))
}
if err := agewrap.CheckAccessStanzas(stanzas); err != nil {
return out, in.fail(12, "policy structure", err)
}
in.pass(12, "policy structure", fmt.Sprintf("time_and_key: INNER_ACCESS_AGE with %d X25519 stanzas", len(stanzas)))
// Step 13: open INNER_ACCESS_AGE with the caller's identities.
accessID, err := agewrap.NewAccessIdentity(ids...)
if err != nil {
return out, in.fail(13, "open access layer", err)
}
if controlBytes, err = decryptAll(inner, accessID); err != nil {
return out, in.fail(13, "open access layer", err)
}
defer clear(controlBytes)
in.pass(13, "open access layer", "identity matched exactly one stanza")
}
// Step 14: parse the canonical CONTROL_CBOR.
control, err := DecodeControl(controlBytes)
if err != nil {
return out, in.fail(14, "control", err)
}
defer clear(control.PayloadIdentity[:])
if err := extension.CheckCritical(control.Critical, opts.Extensions); err != nil {
return out, in.fail(14, "control", fmt.Errorf("capsule: CONTROL_CBOR: %w", err))
}
out.ControlCritical, out.ControlNoncritical = control.Critical, control.Noncritical
in.pass(14, "control", "canonical CONTROL_CBOR")
// Step 15: verify header_binding over the exact stored bytes.
binding := HeaderBinding(st.prelude, in.PublicHeader)
if !hmac.Equal(binding[:], control.HeaderBinding[:]) {
return out, in.fail(15, "header binding", fmt.Errorf("capsule: header_binding does not match PRELUDE || PUBLIC_HEADER: %w", datekeys.ErrHeaderBinding))
}
in.pass(15, "header binding", "matches")
// Steps 16 and 17: recover I_PAYLOAD and open PAYLOAD_AGE with it.
payloadID, err := agewrap.NewPayloadIdentity(control.PayloadIdentity[:])
if err != nil {
return out, in.fail(16, "payload identity", err)
}
in.pass(16, "payload identity", "I_PAYLOAD recovered")
pr, err := age.Decrypt(st.payload, payloadID)
if err != nil {
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
}
if _, err := io.Copy(dst, pr); err != nil {
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
}
// Step 18: age completed without error.
in.pass(18, "commit", "payload authenticated completely")
return out, nil
}
// checkAccessKey validates a .dkk against the capsule before it is used.
func checkAccessKey(k *accesskey.AccessKey, h *Header, reg extension.Registry) error {
if k.CapsuleID != h.CapsuleID {
return fmt.Errorf("capsule: the .dkk is for capsule %x, this is %x: %w", k.CapsuleID, h.CapsuleID, datekeys.ErrAccessInvalid)
}
if err := extension.CheckCritical(k.Critical, reg); err != nil {
return fmt.Errorf("capsule: .dkk: %w", err)
}
return nil
}
// checkCapsuleDigest compares the .dkk capsule_digest with SHA-256 of the
// .dkc (spec §43), then repositions r at the payload. The digest is a fast
// failure for a wrong file, not a security property.
func checkCapsuleDigest(r io.ReadSeeker, start, payloadOffset int64, want []byte) (io.Reader, error) {
if _, err := r.Seek(start, io.SeekStart); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
sum := sha256.New()
if _, err := io.Copy(sum, r); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
if !hmac.Equal(sum.Sum(nil), want) {
return nil, fmt.Errorf("capsule: the .dkk capsule_digest does not match this .dkc: %w", datekeys.ErrAccessInvalid)
}
if _, err := r.Seek(start+payloadOffset, io.SeekStart); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
return r, nil
}
// decryptAll opens a bounded, in-memory age file. The plaintext is never
// longer than the ciphertext.
func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(ciphertext), id)
if err != nil {
return nil, classify("age", err)
}
out, err := io.ReadAll(io.LimitReader(r, int64(len(ciphertext))))
if err != nil {
clear(out)
return nil, classify("age", err)
}
return out, nil
}
// classify keeps the normative error an identity returned from Unwrap, and
// maps every other age failure (malformed header, bad header MAC, STREAM
// authentication, truncation, trailing data) to ErrIntegrity.
func classify(what string, err error) error {
if datekeys.Code(err) != "" {
return fmt.Errorf("capsule: %s: %w", what, err)
}
return fmt.Errorf("capsule: %s: %v: %w", what, err, datekeys.ErrIntegrity)
}

@ -0,0 +1,74 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// checkNew fails if path already exists.
func checkNew(path string) error {
if _, err := os.Lstat(path); err == nil {
return fmt.Errorf("%s already exists; outputs are never overwritten", path)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// writeAtomic stages the output in a private temporary file next to path and
// publishes it only after fn succeeded (spec §56). Publication creates path
// without replacing an existing file: a hard link where the file system
// supports it, otherwise an exclusive create and copy. On any failure no
// partial output remains.
func writeAtomic(path string, fn func(io.Writer) error) (err error) {
if err := checkNew(path); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".datekeys-*")
if err != nil {
return err
}
name := tmp.Name()
defer func() {
tmp.Close()
os.Remove(name)
}()
if err := fn(tmp); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Link(name, path); err == nil {
return nil
} else if errors.Is(err, os.ErrExist) {
return fmt.Errorf("%s already exists; outputs are never overwritten", path)
}
return copyExclusive(name, path)
}
func copyExclusive(from, to string) error {
src, err := os.Open(from)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, copyErr := io.Copy(dst, src)
syncErr := dst.Sync()
closeErr := dst.Close()
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
os.Remove(to)
return err
}
return nil
}

@ -0,0 +1,391 @@
// Command datekeys encrypts, inspects and opens DateKeyCap (.dkc) files.
//
// datekeys encrypt -at 2030-01-01T00:00:00Z -in secret.txt -out secret.dkc
// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk key.dkk -in secret.txt -out secret.dkc
// datekeys inspect -in secret.dkc
// datekeys decrypt -in secret.dkc -out secret.txt [-dkk key.dkk] [-identity key.txt]
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
// datekeys profile hash
//
// Encryption never touches the network. Decryption fetches the release from
// public drand relays and verifies it locally. Outputs are written to a
// temporary file in the destination directory and published only when
// complete; existing files are never overwritten.
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider/drand"
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE -out FILE.dkc [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk]
datekeys decrypt -in FILE.dkc -out FILE [-dkk FILE.dkk] [-identity FILE]... [-relay URL]...
datekeys inspect -in FILE.dkc [-json]
datekeys datekey resolve -at TIME
datekeys profile hash [-in PROFILE.cbor]
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.`
// errUsage reports a malformed command line; main prints the usage text.
var errUsage = errors.New("invalid command line; run 'datekeys help'")
// longHorizon is the product policy threshold for the harvest-now,
// decrypt-later warning (spec §53).
const longHorizon = 365 * 24 * time.Hour
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr, time.Now); err != nil {
if errors.Is(err, errUsage) {
fmt.Fprintln(os.Stderr, usage)
os.Exit(2)
}
fmt.Fprintln(os.Stderr, "datekeys:", err)
if code := datekeys.Code(err); code != "" {
fmt.Fprintln(os.Stderr, "datekeys: error code", code)
}
os.Exit(1)
}
}
type multi []string
func (m *multi) String() string { return strings.Join(*m, ",") }
func (m *multi) Set(v string) error { *m = append(*m, v); return nil }
// run is the CLI; the clock is injected for tests (only the CLI reads the
// wall clock).
func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "encrypt":
return encrypt(args[1:], stderr, now)
case "decrypt":
return decrypt(args[1:], stderr, now)
case "inspect":
return inspect(args[1:], stdout)
case "datekey":
if len(args) < 2 || args[1] != "resolve" {
return errUsage
}
return resolve(args[2:], stdout)
case "profile":
if len(args) < 2 || args[1] != "hash" {
return errUsage
}
return profileHash(args[2:], stdout)
case "-h", "-help", "--help", "help":
fmt.Fprintln(stdout, usage)
return nil
}
return errUsage
}
func newFlags(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
return fs
}
func parse(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%s: %w", fs.Name(), err)
}
if fs.NArg() != 0 {
return fmt.Errorf("%s: unexpected arguments %q", fs.Name(), fs.Args())
}
return nil
}
func parseTime(s string) (time.Time, error) {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fmt.Errorf("invalid -at %q: RFC 3339 with a time zone is required", s)
}
return t, nil
}
func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs := newFlags("encrypt")
at := fs.String("at", "", "unlock time, RFC 3339")
in := fs.String("in", "", "plaintext file")
out := fs.String("out", "", "new .dkc file; never overwritten")
policy := fs.String("policy", "time_only", "time_only or time_and_key")
dkk := fs.String("dkk", "", "time_and_key: new .dkk file for a portable access key")
var recipients multi
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
if err := parse(fs, args); err != nil {
return err
}
unlock, err := parseTime(*at)
if err != nil {
return err
}
pol, err := capsule.ParsePolicy(*policy)
if err != nil {
return err
}
if *in == "" || *out == "" {
return errors.New("encrypt: -in and -out are required")
}
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Now: now}
for _, r := range recipients {
x, err := age.ParseX25519Recipient(r)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
opts.Recipients = append(opts.Recipients, x)
}
if *dkk != "" {
if err := checkNew(*dkk); err != nil {
return err
}
}
src, err := os.Open(*in)
if err != nil {
return err
}
defer src.Close()
var res *capsule.Result
err = writeAtomic(*out, func(w io.Writer) error {
res, err = capsule.Encrypt(w, src, opts)
return err
})
if err != nil {
return err
}
if res.PortableKey != nil {
defer res.PortableKey.Wipe()
if err := writeAtomic(*dkk, func(w io.Writer) error { return accesskey.Encode(w, res.PortableKey) }); err != nil {
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
}
}
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID)
if res.PortableKey != nil {
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
}
if res.UnlockAt.Sub(now()) > longHorizon {
fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+
" and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).")
}
return nil
}
func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs := newFlags("decrypt")
in := fs.String("in", "", ".dkc file")
out := fs.String("out", "", "new plaintext file; never overwritten")
dkk := fs.String("dkk", "", "portable access key (.dkk)")
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
var identities, relays multi
fs.Var(&identities, "identity", "age identity file with X25519 keys (repeatable)")
fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays")
if err := parse(fs, args); err != nil {
return err
}
if *in == "" || *out == "" {
return errors.New("decrypt: -in and -out are required")
}
reg, err := profile.Default()
if err != nil {
return err
}
opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now}
for _, path := range identities {
ids, err := readIdentities(path)
if err != nil {
return err
}
opts.Identities = append(opts.Identities, ids...)
}
if *dkk != "" {
f, err := os.Open(*dkk)
if err != nil {
return err
}
k, err := accesskey.Decode(f)
f.Close()
if err != nil {
return err
}
defer k.Wipe()
opts.AccessKey = k
}
src, err := os.Open(*in)
if err != nil {
return err
}
defer src.Close()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
var opened *capsule.Opened
err = writeAtomic(*out, func(w io.Writer) error {
opened, err = capsule.Open(ctx, w, src, opts)
return err
})
if err != nil {
return err
}
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
return nil
}
func readIdentities(path string) ([]age.Identity, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
ids, err := age.ParseIdentities(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return ids, nil
}
type inspectView struct {
File string `json:"file"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
AccessPolicy string `json:"access_policy,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
}
// inspect runs steps 1 to 8 only: it never requests a release and never uses
// a secret.
func inspect(args []string, stdout io.Writer) error {
fs := newFlags("inspect")
in := fs.String("in", "", ".dkc file")
asJSON := fs.Bool("json", false, "JSON output")
if err := parse(fs, args); err != nil {
return err
}
if *in == "" {
return errors.New("inspect: -in is required")
}
reg, err := profile.Default()
if err != nil {
return err
}
f, err := os.Open(*in)
if err != nil {
return err
}
defer f.Close()
result, inspectErr := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
v := inspectView{File: *in, Valid: inspectErr == nil, Error: datekeys.Code(inspectErr), Checks: result.Checks}
if h := result.Header; h != nil {
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
}
if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
} else {
fmt.Fprintf(stdout, "%s\n", v.File)
for _, c := range v.Checks {
mark := "ok "
if !c.OK {
mark = "FAIL"
}
fmt.Fprintf(stdout, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
}
if v.Valid {
fmt.Fprintf(stdout, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
}
}
return inspectErr
}
type resolveView struct {
DateKey string `json:"datekey"`
Profile string `json:"profile"`
Round uint64 `json:"round"`
Requested string `json:"requested"`
UnlockAt string `json:"unlock_at"`
}
func resolve(args []string, stdout io.Writer) error {
fs := newFlags("datekey resolve")
at := fs.String("at", "", "instant, RFC 3339")
if err := parse(fs, args); err != nil {
return err
}
t, err := parseTime(*at)
if err != nil {
return err
}
p := profile.Quicknet()
d, err := datekey.Resolve(p, t)
if err != nil {
return err
}
return json.NewEncoder(stdout).Encode(resolveView{
DateKey: d.Compact(), Profile: d.ProfileID, Round: d.Round,
Requested: t.Format(time.RFC3339Nano), UnlockAt: d.UnlockAt(p).Format(time.RFC3339),
})
}
func profileHash(args []string, stdout io.Writer) error {
fs := newFlags("profile hash")
in := fs.String("in", "", "Deterministic CBOR profile file; default: the pinned Quicknet profile")
if err := parse(fs, args); err != nil {
return err
}
p := profile.Quicknet()
if *in != "" {
b, err := os.ReadFile(*in)
if err != nil {
return err
}
if p, err = profile.Decode(b); err != nil {
return err
}
}
b, err := p.CanonicalCBOR()
if err != nil {
return err
}
h, err := p.Hash()
if err != nil {
return err
}
pinned := *in == "" || hex.EncodeToString(h[:]) == profile.QuicknetProfileHash
return json.NewEncoder(stdout).Encode(map[string]any{
"profile_id": p.ID,
"profile_hash": hex.EncodeToString(h[:]),
"canonical_cbor": hex.EncodeToString(b),
"pinned": pinned,
})
}

@ -0,0 +1,246 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
const fixtures = "../../testdata/fixtures"
// relay serves the known Quicknet releases like a drand HTTP relay.
func relay(t *testing.T) string {
t.Helper()
p := profile.Quicknet()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, round := range testkit.Rounds {
if r.URL.Path == fmt.Sprintf("/v2/chains/%s/rounds/%d", p.ChainHashHex(), round) {
fmt.Fprintf(w, `{"round":%d,"signature":"%s"}`, round, hex.EncodeToString(testkit.Release(round).Signature))
return
}
}
http.NotFound(w, r)
}))
t.Cleanup(s.Close)
return s.URL
}
func cli(t *testing.T, now time.Time, args ...string) (string, string, error) {
t.Helper()
var out, errOut bytes.Buffer
err := run(args, &out, &errOut, func() time.Time { return now })
return out.String(), errOut.String(), err
}
var later = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
func TestOutputNotPublishedOnFailureOrOverwrite(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "output")
err := writeAtomic(out, func(w io.Writer) error {
_, _ = w.Write([]byte("partial plaintext"))
return errors.New("invalid authentication tag")
})
if err == nil {
t.Fatal("expected failure")
}
if _, err := os.Stat(out); !errors.Is(err, os.ErrNotExist) {
t.Fatal("published partial output")
}
if err := os.WriteFile(out, []byte("keep me"), 0o600); err != nil {
t.Fatal(err)
}
if err := writeAtomic(out, func(io.Writer) error { t.Fatal("should not run"); return nil }); err == nil {
t.Fatal("overwrote output")
}
if b, _ := os.ReadFile(out); string(b) != "keep me" {
t.Fatal("output changed")
}
if files, _ := filepath.Glob(filepath.Join(dir, ".datekeys-*")); len(files) != 0 {
t.Fatal("temporary file left behind")
}
if err := copyExclusive(out, out); err == nil {
t.Fatal("exclusive copy replaced a file")
}
}
func TestDecryptFixtures(t *testing.T) {
url := relay(t)
for _, tc := range []struct{ name, dkk string }{
{"time_only", ""},
{"time_only_extensions", ""},
{"empty_payload", ""},
{"time_and_key_portable", "time_and_key_portable.dkk"},
} {
t.Run(tc.name, func(t *testing.T) {
out := filepath.Join(t.TempDir(), "plain")
args := []string{"decrypt", "-in", filepath.Join(fixtures, tc.name+".dkc"), "-out", out, "-relay", url}
if tc.dkk != "" {
args = append(args, "-dkk", filepath.Join(fixtures, tc.dkk))
}
if _, stderr, err := cli(t, later, args...); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
got, _ := os.ReadFile(out)
want, _ := os.ReadFile(filepath.Join(fixtures, tc.name+".plaintext"))
if !bytes.Equal(got, want) {
t.Fatal("plaintext differs")
}
})
}
}
func TestDecryptWithIdentityFile(t *testing.T) {
var f testkit.DKCFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, "time_and_key_recipients.json"), &f); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
key := filepath.Join(dir, "key.txt")
os.WriteFile(key, []byte("# test identity\n"+f.Identities[1]+"\n"), 0o600)
out := filepath.Join(dir, "plain")
if _, stderr, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, f.File), "-out", out, "-identity", key, "-relay", relay(t)); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
}
func TestDecryptFailuresLeaveNothing(t *testing.T) {
dir := t.TempDir()
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
bad := filepath.Join(dir, "bad.dkc")
b[len(b)-1] ^= 1
os.WriteFile(bad, b, 0o600)
out := filepath.Join(dir, "plain")
_, _, err := cli(t, later, "decrypt", "-in", bad, "-out", out, "-relay", relay(t))
if !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("got %v", err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("left files behind: %v", entries)
}
// time_and_key without credentials fails before contacting any relay.
_, _, err = cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "time_and_key_portable.dkc"), "-out", out, "-relay", "http://127.0.0.1:1")
if !errors.Is(err, datekeys.ErrAccessRequired) {
t.Fatalf("got %v", err)
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "secret.txt")
os.WriteFile(in, []byte("round trip through the CLI"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
x, _ := age.GenerateX25519Identity()
key := filepath.Join(dir, "x.txt")
os.WriteFile(key, []byte(x.String()+"\n"), 0o600)
dkc, dkk := filepath.Join(dir, "s.dkc"), filepath.Join(dir, "s.dkk")
_, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc,
"-policy", "time_and_key", "-recipient", x.Recipient().String(), "-dkk", dkk)
if err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if !strings.Contains(stderr, "round 1000") || strings.Contains(stderr, "not post-quantum") {
t.Fatalf("unexpected report:\n%s", stderr)
}
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc); err == nil {
t.Fatal("overwrote an existing capsule")
}
stdout, _, err := cli(t, later, "inspect", "-in", dkc, "-json")
if err != nil {
t.Fatal(err)
}
var v inspectView
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" {
t.Fatalf("inspect: %+v %v", v, err)
}
for i, extra := range [][]string{{"-dkk", dkk}, {"-identity", key}} {
out := filepath.Join(dir, fmt.Sprintf("out%d", i))
args := append([]string{"decrypt", "-in", dkc, "-out", out, "-relay", relay(t)}, extra...)
if _, stderr, err := cli(t, later, args...); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
if b, _ := os.ReadFile(out); string(b) != "round trip through the CLI" {
t.Fatal("plaintext differs")
}
}
}
func TestInspectReportsFailures(t *testing.T) {
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
b = bytes.Replace(b, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1)
path := filepath.Join(t.TempDir(), "bad.dkc")
os.WriteFile(path, b, 0o600)
stdout, _, err := cli(t, later, "inspect", "-in", path)
if !errors.Is(err, datekeys.ErrRoundMismatch) || !strings.Contains(stdout, "[FAIL] step 8") {
t.Fatalf("%v\n%s", err, stdout)
}
stdout, _, err = cli(t, later, "inspect", "-in", filepath.Join(fixtures, "time_only.dkc"))
if err != nil || !strings.Contains(stdout, "valid before unlock") {
t.Fatalf("%v\n%s", err, stdout)
}
}
func TestResolveAndProfile(t *testing.T) {
stdout, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01T00:00:00Z")
if err != nil || !strings.Contains(stdout, `"round":66884212`) || !strings.Contains(stdout, `"unlock_at":"2030-01-01T00:00:00Z"`) {
t.Fatalf("%v %s", err, stdout)
}
if _, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01 00:00"); err == nil {
t.Fatal("accepted a time without zone")
}
stdout, _, err = cli(t, later, "profile", "hash")
if err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) || !strings.Contains(stdout, `"pinned":true`) {
t.Fatalf("%v %s", err, stdout)
}
b, _ := profile.Quicknet().CanonicalCBOR()
path := filepath.Join(t.TempDir(), "q.cbor")
os.WriteFile(path, b, 0o600)
if stdout, _, err = cli(t, later, "profile", "hash", "-in", path); err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) {
t.Fatalf("%v %s", err, stdout)
}
}
func TestUsage(t *testing.T) {
for _, args := range [][]string{nil, {"nope"}, {"datekey"}, {"profile", "x"}, {"encrypt", "-bogus"}, {"inspect", "extra"}} {
if _, _, err := cli(t, later, args...); err == nil {
t.Errorf("%v accepted", args)
}
}
}
// Spec §53: long horizons get the harvest-now, decrypt-later warning.
func TestLongHorizonWarning(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "in")
os.WriteFile(in, []byte("x"), 0o600)
for i, tc := range []struct {
after time.Duration
warn bool
}{{time.Hour, false}, {2 * 365 * 24 * time.Hour, true}} {
out := filepath.Join(dir, fmt.Sprintf("%d.dkc", i))
_, stderr, err := cli(t, later, "encrypt", "-at", later.Add(tc.after).Format(time.RFC3339), "-in", in, "-out", out)
if err != nil || strings.Contains(stderr, "not post-quantum") != tc.warn {
t.Fatalf("%s: %v: %s", tc.after, err, stderr)
}
}
}

@ -0,0 +1,186 @@
// Copyright (c) 2017 Takatoshi Nakagawa
// Copyright (c) 2019 The age Authors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// Package bech32 is a modified version of the reference implementation of BIP173.
//
// Provenance: copied verbatim from filippo.io/age v1.3.2, internal/bech32,
// under the license above, so that raw X25519 keys stored in .dkk files
// (spec §38) can be converted to and from the Bech32 forms accepted by the
// public age API without diverging from age. This is an encoding, not
// cryptography. Do not modify; resynchronise with upstream instead.
package bech32
import (
"fmt"
"strings"
)
var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
func polymod(values []byte) uint32 {
chk := uint32(1)
for _, v := range values {
top := chk >> 25
chk = (chk & 0x1ffffff) << 5
chk = chk ^ uint32(v)
for i := range 5 {
bit := top >> i & 1
if bit == 1 {
chk ^= generator[i]
}
}
}
return chk
}
func hrpExpand(hrp string) []byte {
h := []byte(strings.ToLower(hrp))
var ret []byte
for _, c := range h {
ret = append(ret, c>>5)
}
ret = append(ret, 0)
for _, c := range h {
ret = append(ret, c&31)
}
return ret
}
func verifyChecksum(hrp string, data []byte) bool {
return polymod(append(hrpExpand(hrp), data...)) == 1
}
func createChecksum(hrp string, data []byte) []byte {
values := append(hrpExpand(hrp), data...)
values = append(values, []byte{0, 0, 0, 0, 0, 0}...)
mod := polymod(values) ^ 1
ret := make([]byte, 6)
for p := range ret {
shift := 5 * (5 - p)
ret[p] = byte(mod>>shift) & 31
}
return ret
}
func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) {
var ret []byte
acc := uint32(0)
bits := byte(0)
maxv := byte(1<<tobits - 1)
for idx, value := range data {
if value>>frombits != 0 {
return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits)
}
acc = acc<<frombits | uint32(value)
bits += frombits
for bits >= tobits {
bits -= tobits
ret = append(ret, byte(acc>>bits)&maxv)
}
}
if pad {
if bits > 0 {
ret = append(ret, byte(acc<<(tobits-bits))&maxv)
}
} else if bits >= frombits {
return nil, fmt.Errorf("illegal zero padding")
} else if byte(acc<<(tobits-bits))&maxv != 0 {
return nil, fmt.Errorf("non-zero padding")
}
return ret, nil
}
// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase,
// the output will be uppercase.
func Encode(hrp string, data []byte) (string, error) {
values, err := convertBits(data, 8, 5, true)
if err != nil {
return "", err
}
if len(hrp) < 1 {
return "", fmt.Errorf("invalid HRP: %q", hrp)
}
for p, c := range hrp {
if c < 33 || c > 126 {
return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c)
}
}
if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp {
return "", fmt.Errorf("mixed case HRP: %q", hrp)
}
lower := strings.ToLower(hrp) == hrp
hrp = strings.ToLower(hrp)
var ret strings.Builder
ret.WriteString(hrp)
ret.WriteString("1")
for _, p := range values {
ret.WriteByte(charset[p])
}
for _, p := range createChecksum(hrp, values) {
ret.WriteByte(charset[p])
}
if lower {
return ret.String(), nil
}
return strings.ToUpper(ret.String()), nil
}
// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase.
func Decode(s string) (hrp string, data []byte, err error) {
if strings.ToLower(s) != s && strings.ToUpper(s) != s {
return "", nil, fmt.Errorf("mixed case")
}
pos := strings.LastIndex(s, "1")
if pos < 1 || pos+7 > len(s) {
return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s))
}
hrp = s[:pos]
for p, c := range hrp {
if c < 33 || c > 126 {
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
}
}
for p, c := range s[pos+1:] {
// Fold ASCII explicitly. Unicode case folding can turn a non-ASCII
// rune into a shorter valid charset member.
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
d := strings.IndexRune(charset, c)
if d == -1 {
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
}
data = append(data, byte(d))
}
if len(data) < 6 {
return "", nil, fmt.Errorf("data part too short")
}
if !verifyChecksum(hrp, data) {
return "", nil, fmt.Errorf("invalid checksum")
}
data, err = convertBits(data[:len(data)-6], 5, 8, false)
if err != nil {
return "", nil, err
}
return hrp, data, nil
}

@ -0,0 +1,115 @@
// Copyright (c) 2013-2017 The btcsuite developers
// Copyright (c) 2016-2017 The Lightning Network Developers
// Copyright (c) 2019 The age Authors
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package bech32_test
import (
"strings"
"testing"
"github.com/datekeys/datekeys-go/codec/bech32"
)
func TestBech32(t *testing.T) {
tests := []struct {
str string
valid bool
}{
{"A12UEL5L", true}, // empty
{"a12uel5l", true},
{"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true},
{"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true},
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true},
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true},
// invalid checksum
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false},
// invalid character (space) in hrp
{"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false},
{"split1cheo2y9e2w", false}, // invalid character (o) in data part
{"split1a2y9w", false}, // too short data part
{"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp
// invalid character (DEL) in hrp
{"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false},
// long vectors that we do accept despite the spec, see Issue 453
{"long10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qfcsvr0", true},
{"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", true},
// BIP 173 invalid vectors.
{"pzry9x0s0muk", false},
{"1pzry9x0s0muk", false},
{"x1b4n0q5v", false},
{"li1dgmt3", false},
{"de1lg7wt\xff", false},
{"A1G7SGD8", false},
{"10a06t8", false},
{"1qzzfhee", false},
}
for _, test := range tests {
str := test.str
hrp, decoded, err := bech32.Decode(str)
if !test.valid {
// Invalid string decoding should result in error.
if err == nil {
t.Errorf("expected decoding to fail for invalid string %v", test.str)
}
continue
}
// Valid string decoding should result in no error.
if err != nil {
t.Errorf("expected string to be valid bech32: %v", err)
}
// Check that it encodes to the same string.
encoded, err := bech32.Encode(hrp, decoded)
if err != nil {
t.Errorf("encoding failed: %v", err)
}
if encoded != str {
t.Errorf("expected data to encode to %v, but got %v", str, encoded)
}
// Flip a bit in the string an make sure it is caught.
pos := strings.LastIndexAny(str, "1")
flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:]
if _, _, err = bech32.Decode(flipped); err == nil {
t.Error("expected decoding to fail")
}
}
}
func TestDecodeShortDataPart(t *testing.T) {
kelvin := string(rune(0x212A))
for _, s := range []string{
"AA3100AC" + kelvin,
"BK1" + kelvin + "0JFM",
"AQM1KZCML",
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Decode(%+q) panicked: %v", s, r)
}
}()
if _, _, err := bech32.Decode(s); err == nil {
t.Errorf("Decode(%+q) = nil error, want error", s)
}
}()
}
}

@ -0,0 +1,126 @@
// Package codec implements the Deterministic CBOR rules of spec §58 and §58.1.
//
// Encoding uses RFC 8949 §4.2.1 Core Deterministic Encoding. Decoding is
// strict (no indefinite lengths, no tags, no duplicate keys, bounded depth and
// sizes, valid UTF-8, no unknown struct fields) and is always followed by a
// re-encoding that must reproduce the input byte for byte. Any difference is
// ErrNonCanonicalCBOR. The same principle as dk1_ canonicality (spec §19):
// canonicality does not depend on a library promising to reject every
// non-canonical form.
package codec
import (
"bytes"
"fmt"
"github.com/fxamacker/cbor/v2"
datekeys "github.com/datekeys/datekeys-go"
)
// Decoding limits. Structural sizes are additionally bounded by the framing
// limits of spec §57 before any CBOR is decoded.
const (
MaxNestedLevels = 16
MaxArrayElements = 65536
MaxMapPairs = 65536
)
var (
encMode = must(cbor.CoreDetEncOptions().EncMode())
decMode = must(decOptions(true).DecMode())
peekMode = must(decOptions(false).DecMode())
)
// decOptions returns the strict decoding options. Peek mode ignores unknown
// map keys; the canonical mode reports them.
func decOptions(strict bool) cbor.DecOptions {
o := cbor.DecOptions{
DupMapKey: cbor.DupMapKeyEnforcedAPF,
IndefLength: cbor.IndefLengthForbidden,
TagsMd: cbor.TagsForbidden,
MaxNestedLevels: MaxNestedLevels,
MaxArrayElements: MaxArrayElements,
MaxMapPairs: MaxMapPairs,
UTF8: cbor.UTF8RejectInvalid,
MapKeyByteString: cbor.MapKeyByteStringAllowed,
}
if strict {
o.ExtraReturnErrors = cbor.ExtraDecErrorUnknownField
}
return o
}
// must accepts only the static options above, which cannot be invalid.
func must[T any](m T, err error) T {
if err != nil {
panic("codec: invalid static options: " + err.Error())
}
return m
}
// Marshal returns the core deterministic CBOR encoding of v.
func Marshal(v any) ([]byte, error) {
b, err := encMode.Marshal(v)
if err != nil {
return nil, fmt.Errorf("codec: encode: %w", err)
}
return b, nil
}
// Unmarshal decodes exactly one CBOR data item from data into v, which must be
// a pointer, and then requires that re-encoding v reproduces data exactly.
// Every failure wraps datekeys.ErrNonCanonicalCBOR.
//
// Fields of type cbor.RawMessage are copied verbatim and are NOT covered by the
// re-encoding check; callers must validate them with Valid.
func Unmarshal(data []byte, v any) error {
if err := decMode.Unmarshal(data, v); err != nil {
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
}
if re, err := encMode.Marshal(v); err != nil || !bytes.Equal(re, data) {
return fmt.Errorf("codec: input is not the deterministic encoding of its value: %w", datekeys.ErrNonCanonicalCBOR)
}
return nil
}
// Peek decodes selected fields of a CBOR map, ignoring every other key and
// without the canonicality check. It exists only to read a type tag and a
// schema version before strict decoding, so that an unknown major version is
// reported as such (spec §70). Its result must never be used as the decoded
// object.
func Peek(data []byte, v any) error {
if err := peekMode.Unmarshal(data, v); err != nil {
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
}
return nil
}
// CheckSchema reads key 0 (type tag) and key 1 (schema version) of a CBOR map
// and requires the expected values. A different type tag is
// ErrNonCanonicalCBOR; a different version is ErrUnsupportedVersion.
func CheckSchema(data []byte, typeTag string, version uint64) error {
var h struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
}
if err := Peek(data, &h); err != nil {
return err
}
if h.Type != typeTag {
return fmt.Errorf("codec: type %q, want %q: %w", h.Type, typeTag, datekeys.ErrNonCanonicalCBOR)
}
if h.Version != version {
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, h.Version, version, datekeys.ErrUnsupportedVersion)
}
return nil
}
// Valid reports whether data is exactly one well-formed CBOR data item in core
// deterministic encoding. It is used for opaque values the protocol does not
// interpret, such as extension data (spec §54). Tags, the simple value
// undefined and map keys that are arrays or maps are rejected.
func Valid(data []byte) error {
var v any
return Unmarshal(data, &v)
}

@ -0,0 +1,174 @@
package codec_test
import (
"encoding/hex"
"errors"
"math/rand/v2"
"strings"
"testing"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec"
)
type sample struct {
Type string `cbor:"0,keyasint"`
N uint64 `cbor:"1,keyasint"`
Bytes []byte `cbor:"2,keyasint"`
List []uint64 `cbor:"10,keyasint,omitempty"`
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
func TestMarshalIsCoreDeterministic(t *testing.T) {
b, err := codec.Marshal(sample{Type: "x", N: 23, Bytes: []byte{1}, List: []uint64{1, 500}})
if err != nil {
t.Fatal(err)
}
// {0: "x", 1: 23, 2: h'01', 10: [1, 500]} with keys sorted and shortest integers.
if got, want := hex.EncodeToString(b), "a400617801170241010a82011901f4"; got != want {
t.Fatalf("got %s, want %s", got, want)
}
var s sample
if err := codec.Unmarshal(b, &s); err != nil {
t.Fatal(err)
}
}
func TestUnmarshalRejectsNonCanonical(t *testing.T) {
for _, tc := range []struct{ name, hex string }{
{"integer not in shortest form", "a300617801181702410" + "1"},
{"keys out of order", "a301170061780241" + "01"},
{"duplicate key", "a4006178006179011702" + "4101"},
{"indefinite-length map", "bf00617801170241" + "01ff"},
{"indefinite-length byte string", "a3006178011702" + "5f4101ff"},
{"tag", "a3006178011702" + "c24101"},
{"unknown key", "a4006178011702410103" + "00"},
{"missing key", "a2006178011" + "7"},
{"trailing byte", "a30061780117024101" + "00"},
{"invalid UTF-8", "a30061ff0117024101"},
{"empty optional array present", "a400617801170241010a" + "80"},
{"wrong type", "a300617801617a024101"},
{"not a map", "83006178" + "01"},
{"empty input", ""},
} {
t.Run(tc.name, func(t *testing.T) {
var s sample
err := codec.Unmarshal(mustHex(t, tc.hex), &s)
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("got %v, want ErrNonCanonicalCBOR", err)
}
})
}
}
func TestValid(t *testing.T) {
for _, h := range []string{
"00", "17", "1818", "20", "3bffffffffffffffff", "40", "60", "80", "a0", "f4", "f5", "f6",
"f97e00", "f93c00", "fa47c35000", "a2016161026162", "a1416101", "8201820203",
} {
if err := codec.Valid(mustHex(t, h)); err != nil {
t.Errorf("%s rejected: %v", h, err)
}
}
for _, h := range []string{
"1817", // 23 encoded in two bytes
"f7", // undefined
"fb3ff0000000000000", // 1.0 as float64 instead of float16
"fa7fc00000", // NaN not in the canonical f97e00 form
"a2026162016161", // keys out of order
"c101", // tag
"9f01ff", // indefinite-length array
"a1810101", // array as map key
"0000", // two items
strings.Repeat("81", codec.MaxNestedLevels+4) + "00", // nesting beyond the limit
} {
if err := codec.Valid(mustHex(t, h)); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s accepted or wrong error: %v", h, err)
}
}
}
func TestCheckSchema(t *testing.T) {
b, _ := codec.Marshal(sample{Type: "datekeycap", N: 1, Bytes: []byte{}})
if err := codec.CheckSchema(b, "datekeycap", 1); err != nil {
t.Fatal(err)
}
if err := codec.CheckSchema(b, "datekeys-control", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("type confusion: %v", err)
}
if err := codec.CheckSchema(b, "datekeycap", 2); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("version: %v", err)
}
// A future version with unknown keys still reports the version.
future, _ := codec.Marshal(map[uint64]any{0: "datekeycap", 1: uint64(2), 99: "new"})
if err := codec.CheckSchema(future, "datekeycap", 1); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("future version: %v", err)
}
}
func TestRoundTripProperty(t *testing.T) {
r := rand.New(rand.NewPCG(1, 2))
for range 2000 {
s := sample{Type: string(rune('a' + r.IntN(26))), N: r.Uint64() >> r.IntN(64), Bytes: make([]byte, r.IntN(40))}
for range r.IntN(4) {
s.List = append(s.List, r.Uint64()>>r.IntN(64))
}
b, err := codec.Marshal(s)
if err != nil {
t.Fatal(err)
}
var got sample
if err := codec.Unmarshal(b, &got); err != nil {
t.Fatalf("%x: %v", b, err)
}
b2, _ := codec.Marshal(got)
if string(b) != string(b2) {
t.Fatal("encoding is not stable")
}
}
}
func TestErrorsCarryTheNormativeCode(t *testing.T) {
if _, err := codec.Marshal(make(chan int)); err == nil {
t.Fatal("encoded a channel")
}
for _, in := range [][]byte{nil, {0xff}, {0x83, 0x01}, mustHex(t, "a10061")} {
if err := codec.CheckSchema(in, "datekeycap", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("%x: %v", in, err)
}
var v struct {
A uint64 `cbor:"0,keyasint"`
}
if err := codec.Peek(in, &v); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("peek %x: %v", in, err)
}
}
}
func FuzzValid(f *testing.F) {
for _, h := range []string{"a400617801170241010a82011901f4", "f97e00", "a2016161026162", "9f01ff"} {
b, _ := hex.DecodeString(h)
f.Add(b)
}
f.Fuzz(func(t *testing.T, b []byte) {
if codec.Valid(b) != nil {
return
}
var v any
if err := codec.Unmarshal(b, &v); err != nil {
t.Fatalf("Valid accepted what Unmarshal rejects: %v", err)
}
re, err := codec.Marshal(v)
if err != nil || string(re) != string(b) {
t.Fatalf("accepted a non-canonical item %x", b)
}
})
}

@ -0,0 +1,258 @@
// Package datekey implements DateKeys (spec §14-§19): the local resolution of
// an instant to a provider condition and the canonical dk1_ representation.
//
// A DateKey is public. It is not a symmetric key, not a private key, not a
// .dkk and not a secret (spec §14).
package datekey
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/profile"
)
// Prefix and JSON version of the V1 representation (spec §18).
const (
Prefix = "dk1_"
Version = 1
)
// MaxRound is the largest round accepted in a dk1_ string, 2^53-1, so that
// every implementation, including JSON parsers that use IEEE 754 doubles,
// reads the same integer. Profiles impose a lower bound through
// profile.Profile.MaxRound.
const MaxRound = 1<<53 - 1
// MaxEncodedLen bounds the input accepted by Parse, checked before decoding.
const MaxEncodedLen = 256
// DateKey is the public descriptor of a time condition: a profile and, for
// Quicknet, a round (spec §9, §14).
type DateKey struct {
ProfileID string
Round uint64
}
// Resolve returns the DateKey of the first round whose round time is at or
// after at (spec §15). The comparison uses the full precision of at: an
// instant one nanosecond after a round boundary resolves to the next round.
// Rounding backwards never happens.
//
// Resolve needs no network. It accepts past instants, which is useful for
// lookups; callers creating new capsules must require a future instant.
func Resolve(p *profile.Profile, at time.Time) (DateKey, error) {
period := int64(p.Period / time.Second)
if period <= 0 || p.Period%time.Second != 0 {
return DateKey{}, fmt.Errorf("datekey: profile %s has no whole-second period: %w", p.ID, datekeys.ErrUnknownProfile)
}
secs := at.Unix()
if secs < p.GenesisTime {
return DateKey{}, fmt.Errorf("datekey: %s is before the genesis of %s: %w",
at.UTC().Format(time.RFC3339Nano), p.ID, datekeys.ErrDateKeyInvalid)
}
if secs > profile.MaxUnixTime {
return DateKey{}, fmt.Errorf("datekey: %s is after 9999-12-31T23:59:59Z: %w",
at.UTC().Format(time.RFC3339Nano), datekeys.ErrDateKeyInvalid)
}
delta := secs - p.GenesisTime
// candidate = floor((timestamp - genesis_time) / period) + 1
candidate := uint64(delta/period) + 1
// if round_time(candidate) < requested_unlock_at: candidate++
// round_time(candidate) is a whole second <= secs, so it is earlier than at
// unless it equals secs and at has no fractional part.
if delta%period != 0 || at.Nanosecond() != 0 {
candidate++
}
d := DateKey{ProfileID: p.ID, Round: candidate}
if err := d.Validate(p); err != nil {
return DateKey{}, err
}
return d, nil
}
// RoundTime returns round_time(r) = genesis_time + (r - 1) * period (spec §15).
func RoundTime(p *profile.Profile, round uint64) (time.Time, error) {
if round == 0 || round > p.MaxRound() {
return time.Time{}, fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
}
period := int64(p.Period / time.Second)
return time.Unix(p.GenesisTime+int64(round-1)*period, 0).UTC(), nil
}
// Validate checks that d belongs to p and that its round is in p's range.
func (d DateKey) Validate(p *profile.Profile) error {
if d.ProfileID != p.ID {
return fmt.Errorf("datekey: profile %q, expected %q: %w", d.ProfileID, p.ID, datekeys.ErrProfileMismatch)
}
if d.Round == 0 || d.Round > p.MaxRound() || d.Round > MaxRound {
return fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", d.Round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
}
return nil
}
// UnlockAt returns the effective unlock time of d under p, or the zero time if
// d is not valid for p.
func (d DateKey) UnlockAt(p *profile.Profile) time.Time {
if d.Validate(p) != nil {
return time.Time{}
}
t, _ := RoundTime(p, d.Round)
return t
}
// CanonicalJSON returns the canonical JSON payload of spec §18, for example
// {"version":1,"network":"datekeys:quicknet:v1","round":66884212}, or nil if
// d is not syntactically valid.
func (d DateKey) CanonicalJSON() []byte {
if !d.valid() {
return nil
}
// ProfileID is restricted to [a-z0-9:._-], so no JSON escaping is needed.
return fmt.Appendf(nil, `{"version":%d,"network":"%s","round":%d}`, Version, d.ProfileID, d.Round)
}
// Compact returns the canonical dk1_ string (spec §18), or "" if d is not
// syntactically valid.
func (d DateKey) Compact() string {
j := d.CanonicalJSON()
if j == nil {
return ""
}
return Prefix + base64.RawURLEncoding.EncodeToString(j)
}
// String returns Compact.
func (d DateKey) String() string { return d.Compact() }
func (d DateKey) valid() bool {
return profile.ValidID(d.ProfileID) && d.Round >= 1 && d.Round <= MaxRound
}
// Parse accepts only the unique canonical dk1_ string of a DateKey (spec §19):
// it decodes Base64URL, parses the JSON, validates the fields, re-emits the
// canonical JSON and dk1_ string and compares them byte for byte with s.
//
// Input that cannot be decoded or holds invalid fields fails with
// ErrDateKeyInvalid; a valid DateKey in any other encoding fails with
// ErrDateKeyNonCanonical. Parse does not check that the profile is known;
// callers look it up in their profile.Registry.
func Parse(s string) (DateKey, error) {
if len(s) > MaxEncodedLen {
return DateKey{}, fmt.Errorf("datekey: input longer than %d bytes: %w", MaxEncodedLen, datekeys.ErrDateKeyInvalid)
}
payload, ok := strings.CutPrefix(s, Prefix)
if !ok {
return DateKey{}, fmt.Errorf("datekey: missing %q prefix: %w", Prefix, datekeys.ErrDateKeyInvalid)
}
raw, err := decodeBase64(payload)
if err != nil {
return DateKey{}, fmt.Errorf("datekey: payload is not Base64URL: %w", datekeys.ErrDateKeyInvalid)
}
d, err := parseJSON(raw)
if err != nil {
return DateKey{}, err
}
if d.Compact() != s {
return DateKey{}, fmt.Errorf("datekey: not the canonical encoding %s: %w", d.Compact(), datekeys.ErrDateKeyNonCanonical)
}
return d, nil
}
// decodeBase64 decodes unpadded Base64URL (spec §18). Padded and standard
// alphabet variants are decoded too, so that they are reported as
// non-canonical rather than invalid; the final comparison rejects them.
func decodeBase64(s string) ([]byte, error) {
var firstErr error
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} {
b, err := enc.DecodeString(s)
if err == nil {
return b, nil
}
if firstErr == nil {
firstErr = err
}
}
return nil, firstErr
}
func parseJSON(raw []byte) (DateKey, error) {
invalid := func(format string, a ...any) error {
return fmt.Errorf("datekey: "+format+": %w", append(a, datekeys.ErrDateKeyInvalid)...)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var obj map[string]any
if err := dec.Decode(&obj); err != nil || obj == nil {
return DateKey{}, invalid("payload is not a JSON object")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return DateKey{}, invalid("trailing data after the JSON object")
}
if len(obj) != 3 {
return DateKey{}, invalid("expected exactly the fields version, network and round")
}
version, ok := jsonUint(obj["version"])
if !ok || version != Version {
return DateKey{}, invalid("unsupported version %v", obj["version"])
}
network, ok := obj["network"].(string)
if !ok || !profile.ValidID(network) {
return DateKey{}, invalid("invalid network %v", obj["network"])
}
round, ok := jsonUint(obj["round"])
if !ok || round == 0 || round > MaxRound {
return DateKey{}, invalid("invalid round %v", obj["round"])
}
return DateKey{ProfileID: network, Round: round}, nil
}
// jsonUint returns the value of a JSON number if it is a non-negative integer
// that fits in uint64, whatever its spelling: 1000, 1000.0, 1e3 and 10E2 all
// yield 1000. Non-canonical spellings are rejected later by the byte
// comparison, as spec §19 prescribes.
func jsonUint(v any) (uint64, bool) {
n, ok := v.(json.Number)
if !ok {
return 0, false
}
lit := string(n)
neg := strings.HasPrefix(lit, "-")
lit = strings.TrimPrefix(lit, "-")
mantissa, exp, hasExp := strings.Cut(strings.ToLower(lit), "e")
intPart, frac, _ := strings.Cut(mantissa, ".")
digits := strings.TrimLeft(intPart+frac, "0")
if digits == "" {
return 0, true // zero, including -0, 0.0 and 0e99999
}
e := int64(0)
if hasExp {
var err error
if e, err = strconv.ParseInt(exp, 10, 16); err != nil {
return 0, false // |exponent| >= 32768 with non-zero digits
}
}
if neg {
return 0, false
}
e -= int64(len(frac))
// digits * 10^e, keeping only integral values.
digits = strings.TrimLeft(digits, "0")
for e < 0 && strings.HasSuffix(digits, "0") {
digits = digits[:len(digits)-1]
e++
}
if e < 0 || int64(len(digits))+e > 20 {
return 0, false
}
u, err := strconv.ParseUint(digits+strings.Repeat("0", int(e)), 10, 64)
return u, err == nil
}

@ -0,0 +1,209 @@
package datekey_test
import (
"encoding/base64"
"errors"
"math/rand/v2"
"reflect"
"testing"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func TestNormativeRoundVector(t *testing.T) {
// Spec §16, stated literally.
p := profile.Quicknet()
d, err := datekey.Resolve(p, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
if err != nil || d.Round != 66884212 {
t.Fatalf("2030-01-01 resolved to %+v, %v", d, err)
}
if got := d.UnlockAt(p).Format(time.RFC3339); got != "2030-01-01T00:00:00Z" {
t.Fatalf("round time %s", got)
}
rt, err := datekey.RoundTime(p, 66432123)
if err != nil || rt.Format(time.RFC3339) != "2029-12-16T07:15:33Z" {
t.Fatalf("round 66432123 at %s, %v", rt, err)
}
if got := d.Compact(); got != "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9" {
t.Fatalf("dk1_ %s", got)
}
if got := string(d.CanonicalJSON()); got != `{"version":1,"network":"datekeys:quicknet:v1","round":66884212}` {
t.Fatalf("canonical JSON %s", got)
}
}
func TestGoldenRoundVectors(t *testing.T) {
var golden testkit.RoundVectorFile
if err := testkit.ReadJSON("../testdata/vectors/quicknet_rounds.json", &golden); err != nil {
t.Fatal(err)
}
if got := testkit.RoundVectors(); !reflect.DeepEqual(got, golden) {
t.Fatalf("round vectors changed:\n got %+v\nwant %+v", got, golden)
}
p := profile.Quicknet()
for _, v := range golden.Vectors {
at, err := time.Parse(time.RFC3339Nano, v.Requested)
if err != nil {
t.Fatal(err)
}
d, err := datekey.Resolve(p, at)
if v.Error != "" {
if datekeys.Code(err) != v.Error {
t.Errorf("%s: got %v, want %s", v.Name, err, v.Error)
}
continue
}
if err != nil || d.Round != v.Round || d.UnlockAt(p).Format(time.RFC3339Nano) != v.Effective {
t.Errorf("%s: got %+v %v", v.Name, d, err)
}
}
}
func TestGoldenDK1Vectors(t *testing.T) {
var golden testkit.DK1VectorFile
if err := testkit.ReadJSON("../testdata/vectors/dk1.json", &golden); err != nil {
t.Fatal(err)
}
if got := testkit.DK1Vectors(); !reflect.DeepEqual(got, golden) {
t.Fatalf("dk1_ vectors changed")
}
for _, v := range golden.Vectors {
if v.DK1 != "" {
d, err := datekey.Parse(v.DK1)
if err != nil || d.ProfileID != v.Network || d.Round != v.Round {
t.Errorf("%s: %+v %v", v.Name, d, err)
}
if string(d.CanonicalJSON()) != v.CanonicalJSON || base64.RawURLEncoding.EncodeToString(d.CanonicalJSON()) != v.Base64URL {
t.Errorf("%s: intermediate encodings differ", v.Name)
}
continue
}
if v.Error == "accepted" {
t.Errorf("%s: a rejected-encoding vector is accepted", v.Name)
}
if _, err := datekey.Parse(v.Input); datekeys.Code(err) != v.Error {
t.Errorf("%s: got %v, want %s", v.Name, err, v.Error)
}
}
}
// Kept from the prototype: resolution never picks a round that opens early.
func TestRoundNeverOpensEarly(t *testing.T) {
p := profile.Quicknet()
g := time.Unix(p.GenesisTime, 0).UTC()
for _, tc := range []struct {
offset time.Duration
want uint64
}{
{0, 1}, {time.Nanosecond, 2}, {time.Second, 2}, {3 * time.Second, 2}, {3*time.Second + time.Nanosecond, 3}, {2997 * time.Second, 1000},
} {
requested := g.Add(tc.offset)
d, err := datekey.Resolve(p, requested)
if err != nil || d.Round != tc.want {
t.Fatalf("offset %s: %+v, %v", tc.offset, d, err)
}
if u := d.UnlockAt(p); u.Before(requested) || u.Sub(requested) >= p.Period {
t.Fatal("unsafe rounding")
}
}
}
func TestResolveProperty(t *testing.T) {
p := profile.Quicknet()
r := rand.New(rand.NewPCG(3, 4))
for range 20000 {
secs := p.GenesisTime + r.Int64N(profile.MaxUnixTime-p.GenesisTime-3)
at := time.Unix(secs, r.Int64N(int64(time.Second)))
d, err := datekey.Resolve(p, at)
if err != nil {
t.Fatalf("%s: %v", at, err)
}
u := d.UnlockAt(p)
// The first round whose time is >= at: never earlier, and the previous
// round is strictly earlier.
if u.Before(at) {
t.Fatalf("%s resolves to round %d at %s, before the request", at, d.Round, u)
}
if d.Round > 1 {
prev, _ := datekey.RoundTime(p, d.Round-1)
if !prev.Before(at) {
t.Fatalf("%s: round %d at %s would already satisfy the request", at, d.Round-1, prev)
}
}
parsed, err := datekey.Parse(d.Compact())
if err != nil || parsed != d {
t.Fatalf("Parse(Compact(d)) != d for %+v: %v", d, err)
}
}
}
func TestTimezoneIndependence(t *testing.T) {
p := profile.Quicknet()
a, _ := time.Parse(time.RFC3339Nano, "2026-10-22T19:00:00.001+02:00")
b, _ := time.Parse(time.RFC3339Nano, "2026-10-22T17:00:00.001Z")
da, _ := datekey.Resolve(p, a)
db, _ := datekey.Resolve(p, b)
if da != db {
t.Fatal("timezone changed the DateKey")
}
}
func TestValidate(t *testing.T) {
p := profile.Quicknet()
if err := (datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 5}).Validate(p); !errors.Is(err, datekeys.ErrProfileMismatch) {
t.Fatalf("other profile: %v", err)
}
for _, r := range []uint64{0, p.MaxRound() + 1} {
if err := (datekey.DateKey{ProfileID: p.ID, Round: r}).Validate(p); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Fatalf("round %d: %v", r, err)
}
}
if !(datekey.DateKey{ProfileID: p.ID, Round: 0}).UnlockAt(p).IsZero() {
t.Fatal("invalid DateKey has an unlock time")
}
if (datekey.DateKey{ProfileID: `bad"id`, Round: 1}).Compact() != "" {
t.Fatal("invalid DateKey has a dk1_ form")
}
if _, err := datekey.Resolve(p, time.Time{}); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Fatalf("zero time: %v", err)
}
}
func TestNumberSpellings(t *testing.T) {
enc := func(round string) string {
return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"network":"datekeys:quicknet:v1","round":`+round+`}`))
}
for _, s := range []string{"1000.0", "1e3", "1E3", "10e2", "1000e0", "100000e-2", "0.1e4"} {
if _, err := datekey.Parse(enc(s)); !errors.Is(err, datekeys.ErrDateKeyNonCanonical) {
t.Errorf("%s: %v, want non-canonical", s, err)
}
}
for _, s := range []string{"1.5", "-1000", "1e-3", "1e400", "18446744073709551616", "0", "-0", "0e5"} {
if _, err := datekey.Parse(enc(s)); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Errorf("%s: %v, want invalid", s, err)
}
}
}
func FuzzParse(f *testing.F) {
d := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
f.Add(d.Compact())
f.Add("dk1_invalid")
f.Add(datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"network":"a","round":1e3}`)))
f.Fuzz(func(t *testing.T, s string) {
d, err := datekey.Parse(s)
if err != nil {
if c := datekeys.Code(err); c != "ERR_DATEKEY_INVALID" && c != "ERR_DATEKEY_NON_CANONICAL" {
t.Fatalf("unexpected error %v", err)
}
return
}
if d.Compact() != s {
t.Fatal("accepted a non-canonical DateKey")
}
})
}

@ -0,0 +1,143 @@
# Traceability: DateKeys Protocol Specification v0.8.1 ↔ datekeys-go
This table maps every normative section of the specification to the code that
implements it and to the tests that exercise it. It is updated in the same
change as any normative code, and it is the document handed to the external
reviewer together with the specification, the fixtures and the mutation corpus
(plan §10).
Paths are relative to the repository root. `§` numbers refer to
`spec/DateKeys_Protocol_Specification_v0.8.1.md`.
## Section map
| § | Topic | Implementation | Tests |
|---|---|---|---|
| 3 | Guiding principle: verify locally | `profile.Registry`, `datekey.Resolve`, `provider.Verify`, `capsule.Inspect` | `capsule.TestMutationCorpus` |
| 4 | Security goals | whole module | whole suite |
| 7 | Threat model | creator model in `internal/testkit.Build`, `RewriteAge`; third-party edits in the mutation corpus | `agewrap.TestTimeIdentityStrictness`, `TestPayloadIdentityStrictness`, `TestAccessIdentityStrictness`, `capsule.TestMutationCorpus` |
| 9 | Provider abstraction | `provider.Condition`, `provider.Release`, `provider.ReleaseSource` | `provider/*` |
| 10 | Provider Profile | `profile.Profile`, `Profile.Validate` | `profile.TestValidateRejectsTamperedProfiles` |
| 11 | Canonical profile encoding, `profile_hash` | `Profile.CanonicalCBOR`, `Profile.Hash`, `profile.Decode` | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json` |
| 12 | Quicknet Provider Profile V1 | `profile.Quicknet`, `profile.Quicknet*` constants | `profile.TestQuicknetMatchesGoldenVector` |
| 13 | Root of trust | `profile.NewRegistry`, `profile.Pin`, `profile.Default`, `QuicknetProfileHash`; chain-hash self-check in `Profile.Validate` | `profile.TestRegistry`; mutations *unknown profile*, *empty registry* |
| 14 | DateKey | `datekey.DateKey` | `datekey/*` |
| 15 | Date → round resolution | `datekey.Resolve`, `datekey.RoundTime` | `datekey.TestGoldenRoundVectors`, `TestRoundNeverOpensEarly`, `TestResolveProperty`, `TestTimezoneIndependence` |
| 16 | Normative round vector | — | `datekey.TestNormativeRoundVector`; `testdata/vectors/quicknet_rounds.json` |
| 17 | Past-round attack | `provider.Verify` (round equality), `capsule.Encrypt` (round time ≥ requested), `agewrap.CheckTimeStanzas` | `provider.TestVerifyRejects`; mutations *DateKey A + release of round B*, *tlock stanza round differs from DateKey.round* |
| 18 | `dk1_` representation | `DateKey.CanonicalJSON`, `DateKey.Compact` | `datekey.TestGoldenDK1Vectors`, `TestNormativeRoundVector` |
| 19 | `dk1_` canonicality | `datekey.Parse` | `datekey.TestGoldenDK1Vectors`, `TestNumberSpellings`, `FuzzParse`; mutation *non-canonical dk1_ JSON*; `testdata/vectors/dk1.json` |
| 20 | File extensions and magic | magic checks in `capsule.ParsePrelude`, `accesskey.Decode` | mutation *a .dkk offered as a .dkc*; `accesskey.TestDecodeRejects` *a .dkc* |
| 21 | `capsule_id` | `capsule.Encrypt` (16 bytes from `crypto/rand`), `capsule.DecodeHeader` | `capsule.TestPortableKeysAreNeverReused` |
| 22 | `.dkc` framing | `capsule.Prelude`, `capsule.ParsePrelude` | mutations *version changed*, *flags != 0*, *reserved != 0*, *magic*, length limits; `capsule.FuzzParsePrelude` |
| 23 | PRELUDE | `Prelude.Bytes` | `capsule.TestConformanceFixtures` |
| 24 | PUBLIC_HEADER | `capsule.Header`, `EncodeHeader`, `DecodeHeader` | `capsule.TestConformanceFixtures`, `FuzzDecodeHeader`; mutations *header schema version changed*, *unknown key in PUBLIC_HEADER* |
| 25 | Declared access policy | `capsule.Policy`; `capsule.Open` step 12 | mutations *access_policy=… with … structure* (four cases), *undefined access_policy* |
| 26 | Header binding | `capsule.HeaderBinding`; `capsule.Open` step 15 | `capsule.TestConformanceFixtures`; mutation *PUBLIC_HEADER_A + SEALED_CONTROL_B* |
| 27 | Pre-unlock validation | `capsule.Inspect` (steps 1–8), `agewrap.Stanzas` probe | `capsule.TestMutationCorpus` (no release request for any pre-unlock failure), `FuzzInspect` |
| 28 | Three age files | `capsule.Encrypt`, `capsule.Open` | `capsule.TestEncryptRoundTripBothPolicies` |
| 29 | PAYLOAD_AGE | `capsule.Encrypt` step 4; `agewrap.PayloadIdentity`, `agewrap.CheckPayloadStanzas` | `agewrap.TestPayloadIdentityStrictness`; mutation *extra stanza in PAYLOAD_AGE* |
| 30 | PAYLOAD_AGE is a complete age file | `filippo.io/age` public API only | `capsule.TestInteropAgeOpensPayload` (`-tags interop`, official `age` CLI) |
| 30.1 | CONTROL_CBOR ↔ PAYLOAD_AGE binding | `agewrap.PayloadIdentity` | mutation *SEALED_CONTROL_A + PAYLOAD_AGE_B*; `agewrap.TestPayloadIdentityStrictness` |
| 31 | CONTROL_CBOR | `capsule.Control`, `EncodeControl`, `DecodeControl` | `capsule.TestConformanceFixtures`, `FuzzDecodeControl`; mutation *unknown critical CONTROL_CBOR extension* |
| 32 | `time_only` | `capsule.Encrypt`; `agewrap.TimeRecipient` | fixtures `time_only*`, `empty_payload`; `capsule.TestInteropTleOpensSealedControl` (`-tags interop`, official `tle` CLI) |
| 33 | `time_and_key` | `capsule.Encrypt` (`seal`); `agewrap.AccessIdentity` | fixtures `time_and_key_*`; `capsule.TestEncryptRoundTripBothPolicies` |
| 34 | SEALED_CONTROL | `capsule.Encrypt`; `capsule.Open` step 11 | `capsule.TestConformanceFixtures` |
| 35 | tlock strict mode | `agewrap.TimeRecipient`, `agewrap.TimeIdentity` (pinned parameters only, exact stanza arguments) | `agewrap.TestTimeIdentityStrictness`, `TestInteroperabilityWithTlockLibrary`, `TestTimeIdentityRelease` |
| 36 | Policy ↔ structure | `capsule.Open` step 12, `agewrap.CheckAccessStanzas` | mutations *access_policy=…* (four cases), *non-X25519 stanza in INNER_ACCESS_AGE* |
| 36.1 | Authenticity semantics | documented in `README.md`, `SECURITY.md` | — (a property the protocol does not provide) |
| 37 | X25519 recipient V1 | `age.X25519Recipient`; `agewrap.X25519IdentityFromRaw` | `agewrap.TestRawKeys` |
| 38 | Portable Access Key | `EncryptOptions.NewPortableKey` (fresh `I_ACCESS` per capsule; no API accepts an existing one); `accesskey.AccessKey` | `capsule.TestPortableKeysAreNeverReused` |
| 39 | Multiple recipients | `capsule.Encrypt`; `agewrap.AccessIdentity` | `capsule.TestFixtureRecipients`, `TestEncryptRoundTripBothPolicies` |
| 40 | `.dkk` framing | `accesskey.Encode`, `accesskey.Decode` | `accesskey.TestDecodeRejects`, `FuzzDecode` |
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` | `accesskey.TestFixtures` |
| 42 | `credential_id` | `capsule.Encrypt` (16 bytes from `crypto/rand`) | `capsule.TestPortableKeysAreNeverReused` |
| 43 | `verification_metadata` | `accesskey.Verification`; `capsule.Open` (`checkCapsuleDigest`, seekable readers) | `accesskey.TestDecodeRejects` *empty verification map*; mutation *capsule_digest of the .dkk does not match* |
| 44 | Application extensions in `.dkk` | `AccessKey.Critical/Noncritical`; `capsule.Open` (`checkAccessKey`) | `accesskey.TestEncodeRejectsAbsenceAsEmptyMap` |
| 45 | Release API | `provider.ReleaseSource` interface only (server out of scope, plan §2) | — |
| 46 | Release Queue | out of scope (server) | — |
| 47 | Release Cache | every release is verified again: `capsule.Open` step 10 and `agewrap.TimeIdentity` | mutations *release of another round* |
| 48 | Multi-relay | `provider/drand.Client` (race, first *verified* release wins) | `drand.TestRaceWaitsForAValidSignature` |
| 49 | Direct recovery from the provider | `provider/drand` | `drand.TestLiveRelays`, `capsule.TestLiveLifecycle` (`-tags integration`) |
| 50 | Historical release dependency | documented in `README.md` | — |
| 51 | Quicknet release verification | `provider.Verify` | `provider.TestVerifyPublishedReleases`, `TestVerifyRejects`, `TestVerifyUsesThePinnedKeyOnly` |
| 52 | DNS / MITM | `provider/drand` (no redirects, bounded responses, BLS) | `drand.TestRedirectsAreNotFollowed`, `TestRejectMalformedRelayResponses`, `TestRandomnessMustMatchWhenPresent` |
| 53 | Harvest now, decrypt later | `cmd/datekeys` warning beyond one year | `cmd/datekeys.TestLongHorizonWarning` |
| 54 | Extensions | `extension` | `extension/*`; mutations *unknown critical … extension*; `capsule.TestKnownCriticalExtensions` |
| 55 | Auxiliary integrity | `capsule_digest` treated as UX only | — |
| 56 | Atomic plaintext output | `capsule.Open` contract; `cmd/datekeys.writeAtomic` | `cmd/datekeys.TestOutputNotPublishedOnFailureOrOverwrite`, `TestDecryptFailuresLeaveNothing` |
| 57 | Parser limits | `capsule.MaxPublicHeaderLen`, `MaxSealedControlLen`, `accesskey.MaxBodyLen`, `codec` limits | mutations *…_LEN above the limit*; `accesskey.TestDecodeRejects` *body length above the limit* |
| 58 | Canonical CBOR | `codec.Marshal`, `codec.Unmarshal` (re-encoding comparison), `codec.Valid` | `codec.TestUnmarshalRejectsNonCanonical`, `TestValid`, `TestRoundTripProperty`, `FuzzValid` |
| 58.1 | Absent optional fields are omitted | `extension.Encode` (nil for empty), re-encoding check, `accesskey` verification map | `codec` *empty optional array present*; `accesskey` *empty extension array*, *empty verification map*, *null verification* |
| 59 | Supply-chain security | pinned `go.mod`/`go.sum`, `.github/workflows`, `.goreleaser.yaml`, `SECURITY.md` | CI jobs `vuln`, `sbom`, `verify` |
| 60 | Conceptual Go interfaces | `provider.ReleaseSource`, `provider.Verify`, `datekey.Resolve`, `datekey.RoundTime` | — |
| 61 | `time_only` encryption flow | `capsule.Encrypt` (steps numbered in comments) | `capsule.TestEncryptRoundTripBothPolicies` |
| 62 | `time_and_key` encryption flow | `capsule.Encrypt` | `capsule.TestEncryptRoundTripBothPolicies`, `TestPortableKeysAreNeverReused` |
| 63 | Decryption flow | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18), MUST rules inside `agewrap` identities | `capsule.TestConformanceFixtures` (stage by stage), `TestMutationCorpus` |
| 64 | Mandatory mutation tests | `capsule/mutation_test.go` | `capsule.TestMutationCorpus`: the 20 listed mutations plus 25 more |
| 65 | Quicknet vectors | `internal/testkit.RoundVectors` | `datekey.TestGoldenRoundVectors` |
| 66 | `dk1_` vectors | `internal/testkit.DK1Vectors` | `datekey.TestGoldenDK1Vectors` |
| 67 | `.dkc` vectors | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures` | `capsule.TestConformanceFixtures` |
| 68 | `.dkk` vectors | `testdata/fixtures/*.dkk` + `*.dkk.json` | `accesskey.TestFixtures` |
| 69 | Normative errors | `errors.go` | `datekeys.TestCatalogueMatchesSpec`, `TestCode` |
| 70 | Compatibility | magic and version checks, `codec.CheckSchema` | mutations; `codec.TestCheckSchema` |
| 71 | Profile registry | `profile.Decode` + `profile.NewRegistry` with pinned hashes | `profile.TestRegistry` |
| 72 | Extension registry | `extension.Registry`, `extension.Set` | `capsule.TestKnownCriticalExtensions` |
| 75 | Blocking requirements before v1.0 | items 1–9 above; item 10 (external review) pending | — |
## Error mapping
Where the specification does not name the error of a failure, the reference
implementation uses the following mapping. Each entry is a reproducible case
under the change policy of §76.
| Failure | Error |
|---|---|
| Bytes that are not the deterministic encoding of a valid schema instance: malformed CBOR, non-canonical encoding, unknown key, missing key, wrong type, wrong type tag (key 0), wrong field length, undefined `access_policy`, empty optional array or map, extension rules | `ERR_NON_CANONICAL_CBOR` |
| Schema version (key 1) other than 1 | `ERR_UNSUPPORTED_VERSION` |
| Truncated framing, length fields beyond the §57 limits, data after BODY_CBOR, malformed or unauthenticated age data, truncated or modified STREAM, trailing data after PAYLOAD_AGE, a PAYLOAD_AGE that I_PAYLOAD cannot open | `ERR_INTEGRITY` |
| Stanza count or type violations in OUTER_TIME_AGE, PAYLOAD_AGE or INNER_ACCESS_AGE, including two stanzas for one recipient | `ERR_POLICY_STRUCTURE_MISMATCH` |
| tlock stanza round argument not exactly the canonical decimal DateKey round | `ERR_ROUND_MISMATCH` |
| tlock stanza chain hash not exactly the lowercase hex chain hash of the pinned profile; profile whose parameters do not hash to its chain hash | `ERR_PROFILE_MISMATCH` |
| Instant before the profile genesis or after 9999-12-31T23:59:59Z; round outside the profile range | `ERR_DATEKEY_INVALID` |
| Unknown `access_type`, wrong material length, `.dkk` for another `capsule_id`, `capsule_digest` mismatch, no supplied identity is a recipient | `ERR_ACCESS_INVALID` |
| Round time not reached yet (no request is made), no source delivered the release | `ERR_RELEASE_UNAVAILABLE` |
## Implementation decisions to confirm in the specification
These are choices the reference implementation had to make where v0.8.1 is
silent or provisional (§74). None changes the protocol semantics; each is a
candidate clarification under §76.
1. **Pre-genesis instants.** §15 defines the candidate formula relative to
`genesis_time`; instants before it are rejected with `ERR_DATEKEY_INVALID`
instead of resolving to round 1.
2. **Round bounds.** `dk1_` accepts rounds in 1..2^53−1 so that JSON parsers
based on IEEE 754 doubles read the same integer; a profile further limits
rounds to round times up to 9999-12-31T23:59:59Z (Quicknet: 83 903 165 811).
3. **`profile_id` alphabet.** `[a-z0-9][a-z0-9:._-]{0,127}`, which keeps the
canonical `dk1_` JSON free of escapes and makes its re-emission trivial.
4. **Number spellings in `dk1_`.** JSON numbers are compared by value, so
`1e3` or `1000.0` for 1000 are `ERR_DATEKEY_NON_CANONICAL`, while
non-integers, negatives and out-of-range values are `ERR_DATEKEY_INVALID`.
Padded or standard-alphabet Base64 and non-zero trailing bits are
non-canonical.
5. **Closed maps.** Unknown keys in core maps are rejected; applications use
extensions (§1, §54).
6. **Extension data.** Key 2 is optional and omitted when absent; data must be
deterministic CBOR without tags. `extension_id` is 1 to 256 bytes of UTF-8.
No V1 schema allows repeating an `extension_id`.
7. **One stanza per recipient.** Enforced as far as a recipient can observe it:
no repeated X25519 ephemeral share, and no identity that unwraps more than
one stanza.
8. **Strict tlock stanza arguments.** Exact string comparison, as the tlock
library itself does for the chain hash; a round with leading zeros is a
mismatch.
9. **`capsule_digest`.** Written by `Encrypt` for every portable key and
checked before any request when the capsule reader is seekable; it remains
a UX shortcut (§43).
10. **Creation in the past.** `Encrypt` requires the unlock time to be strictly
after the injected clock.
11. **Clock injection.** No library package reads the wall clock; `Encrypt` and
`Open` require a `Now` function, and `Open` never requests a release for a
round whose time has not been reached.

@ -0,0 +1,91 @@
// Package datekeys is the reference Go implementation of the DateKeys Protocol
// Specification v0.8.1 (spec/DateKeys_Protocol_Specification_v0.8.1.md).
//
// The protocol objects live in subpackages:
//
// - datekey: DateKey resolution and the canonical dk1_ form (spec §14-§19).
// - profile: Provider Profiles and the pinned Quicknet profile (spec §10-§13).
// - provider, provider/drand: release sources and local BLS verification (spec §45-§52).
// - capsule: the DateKeyCap .dkc container (spec §20-§39, §61-§63).
// - accesskey: the DateKeys Access Key .dkk credential (spec §40-§44).
// - extension: the generic extension mechanism (spec §54).
//
// This package holds the normative error catalogue of spec §69. Every protocol
// failure returned by this module wraps exactly one of these sentinels, so
// callers can match them with [errors.Is] and extract the code with [Code].
package datekeys
import "errors"
// Error is a normative DateKeys error (spec §69). Values are compared by
// identity; use [errors.Is] against the exported sentinels.
type Error struct {
code string
}
// Error returns the normative code, for example "ERR_INVALID_MAGIC".
func (e *Error) Error() string { return e.code }
// Code returns the normative code, for example "ERR_INVALID_MAGIC".
func (e *Error) Code() string { return e.code }
// Normative errors, spec §69.
var (
// ErrInvalidMagic: the object does not start with DKC1 or DKK1 (spec §22, §40).
ErrInvalidMagic = &Error{"ERR_INVALID_MAGIC"}
// ErrUnsupportedVersion: an unknown framing or schema version (spec §22, §70).
ErrUnsupportedVersion = &Error{"ERR_UNSUPPORTED_VERSION"}
// ErrInvalidFlags: FLAGS or RESERVED are not zero (spec §22, §40).
ErrInvalidFlags = &Error{"ERR_INVALID_FLAGS"}
// ErrNonCanonicalCBOR: the bytes are not the unique deterministic CBOR
// encoding of a valid instance of the normative schema (spec §58, §58.1).
ErrNonCanonicalCBOR = &Error{"ERR_NON_CANONICAL_CBOR"}
// ErrUnknownProfile: the DateKey names a profile that is not pinned locally (spec §13).
ErrUnknownProfile = &Error{"ERR_UNKNOWN_PROFILE"}
// ErrProfileMismatch: a chain hash or profile does not match the pinned profile (spec §35, §63).
ErrProfileMismatch = &Error{"ERR_PROFILE_MISMATCH"}
// ErrDateKeyInvalid: a DateKey that cannot be decoded or validated (spec §18, §19).
ErrDateKeyInvalid = &Error{"ERR_DATEKEY_INVALID"}
// ErrDateKeyNonCanonical: a valid DateKey in a non-canonical encoding (spec §19).
ErrDateKeyNonCanonical = &Error{"ERR_DATEKEY_NON_CANONICAL"}
// ErrRoundMismatch: a round that differs from the locally resolved one (spec §17, §63).
ErrRoundMismatch = &Error{"ERR_ROUND_MISMATCH"}
// ErrReleaseUnavailable: the release is not published yet or no source delivered it (spec §45-§50).
ErrReleaseUnavailable = &Error{"ERR_RELEASE_UNAVAILABLE"}
// ErrReleaseInvalid: a release that fails local verification (spec §51).
ErrReleaseInvalid = &Error{"ERR_RELEASE_INVALID"}
// ErrAccessRequired: the policy requires an access credential and none was supplied (spec §33).
ErrAccessRequired = &Error{"ERR_ACCESS_REQUIRED"}
// ErrAccessInvalid: the supplied credentials do not open this capsule (spec §33, §38).
ErrAccessInvalid = &Error{"ERR_ACCESS_INVALID"}
// ErrPolicyStructureMismatch: the cryptographic structure does not match the
// declared access policy or the stanza rules of V1 (spec §25, §29, §32, §33, §36).
ErrPolicyStructureMismatch = &Error{"ERR_POLICY_STRUCTURE_MISMATCH"}
// ErrHeaderBinding: header_binding does not match PRELUDE || PUBLIC_HEADER (spec §26).
ErrHeaderBinding = &Error{"ERR_HEADER_BINDING"}
// ErrIntegrity: truncation, corruption or failed authentication of framing or age data (spec §4, §55).
ErrIntegrity = &Error{"ERR_INTEGRITY"}
// ErrExtensionCriticalUnknown: a critical extension this implementation does not know (spec §54).
ErrExtensionCriticalUnknown = &Error{"ERR_EXTENSION_CRITICAL_UNKNOWN"}
)
// All returns every normative error in the order of spec §69.
func All() []*Error {
return []*Error{
ErrInvalidMagic, ErrUnsupportedVersion, ErrInvalidFlags, ErrNonCanonicalCBOR,
ErrUnknownProfile, ErrProfileMismatch, ErrDateKeyInvalid, ErrDateKeyNonCanonical,
ErrRoundMismatch, ErrReleaseUnavailable, ErrReleaseInvalid, ErrAccessRequired,
ErrAccessInvalid, ErrPolicyStructureMismatch, ErrHeaderBinding, ErrIntegrity,
ErrExtensionCriticalUnknown,
}
}
// Code returns the normative code of the first DateKeys error in err's tree,
// or "" if err does not wrap one.
func Code(err error) string {
var e *Error
if errors.As(err, &e) {
return e.code
}
return ""
}

@ -0,0 +1,55 @@
package datekeys_test
import (
"errors"
"fmt"
"os"
"strings"
"testing"
datekeys "github.com/datekeys/datekeys-go"
)
// The catalogue matches spec §69 exactly, in order.
func TestCatalogueMatchesSpec(t *testing.T) {
spec, err := os.ReadFile("spec/DateKeys_Protocol_Specification_v0.8.1.md")
if err != nil {
t.Fatal(err)
}
s := string(spec)
start := strings.Index(s, "## 69. Errores normativos")
end := strings.Index(s, "## 70.")
if start < 0 || end < start {
t.Fatal("section 69 not found")
}
var want []string
for _, line := range strings.Split(s[start:end], "\n") {
if line = strings.TrimSpace(line); strings.HasPrefix(line, "ERR_") {
want = append(want, line)
}
}
var got []string
for _, e := range datekeys.All() {
got = append(got, e.Code())
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("catalogue\n got %v\nwant %v", got, want)
}
}
func TestCode(t *testing.T) {
err := fmt.Errorf("capsule: step 8: %w", fmt.Errorf("agewrap: %w", datekeys.ErrRoundMismatch))
if datekeys.Code(err) != "ERR_ROUND_MISMATCH" || !errors.Is(err, datekeys.ErrRoundMismatch) || errors.Is(err, datekeys.ErrIntegrity) {
t.Fatal("wrapping")
}
joined := fmt.Errorf("drand: %w: %w", datekeys.ErrReleaseUnavailable, errors.Join(errors.New("x"), datekeys.ErrReleaseInvalid))
if datekeys.Code(joined) != "ERR_RELEASE_UNAVAILABLE" || !errors.Is(joined, datekeys.ErrReleaseInvalid) {
t.Fatal("joined errors")
}
if datekeys.Code(errors.New("plain")) != "" || datekeys.Code(nil) != "" {
t.Fatal("non-DateKeys errors have no code")
}
if datekeys.ErrIntegrity.Error() != "ERR_INTEGRITY" {
t.Fatal("message")
}
}

@ -0,0 +1,158 @@
// Package extension implements the single generic extension mechanism shared
// by PUBLIC_HEADER, CONTROL_CBOR and .dkk (spec §31, §44, §54, §72).
//
// The base protocol does not interpret extension data. It enforces the
// structural rules only: valid UTF-8 identifiers, no identifier repeated
// within an object (V1 registers no schema that allows multiplicity), no
// identifier in both the critical and the noncritical array, canonical order
// by the UTF-8 bytes of extension_id and then by version, rejection of unknown
// critical extensions, and omission of empty arrays (spec §58.1).
package extension
import (
"bytes"
"cmp"
"fmt"
"slices"
"unicode/utf8"
"github.com/fxamacker/cbor/v2"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec"
)
// MaxIDLen bounds extension_id. It is an implementation limit (spec §74).
const MaxIDLen = 256
// Extension is one entry of an extension array.
type Extension struct {
ID string // key 0, extension_id
Version uint64 // key 1, extension_version
// Data is the Deterministic CBOR encoding of the data item (key 2), or
// nil when the extension carries no data and the key is omitted.
Data []byte
}
// New builds an extension whose data is the deterministic encoding of value.
func New(id string, version uint64, value any) (Extension, error) {
b, err := codec.Marshal(value)
if err != nil {
return Extension{}, err
}
return Extension{ID: id, Version: version, Data: b}, nil
}
// Wire is the CBOR map of one extension (spec §54).
type Wire struct {
ID string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
Data cbor.RawMessage `cbor:"2,keyasint,omitempty"`
}
// Registry tells which critical extensions the application implements. A nil
// Registry knows none, which is the state of the base protocol V1.
type Registry interface {
Known(id string, version uint64) bool
}
// Set is a simple Registry.
type Set map[string][]uint64
// Known reports whether (id, version) is in the set.
func (s Set) Known(id string, version uint64) bool { return slices.Contains(s[id], version) }
func compare(a, b Extension) int {
if c := bytes.Compare([]byte(a.ID), []byte(b.ID)); c != 0 {
return c
}
return cmp.Compare(a.Version, b.Version)
}
func validate(e Extension) error {
if e.ID == "" || len(e.ID) > MaxIDLen || !utf8.ValidString(e.ID) {
return fmt.Errorf("extension: invalid extension_id %q: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
if e.Data != nil {
if err := codec.Valid(e.Data); err != nil {
return fmt.Errorf("extension %s: data: %w", e.ID, err)
}
}
return nil
}
// Encode validates one extension array and returns its canonical wire form,
// sorted by extension_id bytes and then version. An empty input yields nil,
// so that the array key is omitted (spec §58.1).
func Encode(exts []Extension) ([]Wire, error) {
if len(exts) == 0 {
return nil, nil
}
sorted := slices.Clone(exts)
slices.SortFunc(sorted, compare)
out := make([]Wire, 0, len(sorted))
for i, e := range sorted {
if err := validate(e); err != nil {
return nil, err
}
if i > 0 && sorted[i-1].ID == e.ID {
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
out = append(out, Wire{ID: e.ID, Version: e.Version, Data: bytes.Clone(e.Data)})
}
return out, nil
}
// Decode validates one decoded extension array: canonical order, no repeated
// identifier and canonical data.
func Decode(ws []Wire) ([]Extension, error) {
if len(ws) == 0 {
return nil, nil
}
out := make([]Extension, 0, len(ws))
for i, w := range ws {
e := Extension{ID: w.ID, Version: w.Version}
if w.Data != nil {
e.Data = bytes.Clone(w.Data)
}
if err := validate(e); err != nil {
return nil, err
}
if i > 0 {
prev := out[i-1]
if prev.ID == e.ID {
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
if compare(prev, e) > 0 {
return nil, fmt.Errorf("extension %s: array is not in canonical order: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
}
out = append(out, e)
}
return out, nil
}
// CheckDisjoint applies the cross-array rule of one object: an extension_id
// must not appear in both critical_extensions and noncritical_extensions
// (spec §31, §54).
func CheckDisjoint(critical, noncritical []Extension) error {
for _, c := range critical {
for _, n := range noncritical {
if c.ID == n.ID {
return fmt.Errorf("extension %s: both critical and noncritical: %w", c.ID, datekeys.ErrNonCanonicalCBOR)
}
}
}
return nil
}
// CheckCritical rejects every critical extension unknown to reg (spec §54,
// §70). Unknown noncritical extensions may be ignored and are not checked.
func CheckCritical(critical []Extension, reg Registry) error {
for _, c := range critical {
if reg == nil || !reg.Known(c.ID, c.Version) {
return fmt.Errorf("extension %s v%d: %w", c.ID, c.Version, datekeys.ErrExtensionCriticalUnknown)
}
}
return nil
}

@ -0,0 +1,104 @@
package extension_test
import (
"errors"
"testing"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/extension"
)
func ext(t *testing.T, id string, v uint64, data any) extension.Extension {
t.Helper()
if data == nil {
return extension.Extension{ID: id, Version: v}
}
e, err := extension.New(id, v, data)
if err != nil {
t.Fatal(err)
}
return e
}
func TestEncodeSortsCanonically(t *testing.T) {
in := []extension.Extension{ext(t, "org.b", 1, nil), ext(t, "org.a", 2, "x"), ext(t, "Z", 9, nil), ext(t, "org.aa", 1, 7)}
w, err := extension.Encode(in)
if err != nil {
t.Fatal(err)
}
var order []string
for _, e := range w {
order = append(order, e.ID)
}
// Bytewise UTF-8 order: uppercase before lowercase, prefixes first.
if got := []string{"Z", "org.a", "org.aa", "org.b"}; !equal(order, got) {
t.Fatalf("order %v, want %v", order, got)
}
if w, _ := extension.Encode(nil); w != nil {
t.Fatal("empty array must encode to nil so that the key is omitted")
}
back, err := extension.Decode(w)
if err != nil || len(back) != 4 || back[1].ID != "org.a" || string(back[1].Data) != "\x61\x78" {
t.Fatalf("decode: %+v %v", back, err)
}
}
func TestEncodeRejects(t *testing.T) {
for name, in := range map[string][]extension.Extension{
"same id twice": {ext(t, "org.a", 1, nil), ext(t, "org.a", 2, nil)},
"empty id": {ext(t, "", 1, nil)},
"invalid UTF-8 id": {{ID: "org.\xff", Version: 1}},
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0x18, 0x01}}},
"data with two items": {{ID: "org.a", Version: 1, Data: []byte{0x01, 0x02}}},
"data with a tag": {{ID: "org.a", Version: 1, Data: []byte{0xc1, 0x01}}},
} {
if _, err := extension.Encode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
}
func TestDecodeRejects(t *testing.T) {
for name, in := range map[string][]extension.Wire{
"out of order": {{ID: "org.b", Version: 1}, {ID: "org.a", Version: 1}},
"versions out of order": {{ID: "org.a", Version: 2}, {ID: "org.a", Version: 1}},
"repeated id": {{ID: "org.a", Version: 1}, {ID: "org.a", Version: 2}},
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0xf9, 0x3c, 0x00, 0x00}}},
} {
if _, err := extension.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
}
func TestCrossArrayRules(t *testing.T) {
crit := []extension.Extension{ext(t, "org.a", 1, nil)}
non := []extension.Extension{ext(t, "org.a", 2, nil)}
if err := extension.CheckDisjoint(crit, non); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("id in both arrays: %v", err)
}
if err := extension.CheckCritical(crit, nil); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
t.Fatalf("unknown critical accepted by the base protocol: %v", err)
}
if err := extension.CheckCritical(crit, extension.Set{"org.a": {2}}); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
t.Fatalf("other version accepted: %v", err)
}
if err := extension.CheckCritical(crit, extension.Set{"org.a": {1}}); err != nil {
t.Fatalf("known critical rejected: %v", err)
}
if err := extension.CheckCritical(nil, nil); err != nil {
t.Fatal(err)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

@ -0,0 +1,31 @@
module github.com/datekeys/datekeys-go
go 1.26.0
require (
filippo.io/age v1.3.2
github.com/drand/drand/v2 v2.1.7
github.com/drand/kyber v1.3.2
github.com/drand/tlock v1.2.0
github.com/fxamacker/cbor/v2 v2.9.4
golang.org/x/crypto v0.57.0
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/drand/kyber-bls12381 v0.3.4 // indirect
github.com/kilic/bls12-381 v0.1.0 // indirect
github.com/nikkolasg/hexjson v0.1.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.dedis.ch/fixbuf v1.0.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
google.golang.org/grpc v1.84.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

141
go.sum

@ -0,0 +1,141 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/ardanlabs/darwin/v2 v2.0.0 h1:XCisQMgQ5EG+ZvSEcADEo+pyfIMKyWAGnn5o2TgriYE=
github.com/ardanlabs/darwin/v2 v2.0.0/go.mod h1:MubZ2e9DAYGaym0mClSOi183NYahrrfKxvSy1HMhoes=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/drand/drand/v2 v2.1.7 h1:VpNrMI7dSFDEayQ8uGCouJ+8SekPqy3G9SdcwAzUkiY=
github.com/drand/drand/v2 v2.1.7/go.mod h1:wuZkwJ47Mbn6mhXgi0doTJQ8urCZB0hDMZ6gVUTRXsk=
github.com/drand/go-clients v0.2.0 h1:2agHJkF2OOjd9Eij/YedQnDc9mW0rywV/9xUHbf2XoQ=
github.com/drand/go-clients v0.2.0/go.mod h1:4m2qC/O8lx2Aj6DEIrEZ4kUzAUV6BIjmiSouW6lpYfI=
github.com/drand/kyber v1.3.2 h1:Cf3NNcb5bV3eODopr3XVHzImjDK40GiObhFUFG93Zeo=
github.com/drand/kyber v1.3.2/go.mod h1:ciDFWoC7ajb89niGJnS4C1Xeo4lSJMmbi+km5w8juAI=
github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAebZQJxI=
github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk=
github.com/drand/tlock v1.2.0 h1:YmbH2PXsq6UeUXljq+GMZcDicUlVnLIW9QbLqYoDp6g=
github.com/drand/tlock v1.2.0/go.mod h1:HFjdoX5v8rp4uOFaIPI8nDdWRKdvDnNgj+kQwQOOxoQ=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4=
github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nikkolasg/hexjson v0.1.0 h1:Cgi1MSZVQFoJKYeRpBNEcdF3LB+Zo4fYKsDz7h8uJYQ=
github.com/nikkolasg/hexjson v0.1.0/go.mod h1:fbGbWFZ0FmJMFbpCMtJpwb0tudVxSSZ+Es2TsCg57cA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.dedis.ch/fixbuf v1.0.3 h1:hGcV9Cd/znUxlusJ64eAlExS+5cJDIyTyEG+otu5wQs=
go.dedis.ch/fixbuf v1.0.3/go.mod h1:yzJMt34Wa5xD37V5RTdmp38cz3QhMagdGoem9anUalw=
go.dedis.ch/protobuf v1.0.11 h1:FTYVIEzY/bfl37lu3pR4lIj+F9Vp1jE8oh91VmxKgLo=
go.dedis.ch/protobuf v1.0.11/go.mod h1:97QR256dnkimeNdfmURz0wAMNVbd1VmLXhG1CrTYrJ4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4=
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -0,0 +1,150 @@
package testkit
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
"filippo.io/age"
"golang.org/x/crypto/hkdf"
"github.com/datekeys/datekeys-go/agewrap"
)
// CaptureRecipient forwards to Recipient and records the file key that age
// asks it to wrap. Knowing the file key lets a test rewrite the age header
// and recompute a valid MAC, which models a malicious creator (spec §27).
type CaptureRecipient struct {
Recipient age.Recipient
FileKey []byte
}
// Wrap implements age.Recipient.
func (c *CaptureRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
c.FileKey = bytes.Clone(fileKey)
return c.Recipient.Wrap(fileKey)
}
// WrapWithLabels forwards labels when the wrapped recipient has them.
func (c *CaptureRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
c.FileKey = bytes.Clone(fileKey)
if l, ok := c.Recipient.(age.RecipientWithLabels); ok {
return l.WrapWithLabels(fileKey)
}
s, err := c.Recipient.Wrap(fileKey)
return s, nil, err
}
// Encrypt returns a complete age file for plaintext and the file key age
// generated for it.
func Encrypt(plaintext []byte, recipients ...age.Recipient) (file, fileKey []byte, err error) {
if len(recipients) == 0 {
return nil, nil, errors.New("testkit: no recipients")
}
capture := &CaptureRecipient{Recipient: recipients[0]}
all := append([]age.Recipient{capture}, recipients[1:]...)
var buf bytes.Buffer
w, err := age.Encrypt(&buf, all...)
if err != nil {
return nil, nil, err
}
if _, err := w.Write(plaintext); err != nil {
return nil, nil, err
}
if err := w.Close(); err != nil {
return nil, nil, err
}
return buf.Bytes(), capture.FileKey, nil
}
// HeaderLen returns the length of the age header at the start of file.
func HeaderLen(file []byte) (int, error) {
hdr, err := age.ExtractHeader(bytes.NewReader(file))
if err != nil {
return 0, err
}
if !bytes.HasPrefix(file, hdr) {
return 0, errors.New("testkit: header is not in canonical form")
}
return len(hdr), nil
}
// RewriteAge replaces the recipient stanzas of an age file with
// edit(stanzas) and recomputes the header MAC with fileKey, keeping the nonce
// and the STREAM payload. The result is an age file that age itself accepts
// whenever some identity yields fileKey: only structural checks can reject it.
func RewriteAge(file, fileKey []byte, edit func([]*age.Stanza) []*age.Stanza) ([]byte, error) {
stanzas, err := agewrap.Stanzas(bytes.NewReader(file))
if err != nil {
return nil, err
}
n, err := HeaderLen(file)
if err != nil {
return nil, err
}
hdr, err := MarshalHeader(edit(stanzas), fileKey)
if err != nil {
return nil, err
}
return append(hdr, file[n:]...), nil
}
// MarshalHeader serialises an age v1 header as specified by C2SP age.md: the
// intro line, each stanza, and the footer with
// HMAC-SHA-256(HKDF-SHA-256(file key, "", "header"), header up to "---").
func MarshalHeader(stanzas []*age.Stanza, fileKey []byte) ([]byte, error) {
var b bytes.Buffer
b.WriteString("age-encryption.org/v1\n")
for _, s := range stanzas {
if !validArg(s.Type) {
return nil, fmt.Errorf("testkit: invalid stanza type %q", s.Type)
}
b.WriteString("-> " + s.Type)
for _, a := range s.Args {
if !validArg(a) {
return nil, fmt.Errorf("testkit: invalid stanza argument %q", a)
}
b.WriteString(" " + a)
}
b.WriteString("\n")
body := base64.RawStdEncoding.EncodeToString(s.Body)
for len(body) >= 64 {
b.WriteString(body[:64] + "\n")
body = body[64:]
}
b.WriteString(body + "\n") // the final line is always short, possibly empty
}
b.WriteString("---")
key := make([]byte, 32)
if _, err := io.ReadFull(hkdf.New(sha256.New, fileKey, nil, []byte("header")), key); err != nil {
return nil, err
}
mac := hmac.New(sha256.New, key)
mac.Write(b.Bytes())
b.WriteString(" " + base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) + "\n")
return b.Bytes(), nil
}
func validArg(s string) bool {
return s != "" && !strings.ContainsFunc(s, func(r rune) bool { return r < 33 || r > 126 })
}
// X25519Stanza returns a well-formed X25519 stanza wrapping fileKey for a
// fresh identity, and that identity. It models an extra decryption path that
// a malicious creator could add.
func X25519Stanza(fileKey []byte) (*age.Stanza, *age.X25519Identity, error) {
id, err := age.GenerateX25519Identity()
if err != nil {
return nil, nil, err
}
s, err := id.Recipient().Wrap(fileKey)
if err != nil {
return nil, nil, err
}
return s[0], id, nil
}

@ -0,0 +1,211 @@
package testkit
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"filippo.io/age"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/profile"
)
// Build assembles a capsule step by step like capsule.Encrypt, but lets a test
// declare one policy and build another structure, and edit the stanzas of each
// age file while keeping every MAC valid. Every other value (header_binding,
// lengths) stays consistent, as a malicious creator would make it.
type Build struct {
Profile *profile.Profile // default Quicknet
Round uint64 // default 1000
Declared capsule.Policy // access_policy written in PUBLIC_HEADER
Structure capsule.Policy // construction actually used
// AccessRecipients of INNER_ACCESS_AGE when Structure is time_and_key.
AccessRecipients []age.Recipient
Plaintext []byte
DateKeyString string // overrides the canonical dk1_ string in PUBLIC_HEADER
HeaderCritical, HeaderNoncritical []extension.Extension
ControlCritical, ControlNoncritical []extension.Extension
// Stanza edits, applied with the corresponding file key.
EditOuter func(fileKey []byte, s []*age.Stanza) []*age.Stanza
EditInner func(fileKey []byte, s []*age.Stanza) []*age.Stanza
EditPayload func(fileKey []byte, s []*age.Stanza) []*age.Stanza
}
// Built is a capsule produced by Build and its secrets.
type Built struct {
DKC []byte
Prelude [capsule.PreludeSize]byte
PublicHeader []byte
Sealed []byte
Payload []byte
Control []byte
PayloadIdentity []byte
CapsuleID [capsule.CapsuleIDSize]byte
}
// Make builds the capsule.
func (b Build) Make() (*Built, error) {
p := b.Profile
if p == nil {
p = profile.Quicknet()
}
round := b.Round
if round == 0 {
round = 1000
}
out := &Built{}
if _, err := rand.Read(out.CapsuleID[:]); err != nil {
return nil, err
}
header, err := b.header(p, round, out.CapsuleID)
if err != nil {
return nil, err
}
out.PublicHeader = header
payloadID, err := age.GenerateX25519Identity()
if err != nil {
return nil, err
}
if out.PayloadIdentity, err = agewrap.RawX25519Identity(payloadID); err != nil {
return nil, err
}
payload, fk, err := Encrypt(b.Plaintext, payloadID.Recipient())
if err != nil {
return nil, err
}
if b.EditPayload != nil {
if payload, err = RewriteAge(payload, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditPayload(fk, s) }); err != nil {
return nil, err
}
}
out.Payload = payload
timeRecipient, err := agewrap.NewTimeRecipient(p, round)
if err != nil {
return nil, err
}
seal := func(control []byte) ([]byte, error) {
plaintext := control
if b.Structure == capsule.TimeAndKey {
if len(b.AccessRecipients) == 0 {
return nil, errors.New("testkit: time_and_key structure needs AccessRecipients")
}
inner, fk, err := Encrypt(control, b.AccessRecipients...)
if err != nil {
return nil, err
}
if b.EditInner != nil {
if inner, err = RewriteAge(inner, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditInner(fk, s) }); err != nil {
return nil, err
}
}
plaintext = inner
}
outer, fk, err := Encrypt(plaintext, timeRecipient)
if err != nil {
return nil, err
}
if b.EditOuter != nil {
return RewriteAge(outer, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditOuter(fk, s) })
}
return outer, nil
}
ctrl := &capsule.Control{Critical: b.ControlCritical, Noncritical: b.ControlNoncritical}
draft, err := capsule.EncodeControl(ctrl)
if err != nil {
return nil, err
}
draftSealed, err := seal(draft)
if err != nil {
return nil, err
}
out.Prelude = capsule.Prelude{PublicHeaderLen: uint32(len(header)), SealedControlLen: uint32(len(draftSealed))}.Bytes()
ctrl.HeaderBinding = capsule.HeaderBinding(out.Prelude, header)
copy(ctrl.PayloadIdentity[:], out.PayloadIdentity)
if out.Control, err = capsule.EncodeControl(ctrl); err != nil {
return nil, err
}
if out.Sealed, err = seal(out.Control); err != nil {
return nil, err
}
if len(out.Sealed) != len(draftSealed) {
return nil, fmt.Errorf("testkit: SEALED_CONTROL length changed from %d to %d", len(draftSealed), len(out.Sealed))
}
out.DKC = Join(out.Prelude[:], out.PublicHeader, out.Sealed, out.Payload)
return out, nil
}
func (b Build) header(p *profile.Profile, round uint64, id [capsule.CapsuleIDSize]byte) ([]byte, error) {
h := &capsule.Header{
CapsuleID: id,
DateKey: datekey.DateKey{ProfileID: p.ID, Round: round},
Policy: b.Declared,
Critical: b.HeaderCritical,
Noncritical: b.HeaderNoncritical,
}
if b.DateKeyString == "" {
return capsule.EncodeHeader(h)
}
return RawHeader(id, b.DateKeyString, uint64(b.Declared))
}
// RawHeader encodes a PUBLIC_HEADER with an arbitrary DateKey string and
// policy value, bypassing the validation of capsule.EncodeHeader.
func RawHeader(id [capsule.CapsuleIDSize]byte, dk string, policy uint64) ([]byte, error) {
type wire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CapsuleID []byte `cbor:"2,keyasint"`
DateKey string `cbor:"3,keyasint"`
Policy uint64 `cbor:"4,keyasint"`
}
return codec.Marshal(wire{capsule.HeaderTypeTag, capsule.HeaderVersion, id[:], dk, policy})
}
// Parts is a .dkc split into its four sections.
type Parts struct {
Prelude, Header, Sealed, Payload []byte
}
// Split splits a .dkc using the lengths in its prelude.
func Split(dkc []byte) (Parts, error) {
if len(dkc) < capsule.PreludeSize {
return Parts{}, errors.New("testkit: short capsule")
}
hl := int(binary.BigEndian.Uint32(dkc[8:12]))
sl := int(binary.BigEndian.Uint32(dkc[12:16]))
if len(dkc) < capsule.PreludeSize+hl+sl {
return Parts{}, errors.New("testkit: capsule shorter than its prelude says")
}
h := dkc[capsule.PreludeSize : capsule.PreludeSize+hl]
s := dkc[capsule.PreludeSize+hl : capsule.PreludeSize+hl+sl]
return Parts{Prelude: dkc[:capsule.PreludeSize], Header: h, Sealed: s, Payload: dkc[capsule.PreludeSize+hl+sl:]}, nil
}
// Join concatenates sections into a new slice.
func Join(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = append(out, p...)
}
return out
}
// Reframe joins sections with a prelude whose lengths match them, keeping
// the version, flags and reserved bytes of prelude.
func Reframe(prelude, header, sealed, payload []byte) []byte {
pre := append([]byte(nil), prelude[:capsule.PreludeSize]...)
binary.BigEndian.PutUint32(pre[8:12], uint32(len(header)))
binary.BigEndian.PutUint32(pre[12:16], uint32(len(sealed)))
return Join(pre, header, sealed, payload)
}

@ -0,0 +1,102 @@
package testkit
import (
"encoding/json"
"os"
"path/filepath"
)
// FixtureStanza is the visible part of an age stanza in a fixture.
type FixtureStanza struct {
Type string `json:"type"`
Args []string `json:"args"`
}
// FixtureRelease is the release a fixture opens with.
type FixtureRelease struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
}
// FixtureStage is the expected result of one step of spec §63.
type FixtureStage struct {
Step int `json:"step"`
Name string `json:"name"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// DKCFixture holds the expected values of an official .dkc fixture (spec §67).
type DKCFixture struct {
Description string `json:"description"`
Spec string `json:"spec"`
File string `json:"file"`
SHA256 string `json:"sha256"`
Release FixtureRelease `json:"release"`
Prelude string `json:"prelude"`
PublicHeader string `json:"public_header"`
DateKey string `json:"datekey"`
CapsuleID string `json:"capsule_id"`
AccessPolicy string `json:"access_policy"`
Structure string `json:"structure"`
UnlockAt string `json:"unlock_at"`
HeaderBinding string `json:"header_binding"`
OuterStanzas []FixtureStanza `json:"outer_stanzas"`
PayloadStanzas []FixtureStanza `json:"payload_stanzas"`
InnerStanzas []FixtureStanza `json:"inner_stanzas,omitempty"`
AccessKeyFile string `json:"access_key_file,omitempty"`
Identities []string `json:"identities,omitempty"`
ControlCBOR string `json:"control_cbor"`
PayloadIdentity string `json:"payload_identity"`
PlaintextFile string `json:"plaintext_file"`
PlaintextSHA256 string `json:"plaintext_sha256"`
HeaderExtensions []FixtureExt `json:"header_extensions,omitempty"`
ControlExt []FixtureExt `json:"control_extensions,omitempty"`
Stages []FixtureStage `json:"stages"`
}
// FixtureExt is an extension in a fixture.
type FixtureExt struct {
Critical bool `json:"critical"`
ID string `json:"id"`
Version uint64 `json:"version"`
Data string `json:"data,omitempty"` // hex of the CBOR data item
}
// DKKFixture holds the expected values of an official .dkk fixture (spec §68).
type DKKFixture struct {
Description string `json:"description"`
Spec string `json:"spec"`
File string `json:"file"`
SHA256 string `json:"sha256"`
CredentialID string `json:"credential_id"`
CapsuleID string `json:"capsule_id"`
AccessType string `json:"access_type"`
Material string `json:"access_material"`
CapsuleDigest string `json:"capsule_digest,omitempty"`
Extensions []FixtureExt `json:"extensions,omitempty"`
Capsule string `json:"capsule"`
ExpectedResult string `json:"expected_result"`
Stages []FixtureStage `json:"stages,omitempty"`
}
// ReadJSON decodes a JSON file.
func ReadJSON(path string, v any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// WriteJSON writes v as indented JSON with a trailing newline.
func WriteJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}

@ -0,0 +1,306 @@
// Command genfixtures generates the official DateKeys vectors and fixtures
// (spec §65-§68) into testdata/.
//
// Fixtures are generated once, over rounds that are already published, and
// then committed: age randomness cannot be injected through its public API,
// so they are decryption and validation fixtures, not byte-reproducible
// encryption outputs (spec §67). Existing fixtures are never overwritten
// unless -force is given; vectors are always regenerated, and the tests fail
// if the implementation stops reproducing the committed ones.
//
// go run ./internal/testkit/genfixtures -out testdata
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func main() {
out := flag.String("out", "testdata", "output directory")
force := flag.Bool("force", false, "overwrite existing fixtures")
flag.Parse()
if err := vectors(filepath.Join(*out, "vectors")); err != nil {
log.Fatal(err)
}
if err := fixtures(filepath.Join(*out, "fixtures"), *force); err != nil {
log.Fatal(err)
}
}
func vectors(dir string) error {
pv, err := testkit.QuicknetProfileVector()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "profile_quicknet.json"), pv); err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "quicknet_rounds.json"), testkit.RoundVectors()); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors())
}
type spec struct {
name, description string
round uint64
policy capsule.Policy
recipients int
portable bool
plaintext []byte
headerExt []extension.Extension
controlExt []extension.Extension
}
func fixtures(dir string, force bool) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
hExt, err := extension.New("org.example.label", 1, "public label")
if err != nil {
return err
}
cExt, err := extension.New("org.example.note", 2, map[string]any{"sealed": true, "n": 7})
if err != nil {
return err
}
specs := []spec{
{name: "time_only", description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
{name: "time_only_extensions", description: "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}},
{name: "time_and_key_portable", description: "time_and_key capsule whose only recipient is a portable .dkk", round: 1000, policy: capsule.TimeAndKey, portable: true, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")},
{name: "time_and_key_recipients", description: "time_and_key capsule for two known X25519 recipients and a portable .dkk", round: 1001, policy: capsule.TimeAndKey, recipients: 2, portable: true, plaintext: []byte("DateKeys fixture for several recipients.\n")},
{name: "empty_payload", description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}},
}
for _, s := range specs {
path := filepath.Join(dir, s.name+".dkc")
if _, err := os.Stat(path); err == nil && !force {
log.Printf("keeping existing %s", path)
continue
}
if err := generate(dir, s); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
log.Printf("generated %s", path)
}
return nil
}
func generate(dir string, s spec) error {
p := profile.Quicknet()
reg := testkit.Registry()
unlock, err := datekey.RoundTime(p, s.round)
if err != nil {
return err
}
opts := capsule.EncryptOptions{
Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable,
Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()),
}
var ids []*age.X25519Identity
for range s.recipients {
id, err := age.GenerateX25519Identity()
if err != nil {
return err
}
ids = append(ids, id)
opts.Recipients = append(opts.Recipients, id.Recipient())
}
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, bytes.NewReader(s.plaintext), opts)
if err != nil {
return err
}
release := testkit.Release(s.round)
// Recover every intermediate value by opening the fixture step by step.
parts, err := testkit.Split(dkc.Bytes())
if err != nil {
return err
}
timeID, err := agewrap.NewTimeIdentity(p, s.round, release)
if err != nil {
return err
}
inner, err := decrypt(parts.Sealed, timeID)
if err != nil {
return err
}
control := inner
var innerStanzas []testkit.FixtureStanza
var identities []string
var dkkFile string
var dkkBytes []byte
if s.policy == capsule.TimeAndKey {
st, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return err
}
innerStanzas = stanzas(st)
var tryIDs []age.Identity
for _, id := range ids {
identities = append(identities, id.String())
tryIDs = append(tryIDs, id)
}
if res.PortableKey != nil {
var kb bytes.Buffer
if err := accesskey.Encode(&kb, res.PortableKey); err != nil {
return err
}
dkkBytes = kb.Bytes()
dkkFile = s.name + ".dkk"
kid, err := res.PortableKey.Identity()
if err != nil {
return err
}
tryIDs = append(tryIDs, kid)
}
accessID, err := agewrap.NewAccessIdentity(tryIDs...)
if err != nil {
return err
}
if control, err = decrypt(inner, accessID); err != nil {
return err
}
}
ctrl, err := capsule.DecodeControl(control)
if err != nil {
return err
}
outer, err := agewrap.Stanzas(bytes.NewReader(parts.Sealed))
if err != nil {
return err
}
payload, err := agewrap.Stanzas(bytes.NewReader(parts.Payload))
if err != nil {
return err
}
// The fixture must open through the public API with the embedded release.
oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)}
for _, id := range ids {
oo.Identities = append(oo.Identities, id)
}
if s.policy == capsule.TimeAndKey && len(ids) == 0 {
oo.AccessKey = res.PortableKey
}
var plain bytes.Buffer
opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), oo)
if err != nil {
return fmt.Errorf("fixture does not open: %w", err)
}
if !bytes.Equal(plain.Bytes(), s.plaintext) {
return fmt.Errorf("fixture plaintext mismatch")
}
sum := sha256.Sum256(dkc.Bytes())
psum := sha256.Sum256(s.plaintext)
f := testkit.DKCFixture{
Description: s.description,
Spec: testkit.SpecVersion,
File: s.name + ".dkc",
SHA256: hex.EncodeToString(sum[:]),
Release: testkit.FixtureRelease{Round: release.Round, Signature: hex.EncodeToString(release.Signature)},
Prelude: hex.EncodeToString(parts.Prelude),
PublicHeader: hex.EncodeToString(parts.Header),
DateKey: res.DateKey.Compact(),
CapsuleID: hex.EncodeToString(res.CapsuleID[:]),
AccessPolicy: s.policy.String(),
Structure: s.policy.String(),
UnlockAt: unlock.Format(time.RFC3339),
HeaderBinding: hex.EncodeToString(ctrl.HeaderBinding[:]),
OuterStanzas: stanzas(outer),
PayloadStanzas: stanzas(payload),
InnerStanzas: innerStanzas,
AccessKeyFile: dkkFile,
Identities: identities,
ControlCBOR: hex.EncodeToString(control),
PayloadIdentity: hex.EncodeToString(ctrl.PayloadIdentity[:]),
PlaintextFile: s.name + ".plaintext",
PlaintextSHA256: hex.EncodeToString(psum[:]),
HeaderExtensions: exts(false, s.headerExt),
ControlExt: exts(false, s.controlExt),
}
for _, c := range opened.Inspection.Checks {
f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
}
if err := os.WriteFile(filepath.Join(dir, f.File), dkc.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, f.PlaintextFile), s.plaintext, 0o644); err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, s.name+".json"), f); err != nil {
return err
}
if dkkBytes == nil {
return nil
}
k := res.PortableKey
ksum := sha256.Sum256(dkkBytes)
kf := testkit.DKKFixture{
Description: "portable X25519 .dkk of " + f.File,
Spec: testkit.SpecVersion,
File: dkkFile,
SHA256: hex.EncodeToString(ksum[:]),
CredentialID: hex.EncodeToString(k.CredentialID[:]),
CapsuleID: hex.EncodeToString(k.CapsuleID[:]),
AccessType: k.Type,
Material: hex.EncodeToString(k.Material),
CapsuleDigest: hex.EncodeToString(k.Verification.CapsuleDigest),
Capsule: f.File,
ExpectedResult: "opens INNER_ACCESS_AGE of " + f.File + " and yields its CONTROL_CBOR",
}
if err := os.WriteFile(filepath.Join(dir, dkkFile), dkkBytes, 0o644); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, s.name+".dkk.json"), kf)
}
func decrypt(file []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(file), id)
if err != nil {
return nil, err
}
var b bytes.Buffer
if _, err := b.ReadFrom(r); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func stanzas(in []*age.Stanza) []testkit.FixtureStanza {
out := make([]testkit.FixtureStanza, len(in))
for i, s := range in {
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
}
return out
}
func exts(critical bool, in []extension.Extension) []testkit.FixtureExt {
var out []testkit.FixtureExt
for _, e := range in {
out = append(out, testkit.FixtureExt{Critical: critical, ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
}
return out
}

@ -0,0 +1,85 @@
// Package testkit builds DateKeys test material: known Quicknet releases,
// offline release sources, capsules with arbitrary structural defects, and age
// files with edited headers whose MAC is still valid.
//
// It is internal and exists for tests and fixture generation only.
package testkit
import (
"context"
"encoding/hex"
"fmt"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// Published Quicknet signatures. They are public data obtained from drand
// relays; tests never trust them blindly but verify them with provider.Verify
// against the pinned public key.
var signatures = map[uint64]string{
1000: "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
1001: "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41",
2000: "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e",
}
// Rounds with a known release, in increasing order.
var Rounds = []uint64{1000, 1001, 2000}
// Release returns the published release of round; it panics for rounds
// without a known signature.
func Release(round uint64) provider.Release {
s, ok := signatures[round]
if !ok {
panic(fmt.Sprintf("testkit: no known release for round %d", round))
}
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return provider.Release{Round: round, Signature: b}
}
// Source serves the given releases by round and reports every other round as
// unavailable. It counts the requests it receives.
type Source struct {
Releases map[uint64]provider.Release
Calls int
}
// NewSource returns a Source with the given releases.
func NewSource(releases ...provider.Release) *Source {
s := &Source{Releases: map[uint64]provider.Release{}}
for _, r := range releases {
s.Releases[r.Round] = r
}
return s
}
// Fetch implements provider.ReleaseSource.
func (s *Source) Fetch(_ context.Context, _ *profile.Profile, c provider.Condition) (provider.Release, error) {
s.Calls++
r, ok := s.Releases[c.Round]
if !ok {
return provider.Release{}, fmt.Errorf("testkit: round %d: %w", c.Round, datekeys.ErrReleaseUnavailable)
}
return r, nil
}
// Fixed returns a clock stopped at t.
func Fixed(t time.Time) func() time.Time { return func() time.Time { return t } }
// Genesis returns the Quicknet genesis instant, a convenient "now" for
// encrypting to rounds that are already published.
func Genesis() time.Time { return time.Unix(profile.QuicknetGenesisTime, 0).UTC() }
// Registry returns the default registry or panics.
func Registry() profile.Registry {
r, err := profile.Default()
if err != nil {
panic(err)
}
return r
}

@ -0,0 +1,232 @@
package testkit
import (
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/profile"
)
// SpecVersion is the specification the vectors and fixtures implement.
const SpecVersion = "0.8.1"
// RoundVector is one Quicknet resolution vector (spec §65).
type RoundVector struct {
Name string `json:"name"`
Requested string `json:"requested"`
Round uint64 `json:"round,omitempty"`
Effective string `json:"effective,omitempty"`
Error string `json:"error,omitempty"`
}
// RoundVectorFile is testdata/vectors/quicknet_rounds.json.
type RoundVectorFile struct {
Spec string `json:"spec"`
Profile string `json:"profile"`
Description string `json:"description"`
Vectors []RoundVector `json:"vectors"`
}
// DK1Vector is one dk1_ vector (spec §66). Valid vectors carry the logical
// object and every intermediate encoding; invalid ones carry the input and the
// expected error.
type DK1Vector struct {
Name string `json:"name"`
Network string `json:"network,omitempty"`
Round uint64 `json:"round,omitempty"`
CanonicalJSON string `json:"canonical_json,omitempty"`
Base64URL string `json:"base64url,omitempty"`
Input string `json:"input,omitempty"`
DK1 string `json:"dk1,omitempty"`
Error string `json:"error,omitempty"`
}
// DK1VectorFile is testdata/vectors/dk1.json.
type DK1VectorFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Vectors []DK1Vector `json:"vectors"`
}
// ProfileVector is testdata/vectors/profile_quicknet.json.
type ProfileVector struct {
Spec string `json:"spec"`
Description string `json:"description"`
ProfileID string `json:"profile_id"`
Provider string `json:"provider"`
Network string `json:"network"`
ChainHash string `json:"chain_hash"`
PublicKey string `json:"public_key"`
PeriodSeconds uint64 `json:"period_seconds"`
GenesisTime int64 `json:"genesis_time"`
GenesisSeed string `json:"genesis_seed"`
Scheme string `json:"scheme"`
CanonicalCBOR string `json:"canonical_cbor"`
ProfileHash string `json:"profile_hash"`
}
// RoundVectors computes the Quicknet resolution vectors with the implementation.
func RoundVectors() RoundVectorFile {
p := profile.Quicknet()
g := time.Unix(p.GenesisTime, 0).UTC()
r1000, _ := datekey.RoundTime(p, 1000)
cases := []struct {
name string
at time.Time
}{
{"genesis exactly: round 1", g},
{"genesis + 1ns: next round", g.Add(time.Nanosecond)},
{"genesis + 1s", g.Add(time.Second)},
{"genesis + one period: round 2", g.Add(3 * time.Second)},
{"genesis + one period + 1ns: round 3", g.Add(3*time.Second + time.Nanosecond)},
{"genesis - 1s: before the profile", g.Add(-time.Second)},
{"round 1000 boundary exactly", r1000},
{"one second before the round 1000 boundary", r1000.Add(-time.Second)},
{"one second after the round 1000 boundary", r1000.Add(time.Second)},
{"1ns after the round 1000 boundary", r1000.Add(time.Nanosecond)},
{"half a second after the round 1000 boundary", r1000.Add(500 * time.Millisecond)},
{"normative vector 2030-01-01 (spec §16)", time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)},
{"1ns after 2030-01-01", time.Date(2030, 1, 1, 0, 0, 0, 1, time.UTC)},
{"normative vector round 66432123 (spec §16)", time.Date(2029, 12, 16, 7, 15, 33, 0, time.UTC)},
{"offset timezone equals UTC instant", time.Date(2026, 10, 22, 19, 0, 0, 1_000_000, time.FixedZone("", 2*3600))},
{"last representable round time", time.Date(9999, 12, 31, 23, 59, 57, 0, time.UTC)},
{"after the last representable round", time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)},
}
f := RoundVectorFile{
Spec: SpecVersion,
Profile: p.ID,
Description: "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
}
for _, c := range cases {
v := RoundVector{Name: c.name, Requested: c.at.Format(time.RFC3339Nano)}
d, err := datekey.Resolve(p, c.at)
if err != nil {
v.Error = datekeys.Code(err)
} else {
v.Round = d.Round
v.Effective = d.UnlockAt(p).Format(time.RFC3339Nano)
}
f.Vectors = append(f.Vectors, v)
}
return f
}
// DK1Vectors computes the dk1_ vectors with the implementation.
func DK1Vectors() DK1VectorFile {
f := DK1VectorFile{
Spec: SpecVersion,
Description: "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
}
for _, v := range []struct {
name string
round uint64
}{
{"round 1", 1},
{"round 1000", 1000},
{"normative 2030-01-01 round", 66884212},
{"last Quicknet round", profile.Quicknet().MaxRound()},
} {
d := datekey.DateKey{ProfileID: profile.QuicknetID, Round: v.round}
j := d.CanonicalJSON()
f.Vectors = append(f.Vectors, DK1Vector{
Name: v.name,
Network: d.ProfileID,
Round: d.Round,
CanonicalJSON: string(j),
Base64URL: base64.RawURLEncoding.EncodeToString(j),
DK1: d.Compact(),
})
}
enc := func(s string) string { return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(s)) }
canon := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 66884212}
// The canonical JSON of round 1000 is 59 bytes: its Base64 form needs padding
// and has unused bits, unlike the 63-byte JSON of round 66884212.
r1000 := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
bad := []struct{ name, input string }{
{"whitespace in JSON", enc(`{"version": 1, "network": "datekeys:quicknet:v1", "round": 66884212}`)},
{"keys reordered", enc(`{"network":"datekeys:quicknet:v1","version":1,"round":66884212}`)},
{"trailing whitespace", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}` + "\n")},
{"exponent notation", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":6.6884212e7}`)},
{"fraction notation", enc(`{"version":1.0,"network":"datekeys:quicknet:v1","round":66884212}`)},
{"escaped character", enc(strings.Replace(string(canon.CanonicalJSON()), "datekeys:", "datekeys\\"+"u003a", 1))},
{"duplicate key", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":1,"round":66884212}`)},
{"padded Base64URL", datekey.Prefix + base64.URLEncoding.EncodeToString(r1000.CanonicalJSON())},
{"non-zero trailing bits", mangleLastChar(r1000.Compact())},
{"extra field", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212,"public_key":"00"}`)},
{"missing field", enc(`{"version":1,"network":"datekeys:quicknet:v1"}`)},
{"version 2", enc(`{"version":2,"network":"datekeys:quicknet:v1","round":66884212}`)},
{"round 0", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":0}`)},
{"negative round", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":-1}`)},
{"fractional round", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":1.5}`)},
{"round above 2^53-1", enc(fmt.Sprintf(`{"version":1,"network":"datekeys:quicknet:v1","round":%d}`, uint64(datekey.MaxRound)+1))},
{"round as string", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":"66884212"}`)},
{"uppercase network", enc(`{"version":1,"network":"DATEKEYS:QUICKNET:V1","round":66884212}`)},
{"trailing data", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}x`)},
{"byte order mark", enc("\ufeff" + `{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`)},
{"not Base64", datekey.Prefix + "!!!"},
{"missing prefix", canon.Compact()[len(datekey.Prefix):]},
{"uppercase prefix", "DK1_" + canon.Compact()[len(datekey.Prefix):]},
}
for _, b := range bad {
_, err := datekey.Parse(b.input)
code := datekeys.Code(err)
if err == nil {
code = "accepted"
}
f.Vectors = append(f.Vectors, DK1Vector{Name: b.name, Input: b.input, Error: code})
}
return f
}
// mangleLastChar changes the last Base64 character to one that decodes to the
// same bytes but has non-zero unused bits.
func mangleLastChar(s string) string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
last := s[len(s)-1]
for i := 0; i < len(alphabet); i++ {
c := alphabet[i]
if c == last {
continue
}
cand := s[:len(s)-1] + string(c)
a, errA := base64.RawURLEncoding.DecodeString(s[len(datekey.Prefix):])
b, errB := base64.RawURLEncoding.DecodeString(cand[len(datekey.Prefix):])
if errA == nil && errB == nil && string(a) == string(b) {
return cand
}
}
return s
}
// QuicknetProfileVector computes the profile vector with the implementation.
func QuicknetProfileVector() (ProfileVector, error) {
p := profile.Quicknet()
b, err := p.CanonicalCBOR()
if err != nil {
return ProfileVector{}, err
}
h, err := p.Hash()
if err != nil {
return ProfileVector{}, err
}
return ProfileVector{
Spec: SpecVersion,
Description: "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
ProfileID: p.ID,
Provider: p.Provider,
Network: p.Network,
ChainHash: p.ChainHashHex(),
PublicKey: hex.EncodeToString(p.PublicKey),
PeriodSeconds: uint64(p.Period / time.Second),
GenesisTime: p.GenesisTime,
GenesisSeed: hex.EncodeToString(p.GenesisSeed[:]),
Scheme: p.Scheme,
CanonicalCBOR: hex.EncodeToString(b),
ProfileHash: hex.EncodeToString(h[:]),
}, nil
}

@ -0,0 +1,259 @@
// Package profile implements Provider Profiles (spec §10-§13): their
// Deterministic CBOR encoding, profile_hash, validation and the locally
// pinned registry that forms the client's root of trust.
package profile
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/drand/drand/v2/common/chain"
"github.com/drand/drand/v2/crypto"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec"
)
// Schema constants of the Provider Profile CBOR map (spec §11).
const (
TypeTag = "datekeys-provider-profile"
SchemaVersion = 1
)
// ProviderDrand is the only provider implemented by this module (spec §12).
const ProviderDrand = "drand"
// MaxUnixTime is 9999-12-31T23:59:59Z. Round times beyond it are rejected so
// that every effective time stays representable in RFC 3339 and every round
// computation stays within int64.
const MaxUnixTime int64 = 253402300799
// Field limits enforced by Validate. They are implementation limits; spec §74
// leaves the definitive field limits open.
const (
maxIDLen = 128
maxNameLen = 64
maxPublicKeyLen = 1024
maxPeriod = 24 * time.Hour
)
// Profile is an immutable Provider Profile (spec §10). Treat values as
// read-only; registries hand out copies.
type Profile struct {
ID string // key 2, profile_id, for example "datekeys:quicknet:v1"
Provider string // key 3, for example "drand"
Network string // key 4, provider network identifier, for example "quicknet"
ChainHash [32]byte // key 5
PublicKey []byte // key 6, provider group public key
Period time.Duration // key 7, encoded as whole seconds
GenesisTime int64 // key 8, Unix seconds
Scheme string // key 9, for example "bls-unchained-g1-rfc9380"
GenesisSeed [32]byte // key 10
}
// wire is the CBOR map of spec §11. Every key is required.
type wire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
ID string `cbor:"2,keyasint"`
Provider string `cbor:"3,keyasint"`
Network string `cbor:"4,keyasint"`
ChainHash []byte `cbor:"5,keyasint"`
PublicKey []byte `cbor:"6,keyasint"`
Period uint64 `cbor:"7,keyasint"`
GenesisTime int64 `cbor:"8,keyasint"`
Scheme string `cbor:"9,keyasint"`
GenesisSeed []byte `cbor:"10,keyasint"`
}
// Clone returns a deep copy of p.
func (p *Profile) Clone() *Profile {
c := *p
c.PublicKey = bytes.Clone(p.PublicKey)
return &c
}
// CanonicalCBOR returns the exact Deterministic CBOR bytes of spec §11.
func (p *Profile) CanonicalCBOR() ([]byte, error) {
if p.Period <= 0 || p.Period%time.Second != 0 {
return nil, fmt.Errorf("profile: period %s is not a positive whole number of seconds", p.Period)
}
return codec.Marshal(wire{
Type: TypeTag,
Version: SchemaVersion,
ID: p.ID,
Provider: p.Provider,
Network: p.Network,
ChainHash: p.ChainHash[:],
PublicKey: p.PublicKey,
Period: uint64(p.Period / time.Second),
GenesisTime: p.GenesisTime,
Scheme: p.Scheme,
GenesisSeed: p.GenesisSeed[:],
})
}
// Hash returns profile_hash = SHA-256(exact_deterministic_cbor_bytes) (spec §11).
//
// A profile_hash declared by a remote party has no security value; security
// comes from the profile pinned locally (spec §11, §13).
func (p *Profile) Hash() ([32]byte, error) {
b, err := p.CanonicalCBOR()
if err != nil {
return [32]byte{}, err
}
return sha256.Sum256(b), nil
}
// Decode parses the Deterministic CBOR encoding of a Provider Profile and
// validates it. It does not make the profile trusted: only a Registry built by
// the caller does (spec §13).
func Decode(b []byte) (*Profile, error) {
if err := codec.CheckSchema(b, TypeTag, SchemaVersion); err != nil {
return nil, fmt.Errorf("profile: %w", err)
}
var w wire
if err := codec.Unmarshal(b, &w); err != nil {
return nil, fmt.Errorf("profile: %w", err)
}
if len(w.ChainHash) != 32 || len(w.GenesisSeed) != 32 {
return nil, fmt.Errorf("profile: chain hash and genesis seed must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
}
if w.Period == 0 || w.Period > uint64(maxPeriod/time.Second) {
return nil, fmt.Errorf("profile: period %d s out of range: %w", w.Period, datekeys.ErrNonCanonicalCBOR)
}
p := &Profile{
ID: w.ID,
Provider: w.Provider,
Network: w.Network,
PublicKey: w.PublicKey,
Period: time.Duration(w.Period) * time.Second,
GenesisTime: w.GenesisTime,
Scheme: w.Scheme,
}
copy(p.ChainHash[:], w.ChainHash)
copy(p.GenesisSeed[:], w.GenesisSeed)
if err := p.Validate(); err != nil {
return nil, err
}
return p, nil
}
// Validate checks the syntax of every field and, for drand profiles, that the
// scheme is supported, that the public key is a valid group element and that
// the chain hash is the drand chain-info hash of the other parameters. The
// last check is the self-verification kept from the prototype: a profile whose
// parameters do not produce its own chain hash is rejected.
func (p *Profile) Validate() error {
if !ValidID(p.ID) {
return fmt.Errorf("profile: invalid profile_id %q: %w", p.ID, datekeys.ErrUnknownProfile)
}
if !validName(p.Provider) || !validName(p.Network) || !validName(p.Scheme) {
return fmt.Errorf("profile %s: invalid provider, network or scheme name: %w", p.ID, datekeys.ErrUnknownProfile)
}
if len(p.PublicKey) == 0 || len(p.PublicKey) > maxPublicKeyLen {
return fmt.Errorf("profile %s: invalid public key length %d: %w", p.ID, len(p.PublicKey), datekeys.ErrUnknownProfile)
}
if p.Period <= 0 || p.Period > maxPeriod || p.Period%time.Second != 0 {
return fmt.Errorf("profile %s: invalid period %s: %w", p.ID, p.Period, datekeys.ErrUnknownProfile)
}
if p.GenesisTime <= 0 || p.GenesisTime >= MaxUnixTime {
return fmt.Errorf("profile %s: invalid genesis time %d: %w", p.ID, p.GenesisTime, datekeys.ErrUnknownProfile)
}
if p.Provider != ProviderDrand {
return fmt.Errorf("profile %s: unsupported provider %q: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
}
return p.validateDrand()
}
func (p *Profile) validateDrand() error {
scheme, err := p.DrandScheme()
if err != nil {
return err
}
switch scheme.Name {
case crypto.SigsOnG1ID, crypto.UnchainedSchemeID, crypto.ShortSigSchemeID:
default:
return fmt.Errorf("profile %s: scheme %q is not supported by tlock: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
}
key := scheme.KeyGroup.Point()
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
return fmt.Errorf("profile %s: public key is not a %s group element: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
}
if key.Equal(key.Null()) {
return fmt.Errorf("profile %s: public key is the identity element: %w", p.ID, datekeys.ErrUnknownProfile)
}
info := chain.Info{
PublicKey: key,
ID: p.Network,
Period: p.Period,
Scheme: p.Scheme,
GenesisTime: p.GenesisTime,
GenesisSeed: p.GenesisSeed[:],
}
if !bytes.Equal(info.Hash(), p.ChainHash[:]) {
return fmt.Errorf("profile %s: parameters hash to chain %s, not the pinned %s: %w",
p.ID, info.HashString(), hex.EncodeToString(p.ChainHash[:]), datekeys.ErrProfileMismatch)
}
return nil
}
// DrandScheme returns a fresh drand scheme object for p. Fresh objects avoid
// sharing mutable kyber state between callers.
func (p *Profile) DrandScheme() (*crypto.Scheme, error) {
if p.Provider != ProviderDrand {
return nil, fmt.Errorf("profile %s: provider %q is not drand: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
}
scheme, err := crypto.SchemeFromName(p.Scheme)
if err != nil {
return nil, fmt.Errorf("profile %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
}
return scheme, nil
}
// ChainHashHex returns the lowercase hexadecimal chain hash, the form used in
// tlock stanzas and drand relay URLs.
func (p *Profile) ChainHashHex() string { return hex.EncodeToString(p.ChainHash[:]) }
// MaxRound is the last round whose round time is not after MaxUnixTime.
func (p *Profile) MaxRound() uint64 {
period := int64(p.Period / time.Second)
if period <= 0 || p.GenesisTime >= MaxUnixTime {
return 0
}
return uint64((MaxUnixTime-p.GenesisTime)/period) + 1
}
// ValidID reports whether s is a syntactically valid profile_id: 1 to 128
// characters from [a-z0-9:._-], starting with a letter or digit. The restricted
// alphabet keeps the dk1_ JSON form free of escapes (spec §18, §19).
func ValidID(s string) bool {
if len(s) == 0 || len(s) > maxIDLen || !alnum(s[0]) {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !alnum(c) && c != ':' && c != '.' && c != '_' && c != '-' {
return false
}
}
return true
}
func validName(s string) bool {
if len(s) == 0 || len(s) > maxNameLen || !alnum(s[0]) {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !alnum(c) && c != '.' && c != '_' && c != '-' {
return false
}
}
return true
}
func alnum(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') }

@ -0,0 +1,186 @@
package profile_test
import (
"bytes"
"encoding/hex"
"errors"
"testing"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/codec"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
)
func TestQuicknetMatchesGoldenVector(t *testing.T) {
var golden testkit.ProfileVector
if err := testkit.ReadJSON("../testdata/vectors/profile_quicknet.json", &golden); err != nil {
t.Fatal(err)
}
got, err := testkit.QuicknetProfileVector()
if err != nil {
t.Fatal(err)
}
if got != golden {
t.Fatalf("Quicknet profile vector changed:\n got %+v\nwant %+v", got, golden)
}
if golden.ProfileHash != profile.QuicknetProfileHash {
t.Fatalf("pinned hash %s differs from golden %s", profile.QuicknetProfileHash, golden.ProfileHash)
}
// Spec §12 values, restated independently of the constants.
p := profile.Quicknet()
if p.ID != "datekeys:quicknet:v1" || p.Provider != "drand" || p.Network != "quicknet" ||
p.Period != 3*time.Second || p.GenesisTime != 1692803367 || p.Scheme != "bls-unchained-g1-rfc9380" ||
p.ChainHashHex() != "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" ||
hex.EncodeToString(p.GenesisSeed[:]) != "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e" {
t.Fatal("Quicknet parameters differ from spec §12")
}
}
func TestQuicknetCBORLayout(t *testing.T) {
b, err := profile.Quicknet().CanonicalCBOR()
if err != nil {
t.Fatal(err)
}
// Map of 11 entries whose keys 0..10 appear in order (spec §11).
if b[0] != 0xab {
t.Fatalf("map header %#x", b[0])
}
var m map[uint64]any
if err := codec.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
want := map[uint64]any{0: "datekeys-provider-profile", 1: uint64(1), 2: "datekeys:quicknet:v1", 3: "drand", 4: "quicknet", 7: uint64(3), 8: uint64(1692803367), 9: "bls-unchained-g1-rfc9380"}
for k, v := range want {
if m[k] != v {
t.Errorf("key %d = %v, want %v", k, m[k], v)
}
}
for _, k := range []uint64{5, 6, 10} {
if _, ok := m[k].([]byte); !ok {
t.Errorf("key %d is not a byte string", k)
}
}
}
func TestDecodeRoundTrip(t *testing.T) {
b, _ := profile.Quicknet().CanonicalCBOR()
p, err := profile.Decode(b)
if err != nil {
t.Fatal(err)
}
b2, _ := p.CanonicalCBOR()
if !bytes.Equal(b, b2) {
t.Fatal("Decode/CanonicalCBOR is not the identity")
}
// The same values with a different key order or integer width are rejected.
period, genesis := []byte{0x07, 0x03}, []byte{0x08, 0x1a, 0x64, 0xe6, 0x21, 0x27}
pair := append(append([]byte(nil), period...), genesis...)
if !bytes.Contains(b, pair) {
t.Fatal("unexpected layout")
}
swapped := bytes.Replace(b, pair, append(append([]byte(nil), genesis...), period...), 1)
widened := bytes.Replace(b, period, []byte{0x07, 0x18, 0x03}, 1)
for name, in := range map[string][]byte{"keys out of order": swapped, "integer not in shortest form": widened} {
if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s accepted: %v", name, err)
}
}
future, _ := codec.Marshal(map[uint64]any{0: profile.TypeTag, 1: uint64(2)})
if _, err := profile.Decode(future); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("future schema: %v", err)
}
}
func TestValidateRejectsTamperedProfiles(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(p *profile.Profile)
want error
}{
{"chain hash changed", func(p *profile.Profile) { p.ChainHash[0] ^= 1 }, datekeys.ErrProfileMismatch},
{"genesis seed changed", func(p *profile.Profile) { p.GenesisSeed[0] ^= 1 }, datekeys.ErrProfileMismatch},
{"genesis time changed", func(p *profile.Profile) { p.GenesisTime++ }, datekeys.ErrProfileMismatch},
{"period changed", func(p *profile.Profile) { p.Period = 30 * time.Second }, datekeys.ErrProfileMismatch},
{"network id changed", func(p *profile.Profile) { p.Network = "default" }, datekeys.ErrProfileMismatch},
{"public key not a point", func(p *profile.Profile) { p.PublicKey = bytes.Repeat([]byte{0xff}, 96) }, datekeys.ErrUnknownProfile},
{"public key truncated", func(p *profile.Profile) { p.PublicKey = p.PublicKey[:95] }, datekeys.ErrUnknownProfile},
{"unknown scheme", func(p *profile.Profile) { p.Scheme = "bls-unchained-g9" }, datekeys.ErrUnknownProfile},
{"scheme without tlock support", func(p *profile.Profile) { p.Scheme = "pedersen-bls-chained" }, datekeys.ErrUnknownProfile},
{"unknown provider", func(p *profile.Profile) { p.Provider = "roughtime" }, datekeys.ErrUnknownProfile},
{"sub-second period", func(p *profile.Profile) { p.Period = 1500 * time.Millisecond }, datekeys.ErrUnknownProfile},
{"uppercase profile id", func(p *profile.Profile) { p.ID = "DateKeys:quicknet:v1" }, datekeys.ErrUnknownProfile},
{"profile id with quote", func(p *profile.Profile) { p.ID = `datekeys:"quicknet` }, datekeys.ErrUnknownProfile},
} {
t.Run(tc.name, func(t *testing.T) {
p := profile.Quicknet()
tc.mutate(p)
if err := p.Validate(); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
}
}
func TestRegistry(t *testing.T) {
reg, err := profile.Default()
if err != nil {
t.Fatal(err)
}
p, ok := reg.Lookup(profile.QuicknetID)
if !ok {
t.Fatal("Quicknet not pinned")
}
// Lookup hands out copies: mutating one does not alter the registry.
p.PublicKey[0] ^= 0xff
p.ChainHash[0] ^= 0xff
again, _ := reg.Lookup(profile.QuicknetID)
if err := again.Validate(); err != nil {
t.Fatalf("registry state was mutated through a lookup: %v", err)
}
if _, ok := reg.Lookup("datekeys:evmnet:v1"); ok {
t.Fatal("unknown profile found")
}
var wrong [32]byte
if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: wrong}); !errors.Is(err, datekeys.ErrProfileMismatch) {
t.Fatalf("wrong pinned hash accepted: %v", err)
}
h, _ := profile.Quicknet().Hash()
if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: h}, profile.Pin{Profile: profile.Quicknet(), Hash: h}); err == nil {
t.Fatal("duplicate profile accepted")
}
bad := profile.Quicknet()
bad.ChainHash[31] ^= 1
bh, _ := bad.Hash()
if _, err := profile.NewRegistry(profile.Pin{Profile: bad, Hash: bh}); !errors.Is(err, datekeys.ErrProfileMismatch) {
t.Fatalf("self-inconsistent profile pinned: %v", err)
}
}
func TestMaxRound(t *testing.T) {
p := profile.Quicknet()
if got := p.MaxRound(); got != 83903165811 {
t.Fatalf("MaxRound = %d", got)
}
}
func FuzzDecode(f *testing.F) {
b, _ := profile.Quicknet().CanonicalCBOR()
f.Add(b)
f.Add(b[:100])
f.Fuzz(func(t *testing.T, in []byte) {
p, err := profile.Decode(in)
if err != nil {
if datekeys.Code(err) == "" {
t.Fatalf("error without a normative code: %v", err)
}
return
}
out, err := p.CanonicalCBOR()
if err != nil || !bytes.Equal(out, in) {
t.Fatal("accepted a profile that does not re-encode to its input")
}
})
}

@ -0,0 +1,53 @@
package profile
import (
"encoding/hex"
"time"
)
// Quicknet Provider Profile V1 parameters, pinned in the binary (spec §12).
// No relay, API or ciphertext can replace them (spec §13, §35).
const (
QuicknetID = "datekeys:quicknet:v1"
QuicknetNetwork = "quicknet"
QuicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
QuicknetPublicKey = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"
QuicknetGenesisSeed = "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e"
QuicknetGenesisTime = int64(1692803367)
QuicknetPeriod = 3 * time.Second
QuicknetScheme = "bls-unchained-g1-rfc9380"
// QuicknetProfileHash is profile_hash of the Quicknet profile: SHA-256 of
// its exact Deterministic CBOR (spec §11). It is the first official vector,
// frozen in testdata/vectors/profile_quicknet.json, and part of the root of
// trust of spec §13: Default refuses to build if the compiled-in
// parameters do not reproduce it.
QuicknetProfileHash = "4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4"
)
// Quicknet returns a fresh copy of the pinned Quicknet Provider Profile V1.
// A function instead of a package variable keeps the root of trust free of
// shared mutable state.
func Quicknet() *Profile {
p := &Profile{
ID: QuicknetID,
Provider: ProviderDrand,
Network: QuicknetNetwork,
PublicKey: mustHex(QuicknetPublicKey),
Period: QuicknetPeriod,
GenesisTime: QuicknetGenesisTime,
Scheme: QuicknetScheme,
}
copy(p.ChainHash[:], mustHex(QuicknetChainHash))
copy(p.GenesisSeed[:], mustHex(QuicknetGenesisSeed))
return p
}
// mustHex decodes compile-time constants only.
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic("profile: invalid pinned constant: " + err.Error())
}
return b
}

@ -0,0 +1,81 @@
package profile
import (
"encoding/hex"
"fmt"
datekeys "github.com/datekeys/datekeys-go"
)
// Registry resolves a profile_id to a locally trusted Provider Profile. The
// client MUST NOT accept a profile or key supplied by the endpoint that
// delivers the release (spec §13); a Registry is built by the caller from
// pinned data only.
type Registry interface {
// Lookup returns a copy of the pinned profile for id.
Lookup(id string) (*Profile, bool)
}
// Pin is a profile together with the profile_hash the caller expects it to
// have (spec §13: the profile hash is known in advance).
type Pin struct {
Profile *Profile
Hash [32]byte
}
type pinned struct {
profile *Profile
hash [32]byte
}
type registry struct {
m map[string]pinned
}
// NewRegistry validates every profile, checks it against its expected
// profile_hash and returns an immutable registry holding private copies.
func NewRegistry(pins ...Pin) (Registry, error) {
r := &registry{m: make(map[string]pinned, len(pins))}
for _, pin := range pins {
if pin.Profile == nil {
return nil, fmt.Errorf("profile: nil profile in registry: %w", datekeys.ErrUnknownProfile)
}
p := pin.Profile.Clone()
if err := p.Validate(); err != nil {
return nil, err
}
h, err := p.Hash()
if err != nil {
return nil, err
}
if h != pin.Hash {
return nil, fmt.Errorf("profile %s: profile_hash %x does not match the pinned %x: %w",
p.ID, h, pin.Hash, datekeys.ErrProfileMismatch)
}
if _, dup := r.m[p.ID]; dup {
return nil, fmt.Errorf("profile %s: pinned twice", p.ID)
}
r.m[p.ID] = pinned{profile: p, hash: h}
}
return r, nil
}
func (r *registry) Lookup(id string) (*Profile, bool) {
e, ok := r.m[id]
if !ok {
return nil, false
}
return e.profile.Clone(), true
}
// Default returns the default registry, which contains only the Quicknet
// profile checked against QuicknetProfileHash.
func Default() (Registry, error) {
var h [32]byte
b, err := hex.DecodeString(QuicknetProfileHash)
if err != nil || len(b) != len(h) {
return nil, fmt.Errorf("profile: invalid pinned Quicknet profile hash: %w", datekeys.ErrProfileMismatch)
}
copy(h[:], b)
return NewRegistry(Pin{Profile: Quicknet(), Hash: h})
}

@ -0,0 +1,149 @@
// Package drand fetches Quicknet releases directly from public drand relays
// (spec §48, §49). HTTP is an untrusted transport: every response is verified
// locally with provider.Verify against the pinned profile before it is
// returned, and authenticity comes from the BLS signature, never from the
// hostname (spec §48, §52).
package drand
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// Limits of a single relay exchange.
const (
DefaultTimeout = 6 * time.Second
maxResponseSize = 8 << 10
)
// DefaultRelays returns the public drand relays used when none are given.
func DefaultRelays() []string {
return []string{"https://api.drand.sh", "https://api2.drand.sh", "https://api3.drand.sh"}
}
// Client races independent relays and returns the first release that passes
// local verification. It implements provider.ReleaseSource.
type Client struct {
http *http.Client
relays []string
timeout time.Duration
}
var _ provider.ReleaseSource = (*Client)(nil)
// New returns a client for the given relay base URLs, or DefaultRelays.
// Redirects are not followed.
func New(relays ...string) *Client {
return NewWithHTTPClient(&http.Client{
Timeout: DefaultTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}, relays...)
}
// NewWithHTTPClient is New with a caller-supplied HTTP client.
func NewWithHTTPClient(hc *http.Client, relays ...string) *Client {
if len(relays) == 0 {
relays = DefaultRelays()
}
return &Client{http: hc, relays: append([]string(nil), relays...), timeout: DefaultTimeout}
}
// Fetch implements provider.ReleaseSource. Only a cryptographically valid
// release for exactly the requested round wins the race.
func (c *Client) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
if p.Provider != profile.ProviderDrand {
return provider.Release{}, fmt.Errorf("drand: profile %s is not a drand profile: %w", p.ID, datekeys.ErrUnknownProfile)
}
if cond.Round == 0 || cond.Round > p.MaxRound() {
return provider.Release{}, fmt.Errorf("drand: round %d outside the range of %s: %w", cond.Round, p.ID, datekeys.ErrDateKeyInvalid)
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
type result struct {
release provider.Release
err error
}
ch := make(chan result, len(c.relays))
for _, relay := range c.relays {
go func(relay string) {
r, err := c.fetch(ctx, relay, p, cond)
ch <- result{r, err}
}(relay)
}
var failures []error
for range c.relays {
select {
case <-ctx.Done():
return provider.Release{}, fmt.Errorf("drand: %w: %w", datekeys.ErrReleaseUnavailable, ctx.Err())
case r := <-ch:
if r.err == nil {
return r.release, nil
}
failures = append(failures, r.err)
}
}
return provider.Release{}, fmt.Errorf("drand: no relay returned a verified release for round %d: %w: %w",
cond.Round, datekeys.ErrReleaseUnavailable, errors.Join(failures...))
}
func (c *Client) fetch(ctx context.Context, relay string, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
url := strings.TrimRight(relay, "/") + "/v2/chains/" + p.ChainHashHex() + "/rounds/" + strconv.FormatUint(cond.Round, 10)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
req.Header.Set("Accept", "application/json")
res, err := c.http.Do(req)
if err != nil {
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return provider.Release{}, fmt.Errorf("%s: HTTP %d", relay, res.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(res.Body, maxResponseSize+1))
if err != nil {
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
if len(b) > maxResponseSize {
return provider.Release{}, fmt.Errorf("%s: response larger than %d bytes: %w", relay, maxResponseSize, datekeys.ErrReleaseInvalid)
}
var wire struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
Randomness string `json:"randomness"`
}
if err := json.Unmarshal(b, &wire); err != nil {
return provider.Release{}, fmt.Errorf("%s: malformed response: %w", relay, datekeys.ErrReleaseInvalid)
}
sig, err := hex.DecodeString(wire.Signature)
if err != nil {
return provider.Release{}, fmt.Errorf("%s: signature is not hex: %w", relay, datekeys.ErrReleaseInvalid)
}
release := provider.Release{Round: wire.Round, Signature: sig}
if err := provider.Verify(p, cond, release); err != nil {
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
// The v2 API omits randomness; if a relay supplies it, it must be
// SHA-256 of the verified signature.
if wire.Randomness != "" {
sum := sha256.Sum256(sig)
if !strings.EqualFold(wire.Randomness, hex.EncodeToString(sum[:])) {
return provider.Release{}, fmt.Errorf("%s: randomness does not match the signature: %w", relay, datekeys.ErrReleaseInvalid)
}
}
return release, nil
}

@ -0,0 +1,122 @@
package drand_test
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
"github.com/datekeys/datekeys-go/provider/drand"
)
var sig1000 = hex.EncodeToString(testkit.Release(1000).Signature)
func serve(t *testing.T, h http.HandlerFunc) string {
t.Helper()
s := httptest.NewServer(h)
t.Cleanup(s.Close)
return s.URL
}
func TestRaceWaitsForAValidSignature(t *testing.T) {
p := profile.Quicknet()
bad := serve(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, strings.Repeat("0", 96))
})
good := serve(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/chains/"+p.ChainHashHex()+"/rounds/1000" {
t.Errorf("request not pinned to the chain: %s", r.URL.Path)
}
time.Sleep(25 * time.Millisecond)
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, sig1000)
})
c := drand.NewWithHTTPClient(http.DefaultClient, bad, good)
rel, err := c.Fetch(context.Background(), p, provider.Condition{Round: 1000})
if err != nil || hex.EncodeToString(rel.Signature) != sig1000 || rel.Round != 1000 {
t.Fatalf("race failed: %+v %v", rel, err)
}
}
func TestRejectMalformedRelayResponses(t *testing.T) {
p := profile.Quicknet()
for _, payload := range []string{
`{"round":999,"signature":"` + sig1000 + `"}`,
`{"round":1000,"signature":"` + sig1000 + `","randomness":"fake"}`,
`{"round":1000,"signature":"fake"}`,
`{"round":1000,"signature":"` + sig1000 + `"} {}`,
`{"round":1000,"signature":"` + hex.EncodeToString(testkit.Release(1001).Signature) + `"}`,
strings.Repeat("x", 9000),
``,
} {
url := serve(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, payload) })
c := drand.NewWithHTTPClient(http.DefaultClient, url)
_, err := c.Fetch(context.Background(), p, provider.Condition{Round: 1000})
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Errorf("accepted or misclassified %.40q: %v", payload, err)
}
}
}
func TestRandomnessMustMatchWhenPresent(t *testing.T) {
p := profile.Quicknet()
sum := "e1f1cb5a9ddd0e2ae4a4a8c5bf42e8e1e1ed8b5a9f1f7da1a3f1d2bb0f1b2c3d"
url := serve(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"round":1000,"signature":"%s","randomness":"%s"}`, sig1000, sum)
})
_, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(context.Background(), p, provider.Condition{Round: 1000})
if !errors.Is(err, datekeys.ErrReleaseInvalid) {
t.Fatalf("wrong randomness accepted: %v", err)
}
}
func TestUnavailabilityAndCancellation(t *testing.T) {
p := profile.Quicknet()
for _, status := range []int{404, 425, 503} {
url := serve(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) })
_, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(context.Background(), p, provider.Condition{Round: 1000})
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("HTTP %d: %v", status, err)
}
}
url := serve(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(time.Second) })
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(ctx, p, provider.Condition{Round: 1000}); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("ignored cancellation: %v", err)
}
}
func TestRedirectsAreNotFollowed(t *testing.T) {
p := profile.Quicknet()
target := serve(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, sig1000)
})
redirect := serve(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target+r.URL.Path, http.StatusFound)
})
if _, err := drand.New(redirect).Fetch(context.Background(), p, provider.Condition{Round: 1000}); err == nil {
t.Fatal("followed a redirect")
}
}
func TestInvalidRequests(t *testing.T) {
c := drand.New("http://127.0.0.1:1")
p := profile.Quicknet()
if _, err := c.Fetch(context.Background(), p, provider.Condition{Round: 0}); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Fatalf("round 0: %v", err)
}
other := profile.Quicknet()
other.Provider = "other"
if _, err := c.Fetch(context.Background(), other, provider.Condition{Round: 1}); !errors.Is(err, datekeys.ErrUnknownProfile) {
t.Fatalf("non-drand profile: %v", err)
}
}

@ -0,0 +1,30 @@
//go:build integration
package drand_test
import (
"bytes"
"context"
"testing"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
"github.com/datekeys/datekeys-go/provider/drand"
)
// Every default relay serves the same, locally verified release.
func TestLiveRelays(t *testing.T) {
p := profile.Quicknet()
for _, relay := range drand.DefaultRelays() {
for _, round := range testkit.Rounds {
rel, err := drand.New(relay).Fetch(context.Background(), p, provider.Condition{Round: round})
if err != nil {
t.Fatalf("%s round %d: %v", relay, round, err)
}
if !bytes.Equal(rel.Signature, testkit.Release(round).Signature) {
t.Fatalf("%s round %d: unexpected signature", relay, round)
}
}
}
}

@ -0,0 +1,75 @@
// Package provider defines conditions, releases and release sources (spec §9,
// §45-§52) and the local verification every release must pass.
//
// A release is never trusted because of where it came from: the DateKeys API,
// a cache or a relay are untrusted transports (spec §3, §48, §52). A remote
// "verified: true" has no security value (spec §51).
package provider
import (
"context"
"fmt"
"github.com/drand/drand/v2/common"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/profile"
)
// Condition is the time condition of a Quicknet-style profile: a round.
type Condition struct {
Round uint64
}
// Release is the material that satisfies a condition. For drand it is the
// BLS signature of the round.
type Release struct {
Round uint64
Signature []byte
}
// ReleaseSource fetches the release of a condition. Implementations need not
// verify it; callers always do, with Verify.
//
// Errors should wrap datekeys.ErrReleaseUnavailable when the release cannot be
// obtained, for example because the round is not published yet.
type ReleaseSource interface {
Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error)
}
// ReleaseSourceFunc adapts a function to ReleaseSource.
type ReleaseSourceFunc func(ctx context.Context, p *profile.Profile, c Condition) (Release, error)
// Fetch calls f.
func (f ReleaseSourceFunc) Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error) {
return f(ctx, p, c)
}
// Verify checks a release locally against the pinned profile (spec §17, §51):
// the expected round, the signature length of the scheme and a valid BLS
// signature under the pinned public key. The chain hash is covered because
// the pinned public key is bound to it by profile.Validate.
func Verify(p *profile.Profile, c Condition, r Release) error {
if c.Round == 0 || c.Round > p.MaxRound() {
return fmt.Errorf("provider: round %d outside the range of %s: %w", c.Round, p.ID, datekeys.ErrDateKeyInvalid)
}
if r.Round != c.Round {
return fmt.Errorf("provider: release for round %d, expected %d: %w", r.Round, c.Round, datekeys.ErrRoundMismatch)
}
scheme, err := p.DrandScheme()
if err != nil {
return err
}
if want := scheme.SigGroup.PointLen(); len(r.Signature) != want {
return fmt.Errorf("provider: signature is %d bytes, %s uses %d: %w", len(r.Signature), scheme.Name, want, datekeys.ErrReleaseInvalid)
}
key := scheme.KeyGroup.Point()
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
return fmt.Errorf("provider: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
}
beacon := &common.Beacon{Round: r.Round, Signature: r.Signature}
if err := scheme.VerifyBeacon(beacon, key); err != nil {
return fmt.Errorf("provider: BLS signature of round %d does not verify under %s: %w", r.Round, p.ID, datekeys.ErrReleaseInvalid)
}
return nil
}

@ -0,0 +1,66 @@
package provider_test
import (
"bytes"
"errors"
"testing"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/internal/testkit"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
func TestVerifyPublishedReleases(t *testing.T) {
p := profile.Quicknet()
for _, round := range testkit.Rounds {
if err := provider.Verify(p, provider.Condition{Round: round}, testkit.Release(round)); err != nil {
t.Fatalf("round %d: %v", round, err)
}
}
}
func TestVerifyRejects(t *testing.T) {
p := profile.Quicknet()
r1000, r1001 := testkit.Release(1000), testkit.Release(1001)
for _, tc := range []struct {
name string
cond uint64
rel provider.Release
want error
}{
// Spec §17: a valid signature of another round is not enough.
{"valid release of another round", 1000, r1001, datekeys.ErrRoundMismatch},
// A signature of round 1001 relabelled as round 1000.
{"signature of another round relabelled", 1000, provider.Release{Round: 1000, Signature: r1001.Signature}, datekeys.ErrReleaseInvalid},
{"all-zero signature", 1000, provider.Release{Round: 1000, Signature: make([]byte, 48)}, datekeys.ErrReleaseInvalid},
{"flipped bit", 1000, provider.Release{Round: 1000, Signature: flip(r1000.Signature)}, datekeys.ErrReleaseInvalid},
{"short signature", 1000, provider.Release{Round: 1000, Signature: r1000.Signature[:47]}, datekeys.ErrReleaseInvalid},
{"G2-sized signature", 1000, provider.Release{Round: 1000, Signature: bytes.Repeat(r1000.Signature, 2)}, datekeys.ErrReleaseInvalid},
{"round zero", 0, provider.Release{Round: 0, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid},
{"round beyond the profile", p.MaxRound() + 1, provider.Release{Round: p.MaxRound() + 1, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid},
} {
t.Run(tc.name, func(t *testing.T) {
if err := provider.Verify(p, provider.Condition{Round: tc.cond}, tc.rel); !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
}
}
func TestVerifyUsesThePinnedKeyOnly(t *testing.T) {
// A profile with another public key rejects the genuine signature.
p := profile.Quicknet()
p.PublicKey = append([]byte(nil), p.PublicKey...)
p.PublicKey[len(p.PublicKey)-1] ^= 1
err := provider.Verify(p, provider.Condition{Round: 1000}, testkit.Release(1000))
if err == nil {
t.Fatal("verified under a different key")
}
}
func flip(b []byte) []byte {
c := append([]byte(nil), b...)
c[10] ^= 0x01
return c
}

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Runs every parser fuzz target for the given duration each (default 20s).
# FUZZ_PARALLEL limits the number of fuzzing workers; each one keeps a
# 100 MB shared-memory file in the temporary directory.
# FUZZ_MINIMIZE is the time spent minimising each new input (default 0):
# minimisation stalls FuzzInspect for minutes, so short runs skip it; a
# failing input is still saved under testdata/fuzz as found.
set -euo pipefail
duration="${1:-20s}"
parallel=(-fuzzminimizetime="${FUZZ_MINIMIZE:-0}")
if [[ -n "${FUZZ_PARALLEL:-}" ]]; then
parallel+=(-parallel "$FUZZ_PARALLEL")
fi
targets=(
"./codec FuzzValid"
"./profile FuzzDecode"
"./datekey FuzzParse"
"./agewrap FuzzStanzas"
"./accesskey FuzzDecode"
"./capsule FuzzParsePrelude"
"./capsule FuzzDecodeHeader"
"./capsule FuzzDecodeControl"
"./capsule FuzzInspect"
)
for t in "${targets[@]}"; do
read -r pkg name <<<"$t"
echo "== $pkg $name ($duration)"
go test -run='^$' -fuzz="^${name}\$" -fuzztime="$duration" "${parallel[@]}" "$pkg"
done

File diff suppressed because it is too large Load Diff

@ -0,0 +1,16 @@
# Specification
- `DateKeys_Protocol_Specification_v0.8.1.md`: frozen copy of the normative
draft v0.8.1 (25 September 2026) implemented by this module. SHA-256:
`8beee534efecf19dcdee765f7d198b3ce4da12c799ada525e64878dc263f6fad`.
- `datekeys.cddl`: the CBOR schemas of the specification as implemented, with
the encoding rules CDDL cannot express.
The specification is licensed under the Creative Commons Attribution 4.0
International License (CC-BY-4.0): <https://creativecommons.org/licenses/by/4.0/>.
The code of this repository is licensed separately under Apache-2.0.
Changes to the specification follow its §76: a normative change should answer a
reproducible case found through the reference implementation, the CDDL, a
fixture, a mutation test, an interoperability test, fuzzing, a second
implementation or an external review.

@ -0,0 +1,98 @@
; DateKeys Protocol Specification v0.8.1 - CBOR schemas (RFC 8610 CDDL).
;
; Normative companion of spec/DateKeys_Protocol_Specification_v0.8.1.md, as
; implemented by the reference implementation github.com/datekeys/datekeys-go.
;
; Encoding rules that CDDL cannot express (spec section 58, 58.1):
; - Every structure is Deterministic CBOR (RFC 8949 section 4.2.1): map keys
; sorted by their encoded bytes, shortest-form integers and lengths,
; definite lengths only, no tags.
; - A decoder re-encodes what it decoded and rejects any byte difference
; (ERR_NON_CANONICAL_CBOR).
; - A semantically absent optional field is omitted. Empty arrays, empty
; maps, null and "" never stand for absence; the .size and non-empty
; constraints below make those forms invalid.
; - Maps are closed: keys not listed here are rejected. New semantics go in
; extensions (spec section 54).
; - Within one object an extension_id appears at most once and never in both
; extension arrays; arrays are sorted by the UTF-8 bytes of extension_id and
; then by extension_version.
; Spec section 11 and 12.
provider-profile = {
0 => "datekeys-provider-profile",
1 => 1,
2 => profile-id,
3 => name, ; provider, "drand"
4 => name, ; provider network identifier, "quicknet"
5 => bstr .size 32, ; chain_hash
6 => bstr, ; group public key
7 => uint .ge 1, ; period in seconds
8 => uint, ; genesis_time, Unix seconds
9 => name, ; scheme, "bls-unchained-g1-rfc9380"
10 => bstr .size 32, ; genesis_seed
}
; Spec section 24. Stored as exact bytes after the 16-byte PRELUDE and covered
; by header_binding = SHA-256(PRELUDE || PUBLIC_HEADER).
public-header = {
0 => "datekeycap",
1 => 1,
2 => capsule-id,
3 => compact-datekey, ; the only source of the profile
4 => access-policy,
? 5 => extensions, ; critical_extensions
? 6 => extensions, ; noncritical_extensions
}
; Spec section 31. Sealed inside OUTER_TIME_AGE (time_only) or inside
; INNER_ACCESS_AGE inside OUTER_TIME_AGE (time_and_key).
control = {
0 => "datekeys-control",
1 => 1,
2 => bstr .size 32, ; header_binding
3 => bstr .size 32, ; payload_identity, raw X25519 identity I_PAYLOAD
? 4 => extensions, ; critical_extensions
? 5 => extensions, ; noncritical_extensions
}
; Spec section 41. BODY_CBOR of a .dkk, after the 12-byte DKK1 prelude.
access-key-body = {
0 => "datekeys-access-key",
1 => 1,
2 => bstr .size 16, ; credential_id
3 => capsule-id,
4 => access-type,
5 => access-material,
? 6 => verification-metadata,
? 7 => extensions, ; critical_extensions
? 8 => extensions, ; noncritical_extensions
}
; Spec section 43. Present only when it holds a digest: never an empty map.
verification-metadata = {
0 => bstr .size 32, ; capsule_digest = SHA-256(exact .dkc bytes)
}
; Spec section 31 and 54.
extensions = [+ extension]
extension = {
0 => extension-id,
1 => uint, ; extension_version
? 2 => any, ; data, not interpreted by the base protocol
}
capsule-id = bstr .size 16
access-policy = &(time_only: 0, time_and_key: 1)
access-type = "x25519"
access-material = bstr .size 32 ; for access-type "x25519"
; Implementation limits of the reference implementation (spec section 74
; leaves definitive field limits open).
profile-id = tstr .regexp "[a-z0-9][a-z0-9:._-]{0,127}"
name = tstr .regexp "[a-z0-9][a-z0-9._-]{0,63}"
extension-id = tstr .size (1..256)
; Spec section 18 and 19: "dk1_" + unpadded Base64URL of the canonical JSON
; {"version":1,"network":<profile-id>,"round":<round>}, round in 1..2^53-1.
compact-datekey = tstr .regexp "dk1_[A-Za-z0-9_-]+"

Binary file not shown.

@ -0,0 +1,121 @@
{
"description": "time_only capsule with an empty payload",
"spec": "0.8.1",
"file": "empty_payload.dkc",
"sha256": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",
"release": {
"round": 1001,
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
},
"prelude": "444b43310100000000000079000001be",
"public_header": "a5006a646174656b657963617001010250ab10174561a9a19a6d9dc9ab1ef59c66037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d58300400",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
"capsule_id": "ab10174561a9a19a6d9dc9ab1ef59c66",
"access_policy": "time_only",
"structure": "time_only",
"unlock_at": "2023-08-23T15:59:27Z",
"header_binding": "33186a4f03d79eb8e28dbb82d2538ada45b68cd4f2ed6d17fa87b211e54f4d58",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1001",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"kT/xASH7NSOUvlk9XfIAh37JzSq6kWxPKVkSzflygjY"
]
}
],
"control_cbor": "a40070646174656b6579732d636f6e74726f6c010102582033186a4f03d79eb8e28dbb82d2538ada45b68cd4f2ed6d17fa87b211e54f4d58035820cd5ca142d51386860bf4ae4ae16740d498ef70ff534084acf5f4005b74f278c3",
"payload_identity": "cd5ca142d51386860bf4ae4ae16740d498ef70ff534084acf5f4005b74f278c3",
"plaintext_file": "empty_payload.plaintext",
"plaintext_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

Binary file not shown.

Binary file not shown.

@ -0,0 +1,13 @@
{
"description": "portable X25519 .dkk of time_and_key_portable.dkc",
"spec": "0.8.1",
"file": "time_and_key_portable.dkk",
"sha256": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
"access_type": "x25519",
"access_material": "3d5e4d51f21236ecff40d89c0adcf9aa325a24b91b46fd9d1afa482f54b2d2c6",
"capsule_digest": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"capsule": "time_and_key_portable.dkc",
"expected_result": "opens INNER_ACCESS_AGE of time_and_key_portable.dkc and yields its CONTROL_CBOR"
}

@ -0,0 +1,140 @@
{
"description": "time_and_key capsule whose only recipient is a portable .dkk",
"spec": "0.8.1",
"file": "time_and_key_portable.dkc",
"sha256": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"prelude": "444b4331010000000000007900000286",
"public_header": "a5006a646174656b657963617001010250448e134a13457c319cab7fceaf7ffa1f037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300401",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
"access_policy": "time_and_key",
"structure": "time_and_key",
"unlock_at": "2023-08-23T15:59:24Z",
"header_binding": "841fe789895abdd0ffecb0eb7562f4c4b4dfd0d1f8ec6fd251adbe1a89d5ab5f",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1000",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"/g3xW+kK9u65qfWLzS5huoFB/JIc9EqG3QBOfuO9PVQ"
]
}
],
"inner_stanzas": [
{
"type": "X25519",
"args": [
"FyePxd/IAO/2FpE3kQgiidbDsxRVuNM/eEI3SDbbamc"
]
}
],
"access_key_file": "time_and_key_portable.dkk",
"control_cbor": "a40070646174656b6579732d636f6e74726f6c0101025820841fe789895abdd0ffecb0eb7562f4c4b4dfd0d1f8ec6fd251adbe1a89d5ab5f0358202536e99b16373ca8d118695c600fd652541367b0198dbfaba9362a98f9fa70cb",
"payload_identity": "2536e99b16373ca8d118695c600fd652541367b0198dbfaba9362a98f9fa70cb",
"plaintext_file": "time_and_key_portable.plaintext",
"plaintext_sha256": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "access credential",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 13,
"name": "open access layer",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

@ -0,0 +1 @@
DateKeys fixture opened with a portable .dkk.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,13 @@
{
"description": "portable X25519 .dkk of time_and_key_recipients.dkc",
"spec": "0.8.1",
"file": "time_and_key_recipients.dkk",
"sha256": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
"credential_id": "b89292aedf6d05d584cec9a871ce8735",
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
"access_type": "x25519",
"access_material": "42d6d897097fd5af2772e058db17920afe1390e2856139bc2f800294564dd7ac",
"capsule_digest": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
"capsule": "time_and_key_recipients.dkc",
"expected_result": "opens INNER_ACCESS_AGE of time_and_key_recipients.dkc and yields its CONTROL_CBOR"
}

@ -0,0 +1,156 @@
{
"description": "time_and_key capsule for two known X25519 recipients and a portable .dkk",
"spec": "0.8.1",
"file": "time_and_key_recipients.dkc",
"sha256": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
"release": {
"round": 1001,
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
},
"prelude": "444b433101000000000000790000034a",
"public_header": "a5006a646174656b657963617001010250c75dfc8e9c576d1369910664df93693a037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d58300401",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
"access_policy": "time_and_key",
"structure": "time_and_key",
"unlock_at": "2023-08-23T15:59:27Z",
"header_binding": "5d789b4bde52beecbad4b80e9b0a8fec8b59b6a84263af6d022cd1eccfded0e2",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1001",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"ew9JpO0AGTGmWXpZCEHsSrN0Ey2kSjzphbokfjCMoF4"
]
}
],
"inner_stanzas": [
{
"type": "X25519",
"args": [
"sOnq+vJxTJVHlPwhfXd2LovGNqRMuyS3e7qJQm8at0E"
]
},
{
"type": "X25519",
"args": [
"T8NHQ9xo+IaBvEoqirkEgwdbFzOUt19moVhfvkebdUY"
]
},
{
"type": "X25519",
"args": [
"tIu+bu/q+z3ACTYdwpolbaSwA7PXv4QVjXbcrgOXAAM"
]
}
],
"access_key_file": "time_and_key_recipients.dkk",
"identities": [
"AGE-SECRET-KEY-1DPM6CQMQV3665FK762HTVC37XAY6X99MM4YLJ9J0J2DQD7K63GCSG5TMCK",
"AGE-SECRET-KEY-15MEM79HAM2XQ79HN5QVCLECUDM4JQQKWEE9JWR3VV2FWK033AXPQQ2422Y"
],
"control_cbor": "a40070646174656b6579732d636f6e74726f6c01010258205d789b4bde52beecbad4b80e9b0a8fec8b59b6a84263af6d022cd1eccfded0e20358205d3f4172eca48e5d5b2570208cfe3298c4735f1d77ceaed958bcccb8eec18a12",
"payload_identity": "5d3f4172eca48e5d5b2570208cfe3298c4735f1d77ceaed958bcccb8eec18a12",
"plaintext_file": "time_and_key_recipients.plaintext",
"plaintext_sha256": "0e9fd50e98a85953aa9cf07a11ee3c62bb3d7622f344f1c6ce744d1ed111659f",
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "access credential",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 13,
"name": "open access layer",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

@ -0,0 +1 @@
DateKeys fixture for several recipients.

Binary file not shown.

@ -0,0 +1,121 @@
{
"description": "time_only capsule, two STREAM chunks, no extensions",
"spec": "0.8.1",
"file": "time_only.dkc",
"sha256": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"prelude": "444b43310100000000000079000001be",
"public_header": "a5006a646174656b657963617001010250ad4d676812b134ff8a3de263f77018b4037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300400",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"capsule_id": "ad4d676812b134ff8a3de263f77018b4",
"access_policy": "time_only",
"structure": "time_only",
"unlock_at": "2023-08-23T15:59:24Z",
"header_binding": "8e1d05df55bc626a579759d54d436b512b7bfc71039d3c12c1875a5b475f2417",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1000",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"LvR2+5baviJPeIiyw96VfTW1GnzTw3DbWIPGuq9amEw"
]
}
],
"control_cbor": "a40070646174656b6579732d636f6e74726f6c01010258208e1d05df55bc626a579759d54d436b512b7bfc71039d3c12c1875a5b475f2417035820e63d28ff1a6d1975263db49ff80c3bf949737d20aeedcc9539e648ffd330082b",
"payload_identity": "e63d28ff1a6d1975263db49ff80c3bf949737d20aeedcc9539e648ffd330082b",
"plaintext_file": "time_only.plaintext",
"plaintext_sha256": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

@ -0,0 +1,137 @@
{
"description": "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension",
"spec": "0.8.1",
"file": "time_only_extensions.dkc",
"sha256": "1e7effd58016d9447f9a2e7c9645c658604979c4e35af675d1dc6c4ae12ac444",
"release": {
"round": 2000,
"signature": "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e"
},
"prelude": "444b4331010000000000009f000001e2",
"public_header": "a6006a646174656b657963617001010250d2fd9af55dc0253132fb36b8dc0d016b037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d6a41774d483004000681a300716f72672e6578616d706c652e6c6162656c0101026c7075626c6963206c6162656c",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0",
"capsule_id": "d2fd9af55dc0253132fb36b8dc0d016b",
"access_policy": "time_only",
"structure": "time_only",
"unlock_at": "2023-08-23T16:49:24Z",
"header_binding": "a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc154",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"2000",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"pUUIuCTHAZ3+kpMwJQQ9dc3EJO1zPagucu+VdFovSy0"
]
}
],
"control_cbor": "a50070646174656b6579732d636f6e74726f6c0101025820a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc1540358201b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb80581a300706f72672e6578616d706c652e6e6f7465010202a2616e07667365616c6564f5",
"payload_identity": "1b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb8",
"plaintext_file": "time_only_extensions.plaintext",
"plaintext_sha256": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
"header_extensions": [
{
"critical": false,
"id": "org.example.label",
"version": 1,
"data": "6c7075626c6963206c6162656c"
}
],
"control_extensions": [
{
"critical": false,
"id": "org.example.note",
"version": 2,
"data": "a2616e07667365616c6564f5"
}
],
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

@ -0,0 +1 @@
DateKeys fixture with extensions.

@ -0,0 +1,153 @@
{
"spec": "0.8.1",
"description": "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
"vectors": [
{
"name": "round 1",
"network": "datekeys:quicknet:v1",
"round": 1,
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":1}",
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MX0",
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MX0"
},
{
"name": "round 1000",
"network": "datekeys:quicknet:v1",
"round": 1000,
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":1000}",
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0"
},
{
"name": "normative 2030-01-01 round",
"network": "datekeys:quicknet:v1",
"round": 66884212,
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":66884212}",
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9"
},
{
"name": "last Quicknet round",
"network": "datekeys:quicknet:v1",
"round": 83903165811,
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":83903165811}",
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6ODM5MDMxNjU4MTF9",
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6ODM5MDMxNjU4MTF9"
},
{
"name": "whitespace in JSON",
"input": "dk1_eyJ2ZXJzaW9uIjogMSwgIm5ldHdvcmsiOiAiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCAicm91bmQiOiA2Njg4NDIxMn0",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "keys reordered",
"input": "dk1_eyJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJ2ZXJzaW9uIjoxLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "trailing whitespace",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9Cg",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "exponent notation",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6Ni42ODg0MjEyZTd9",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "fraction notation",
"input": "dk1_eyJ2ZXJzaW9uIjoxLjAsIm5ldHdvcmsiOiJkYXRla2V5czpxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "escaped character",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXNcdTAwM2FxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "duplicate key",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MSwicm91bmQiOjY2ODg0MjEyfQ",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "padded Base64URL",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0=",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "non-zero trailing bits",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH1",
"error": "ERR_DATEKEY_NON_CANONICAL"
},
{
"name": "extra field",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTIsInB1YmxpY19rZXkiOiIwMCJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "missing field",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEifQ",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "version 2",
"input": "dk1_eyJ2ZXJzaW9uIjoyLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "round 0",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MH0",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "negative round",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6LTF9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "fractional round",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MS41fQ",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "round above 2^53-1",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6OTAwNzE5OTI1NDc0MDk5Mn0",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "round as string",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6IjY2ODg0MjEyIn0",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "uppercase network",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiREFURUtFWVM6UVVJQ0tORVQ6VjEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "trailing data",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9eA",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "byte order mark",
"input": "dk1_77u_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "not Base64",
"input": "dk1_!!!",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "missing prefix",
"input": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "uppercase prefix",
"input": "DK1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
}
]
}

@ -0,0 +1,15 @@
{
"spec": "0.8.1",
"description": "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
"profile_id": "datekeys:quicknet:v1",
"provider": "drand",
"network": "quicknet",
"chain_hash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
"public_key": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
"period_seconds": 3,
"genesis_time": 1692803367,
"genesis_seed": "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e",
"scheme": "bls-unchained-g1-rfc9380",
"canonical_cbor": "ab007819646174656b6579732d70726f76696465722d70726f66696c6501010274646174656b6579733a717569636b6e65743a763103656472616e640468717569636b6e657405582052db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e97106586083cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a0703081a64e62127097818626c732d756e636861696e65642d67312d726663393338300a5820f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e",
"profile_hash": "4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4"
}

@ -0,0 +1,107 @@
{
"spec": "0.8.1",
"profile": "datekeys:quicknet:v1",
"description": "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
"vectors": [
{
"name": "genesis exactly: round 1",
"requested": "2023-08-23T15:09:27Z",
"round": 1,
"effective": "2023-08-23T15:09:27Z"
},
{
"name": "genesis + 1ns: next round",
"requested": "2023-08-23T15:09:27.000000001Z",
"round": 2,
"effective": "2023-08-23T15:09:30Z"
},
{
"name": "genesis + 1s",
"requested": "2023-08-23T15:09:28Z",
"round": 2,
"effective": "2023-08-23T15:09:30Z"
},
{
"name": "genesis + one period: round 2",
"requested": "2023-08-23T15:09:30Z",
"round": 2,
"effective": "2023-08-23T15:09:30Z"
},
{
"name": "genesis + one period + 1ns: round 3",
"requested": "2023-08-23T15:09:30.000000001Z",
"round": 3,
"effective": "2023-08-23T15:09:33Z"
},
{
"name": "genesis - 1s: before the profile",
"requested": "2023-08-23T15:09:26Z",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "round 1000 boundary exactly",
"requested": "2023-08-23T15:59:24Z",
"round": 1000,
"effective": "2023-08-23T15:59:24Z"
},
{
"name": "one second before the round 1000 boundary",
"requested": "2023-08-23T15:59:23Z",
"round": 1000,
"effective": "2023-08-23T15:59:24Z"
},
{
"name": "one second after the round 1000 boundary",
"requested": "2023-08-23T15:59:25Z",
"round": 1001,
"effective": "2023-08-23T15:59:27Z"
},
{
"name": "1ns after the round 1000 boundary",
"requested": "2023-08-23T15:59:24.000000001Z",
"round": 1001,
"effective": "2023-08-23T15:59:27Z"
},
{
"name": "half a second after the round 1000 boundary",
"requested": "2023-08-23T15:59:24.5Z",
"round": 1001,
"effective": "2023-08-23T15:59:27Z"
},
{
"name": "normative vector 2030-01-01 (spec §16)",
"requested": "2030-01-01T00:00:00Z",
"round": 66884212,
"effective": "2030-01-01T00:00:00Z"
},
{
"name": "1ns after 2030-01-01",
"requested": "2030-01-01T00:00:00.000000001Z",
"round": 66884213,
"effective": "2030-01-01T00:00:03Z"
},
{
"name": "normative vector round 66432123 (spec §16)",
"requested": "2029-12-16T07:15:33Z",
"round": 66432123,
"effective": "2029-12-16T07:15:33Z"
},
{
"name": "offset timezone equals UTC instant",
"requested": "2026-10-22T19:00:00.001+02:00",
"round": 33295013,
"effective": "2026-10-22T17:00:03Z"
},
{
"name": "last representable round time",
"requested": "9999-12-31T23:59:57Z",
"round": 83903165811,
"effective": "9999-12-31T23:59:57Z"
},
{
"name": "after the last representable round",
"requested": "9999-12-31T23:59:59Z",
"error": "ERR_DATEKEY_INVALID"
}
]
}
Loading…
Cancel
Save

Powered by TurnKey Linux.