commit 0bd38f18cf6910c0f7a7b657620b5027b8601457 Author: dev Date: Fri Sep 25 16:07:39 2026 +0200 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f84401e --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fedbbe7 --- /dev/null +++ b/.github/dependabot.yml @@ -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] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b67d84d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..f7fa6fc --- /dev/null +++ b/.github/workflows/nightly.yml @@ -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 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8f0d6be --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44e5f6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build outputs +/datekeys +/datekeys.exe +/dist/ +*.test + +# Coverage and profiles +*.out +*.cdx.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..945e800 --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..72a973e --- /dev/null +++ b/.goreleaser.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba67c1d --- /dev/null +++ b/CHANGELOG.md @@ -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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4d837d0 --- /dev/null +++ b/CONTRIBUTING.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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..86bd44c --- /dev/null +++ b/README.es.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b8f889 --- /dev/null +++ b/README.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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8786297 --- /dev/null +++ b/SECURITY.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`). diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000..19baccb --- /dev/null +++ b/TRADEMARKS.md @@ -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". diff --git a/accesskey/accesskey.go b/accesskey/accesskey.go new file mode 100644 index 0000000..3bc2db4 --- /dev/null +++ b/accesskey/accesskey.go @@ -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 +} diff --git a/accesskey/accesskey_test.go b/accesskey/accesskey_test.go new file mode 100644 index 0000000..bfdce09 --- /dev/null +++ b/accesskey/accesskey_test.go @@ -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 +} diff --git a/agewrap/agewrap.go b/agewrap/agewrap.go new file mode 100644 index 0000000..d544b7d --- /dev/null +++ b/agewrap/agewrap.go @@ -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 ", 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 +} diff --git a/agewrap/agewrap_test.go b/agewrap/agewrap_test.go new file mode 100644 index 0000000..3997693 --- /dev/null +++ b/agewrap/agewrap_test.go @@ -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) + }) +} diff --git a/capsule/conformance_test.go b/capsule/conformance_test.go new file mode 100644 index 0000000..7b0904b --- /dev/null +++ b/capsule/conformance_test.go @@ -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) + } + } +} diff --git a/capsule/encrypt.go b/capsule/encrypt.go new file mode 100644 index 0000000..8d65ec4 --- /dev/null +++ b/capsule/encrypt.go @@ -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 +} diff --git a/capsule/encrypt_test.go b/capsule/encrypt_test.go new file mode 100644 index 0000000..8ebded9 --- /dev/null +++ b/capsule/encrypt_test.go @@ -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") } diff --git a/capsule/example_test.go b/capsule/example_test.go new file mode 100644 index 0000000..ed6dd1f --- /dev/null +++ b/capsule/example_test.go @@ -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 +} diff --git a/capsule/framing.go b/capsule/framing.go new file mode 100644 index 0000000..fcba1fd --- /dev/null +++ b/capsule/framing.go @@ -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")) +} diff --git a/capsule/framing_test.go b/capsule/framing_test.go new file mode 100644 index 0000000..b7628ad --- /dev/null +++ b/capsule/framing_test.go @@ -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") + } +} diff --git a/capsule/fuzz_test.go b/capsule/fuzz_test.go new file mode 100644 index 0000000..7fcdcb8 --- /dev/null +++ b/capsule/fuzz_test.go @@ -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) } diff --git a/capsule/inspect.go b/capsule/inspect.go new file mode 100644 index 0000000..d850b04 --- /dev/null +++ b/capsule/inspect.go @@ -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 +} diff --git a/capsule/interop_test.go b/capsule/interop_test.go new file mode 100644 index 0000000..8f7c0f3 --- /dev/null +++ b/capsule/interop_test.go @@ -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") + } +} diff --git a/capsule/live_test.go b/capsule/live_test.go new file mode 100644 index 0000000..329e8fe --- /dev/null +++ b/capsule/live_test.go @@ -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) + } + }) + } +} diff --git a/capsule/mutation_test.go b/capsule/mutation_test.go new file mode 100644 index 0000000..4222a89 --- /dev/null +++ b/capsule/mutation_test.go @@ -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 . + 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 + 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)) } diff --git a/capsule/open.go b/capsule/open.go new file mode 100644 index 0000000..295d6c1 --- /dev/null +++ b/capsule/open.go @@ -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) +} diff --git a/cmd/datekeys/atomic.go b/cmd/datekeys/atomic.go new file mode 100644 index 0000000..8de4e5e --- /dev/null +++ b/cmd/datekeys/atomic.go @@ -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 +} diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go new file mode 100644 index 0000000..183d712 --- /dev/null +++ b/cmd/datekeys/main.go @@ -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, + }) +} diff --git a/cmd/datekeys/main_test.go b/cmd/datekeys/main_test.go new file mode 100644 index 0000000..d3057fc --- /dev/null +++ b/cmd/datekeys/main_test.go @@ -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) + } + } +} diff --git a/codec/bech32/bech32.go b/codec/bech32/bech32.go new file mode 100644 index 0000000..370ad13 --- /dev/null +++ b/codec/bech32/bech32.go @@ -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<>frombits != 0 { + return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits) + } + acc = acc<= 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 +} diff --git a/codec/bech32/bech32_test.go b/codec/bech32/bech32_test.go new file mode 100644 index 0000000..bd70018 --- /dev/null +++ b/codec/bech32/bech32_test.go @@ -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) + } + }() + } +} diff --git a/codec/codec.go b/codec/codec.go new file mode 100644 index 0000000..a796a92 --- /dev/null +++ b/codec/codec.go @@ -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) +} diff --git a/codec/codec_test.go b/codec/codec_test.go new file mode 100644 index 0000000..a45a4d0 --- /dev/null +++ b/codec/codec_test.go @@ -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) + } + }) +} diff --git a/datekey/datekey.go b/datekey/datekey.go new file mode 100644 index 0000000..4c11ba4 --- /dev/null +++ b/datekey/datekey.go @@ -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 +} diff --git a/datekey/datekey_test.go b/datekey/datekey_test.go new file mode 100644 index 0000000..be01445 --- /dev/null +++ b/datekey/datekey_test.go @@ -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") + } + }) +} diff --git a/docs/traceability.md b/docs/traceability.md new file mode 100644 index 0000000..55ee7f5 --- /dev/null +++ b/docs/traceability.md @@ -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. diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..fab2dd7 --- /dev/null +++ b/errors.go @@ -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 "" +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..2d034f6 --- /dev/null +++ b/errors_test.go @@ -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") + } +} diff --git a/extension/extension.go b/extension/extension.go new file mode 100644 index 0000000..a3fbd7f --- /dev/null +++ b/extension/extension.go @@ -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 +} diff --git a/extension/extension_test.go b/extension/extension_test.go new file mode 100644 index 0000000..16f4214 --- /dev/null +++ b/extension/extension_test.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..771146c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..20f23aa --- /dev/null +++ b/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= diff --git a/internal/testkit/agefile.go b/internal/testkit/agefile.go new file mode 100644 index 0000000..7959034 --- /dev/null +++ b/internal/testkit/agefile.go @@ -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 +} diff --git a/internal/testkit/builder.go b/internal/testkit/builder.go new file mode 100644 index 0000000..27e06c2 --- /dev/null +++ b/internal/testkit/builder.go @@ -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) +} diff --git a/internal/testkit/fixture.go b/internal/testkit/fixture.go new file mode 100644 index 0000000..a37a71e --- /dev/null +++ b/internal/testkit/fixture.go @@ -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) +} diff --git a/internal/testkit/genfixtures/main.go b/internal/testkit/genfixtures/main.go new file mode 100644 index 0000000..2d31481 --- /dev/null +++ b/internal/testkit/genfixtures/main.go @@ -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 +} diff --git a/internal/testkit/testkit.go b/internal/testkit/testkit.go new file mode 100644 index 0000000..0511206 --- /dev/null +++ b/internal/testkit/testkit.go @@ -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 +} diff --git a/internal/testkit/vectors.go b/internal/testkit/vectors.go new file mode 100644 index 0000000..7b7eec7 --- /dev/null +++ b/internal/testkit/vectors.go @@ -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 +} diff --git a/profile/profile.go b/profile/profile.go new file mode 100644 index 0000000..f08fe05 --- /dev/null +++ b/profile/profile.go @@ -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') } diff --git a/profile/profile_test.go b/profile/profile_test.go new file mode 100644 index 0000000..3198546 --- /dev/null +++ b/profile/profile_test.go @@ -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") + } + }) +} diff --git a/profile/quicknet.go b/profile/quicknet.go new file mode 100644 index 0000000..39c912b --- /dev/null +++ b/profile/quicknet.go @@ -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 +} diff --git a/profile/registry.go b/profile/registry.go new file mode 100644 index 0000000..b0eb46b --- /dev/null +++ b/profile/registry.go @@ -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 := ®istry{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}) +} diff --git a/provider/drand/client.go b/provider/drand/client.go new file mode 100644 index 0000000..cf0c10d --- /dev/null +++ b/provider/drand/client.go @@ -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 +} diff --git a/provider/drand/client_test.go b/provider/drand/client_test.go new file mode 100644 index 0000000..32299c8 --- /dev/null +++ b/provider/drand/client_test.go @@ -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) + } +} diff --git a/provider/drand/live_test.go b/provider/drand/live_test.go new file mode 100644 index 0000000..a27cf84 --- /dev/null +++ b/provider/drand/live_test.go @@ -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) + } + } + } +} diff --git a/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..0ecc395 --- /dev/null +++ b/provider/provider.go @@ -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 +} diff --git a/provider/provider_test.go b/provider/provider_test.go new file mode 100644 index 0000000..f48cc66 --- /dev/null +++ b/provider/provider_test.go @@ -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 +} diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh new file mode 100644 index 0000000..a4f875b --- /dev/null +++ b/scripts/fuzz.sh @@ -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 diff --git a/spec/DateKeys_Protocol_Specification_v0.8.1.md b/spec/DateKeys_Protocol_Specification_v0.8.1.md new file mode 100644 index 0000000..83e483d --- /dev/null +++ b/spec/DateKeys_Protocol_Specification_v0.8.1.md @@ -0,0 +1,1875 @@ +# DateKeys Protocol Specification +## DateKey, DateKeyCap (`.dkc`) y DateKeys Access Key (`.dkk`) +### Borrador normativo v0.8.1 + +**Estado:** Draft / pre-estándar +**Fecha:** 25 septiembre 2026 +**Proyecto:** DateKeys +**Implementación de referencia prevista:** Go +**Proveedor temporal V1:** drand Quicknet + +--- + +## 1. Alcance + +Este documento define exclusivamente el **protocolo base DateKeys**. + +Define: + +- **DateKey**: descriptor público de una condición temporal criptográfica. +- **DateKeyCap (`.dkc`)**: contenedor cifrado asociado a una DateKey y a una política de acceso. +- **DateKeys Access Key (`.dkk`)**: credencial portable de acceso. +- perfiles de proveedor; +- Quicknet como proveedor temporal V1; +- resolución fecha → ronda; +- framing de `.dkc` y `.dkk`; +- cifrado y bindings; +- políticas `time_only` y `time_and_key`; +- recipient X25519 V1; +- verificación; +- Release API; +- Release Cache; +- recuperación directa contra el proveedor; +- extensiones genéricas. + +Este documento **no define almacenamiento, descubrimiento, distribución ni aplicaciones construidas sobre DateKeys**. + +Una aplicación que necesite información de transporte, descubrimiento o integración DEBE expresarla mediante extensiones no críticas, sin modificar el núcleo del protocolo. + +--- + +## 2. Terminología normativa + +Las palabras **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY** y **OPTIONAL** se interpretan conforme a RFC 2119 / RFC 8174. + +- **MUST / DEBE**: requisito obligatorio. +- **MUST NOT / NO DEBE**: prohibición obligatoria. +- **SHOULD / DEBERÍA**: recomendación fuerte. +- **MAY / PUEDE**: opcional. + +--- + +## 3. Principio rector + +> **Nunca confiar en el servidor cuando la misma propiedad puede verificarse criptográficamente en el cliente.** + +Consecuencias: + +- fecha → condición se calcula localmente; +- Provider Profiles se pinnean localmente; +- releases se verifican localmente; +- `.dkc`, `.dkk`, APIs y relays se consideran entradas no confiables; +- DateKeys no debe necesitar plaintext ni secretos finales de acceso. + +--- + +## 4. Objetivos de seguridad + +DateKeys persigue: + +1. **Confidencialidad temporal** + Bajo los supuestos del proveedor, una condición temporal no debe poder satisfacerse antes del momento elegido. + +2. **Confidencialidad frente al operador** + DateKeys no debe necesitar conocer el plaintext ni la credencial final de acceso. + +3. **Verificabilidad local** + El SDK debe detectar condiciones, perfiles y releases manipulados. + +4. **Integridad** + Alteraciones del framing, cabecera, control o payload deben provocar fallo. + +5. **Interoperabilidad** + Implementaciones independientes deben producir y consumir objetos compatibles. + +6. **Recuperación independiente** + Siempre que el proveedor conserve o pueda servir el release necesario, el ciphertext siga disponible y las credenciales correspondientes existan, un objeto maduro debería poder abrirse sin pasar por la API DateKeys. + +7. **Extensibilidad** + Nuevos proveedores y extensiones no deben redefinir objetos antiguos. + +--- + +## 5. No objetivos + +El protocolo base no garantiza: + +- existencia perpetua de Quicknet; +- conservación perpetua del histórico de releases; +- resistencia post-cuántica del timelock V1; +- revocación de una copia ya distribuida; +- anonimato absoluto; +- autoría legal por metadatos; +- fecha probatoria solo por `created_at`; +- protección frente a un dispositivo ya comprometido; +- control del plaintext después de un descifrado legítimo. + +--- + +## 6. Fuera de alcance + +El protocolo base NO define: + +- dónde se almacena un `.dkc`; +- cómo se descubre un `.dkc`; +- cómo se distribuye un `.dkc`; +- políticas de disponibilidad del fichero; +- mecanismos de naming externos; +- mecanismos de entrega de una `.dkk`. + +Estas cuestiones pertenecen a capas superiores. + +--- + +## 7. Modelo de amenazas + +El protocolo asume potencialmente maliciosos: + +### 7.1 Servidor DateKeys + +Puede intentar: + +- devolver una ronda pasada; +- devolver un perfil falso; +- devolver releases inválidos; +- correlacionar consultas; +- servir datos obsoletos; +- guardar copias del ciphertext. + +### 7.2 Relay / MITM / DNS + +Puede: + +- suplantar endpoints; +- modificar respuestas; +- retrasarlas; +- denegar servicio. + +### 7.3 Poseedor de `.dkc` + +Puede: + +- modificar bytes; +- truncar; +- reordenar; +- intercambiar cabeceras; +- intercambiar controles; +- intercambiar payloads; +- cambiar una política declarada; +- intentar downgrade. + +### 7.4 Poseedor de `.dkk` + +Debe tratarse como poseedor de una capacidad sensible. + +### 7.5 Cadena de suministro + +Un SDK o dependencia comprometidos pueden: + +- sustituir la raíz de confianza; +- aceptar condiciones falsas; +- exfiltrar secretos; +- debilitar criptografía. + +### 7.6 Provider comprometido + +Si deja de cumplirse el supuesto de seguridad del provider, puede fallar la confidencialidad temporal. + +### 7.7 Adversario cuántico futuro + +El timelock Quicknet V1 no se considera post-cuántico. + +Existe riesgo **harvest now, decrypt later** para ciphertexts de larga duración. + +### 7.8 Dispositivo del creador + +Si está comprometido antes o durante el cifrado, el protocolo no puede impedir la copia del plaintext o de secretos. + +--- + +## 8. Objetos del protocolo + +```text +DateKey + ↓ +condición temporal pública + +DateKeyCap (.dkc) + ↓ +objeto protegido + +DateKeys Access Key (.dkk) + ↓ +capacidad adicional de acceso +``` + +--- + +## 9. Provider abstraction + +Toda condición temporal se expresa como: + +```text +provider +profile +condition +``` + +Ejemplo Quicknet: + +```json +{ + "provider": "drand", + "profile": "datekeys:quicknet:v1", + "condition": { + "round": 66884212 + } +} +``` + +El protocolo no presupone que todos los providers utilicen rondas. + +--- + +## 10. Provider Profile + +Un Provider Profile es inmutable. + +Debe definir: + +- `profile_id`; +- provider; +- identificador de red; +- parámetros criptográficos; +- parámetros de tiempo; +- reglas de validación; +- `profile_hash`. + +Cualquier cambio criptográficamente relevante exige otro perfil. + +--- + +## 11. Codificación canónica del Provider Profile + +V1 usa **Deterministic CBOR** conforme a RFC 8949. + +Quicknet se serializa como mapa CBOR con claves enteras: + +```text +0 → "datekeys-provider-profile" +1 → 1 +2 → "datekeys:quicknet:v1" +3 → "drand" +4 → "quicknet" +5 → <32-byte chain hash> +6 → +7 → 3 +8 → 1692803367 +9 → "bls-unchained-g1-rfc9380" +10 → +``` + +El hash: + +```text +profile_hash = +SHA-256(exact_deterministic_cbor_bytes) +``` + +La seguridad NO procede de un `profile_hash` autodeclarado por una entrada remota. + +La seguridad procede del perfil pinneado/confiado localmente. + +--- + +## 12. Quicknet Provider Profile V1 + +```text +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 +``` + +El SDK oficial DEBE pinnear estos parámetros o una representación firmada equivalente. + +--- + +## 13. Root of trust + +El cliente NO DEBE aceptar como raíz de confianza una clave pública o un perfil suministrados por el mismo endpoint que entrega el release. + +Debe conocer previamente: + +- chain hash; +- public key; +- scheme; +- genesis time; +- genesis seed; +- period; +- profile hash. + +--- + +## 14. DateKey + +Una DateKey: + +- es pública; +- no es una clave simétrica; +- no es una private key; +- no es una `.dkk`; +- no es un secreto. + +Representa una condición temporal verificable. + +--- + +## 15. Resolución Quicknet fecha → ronda + +Para Quicknet: + +```text +round_time(r) = +genesis_time + (r - 1) * period +``` + +El SDK MUST elegir la primera ronda cuyo: + +```text +round_time >= requested_unlock_at +``` + +Algoritmo: + +```text +candidate = +floor((timestamp - genesis_time) / period) + 1 + +if round_time(candidate) < requested_unlock_at: + candidate++ +``` + +`requested_unlock_at` MAY tener precisión inferior al segundo. La comparación +`round_time(candidate) < requested_unlock_at` MUST realizarse a la precisión +completa del instante solicitado; truncar o redondear el instante antes de +comparar está prohibido. + +Nunca se redondea hacia atrás. + +--- + +## 16. Vector normativo de ronda + +Con: + +```text +genesis_time = 1692803367 +period = 3 +requested_unlock_at = 2030-01-01T00:00:00Z +``` + +resultado: + +```text +round = 66884212 +round_time = 2030-01-01T00:00:00Z +``` + +La ronda: + +```text +66432123 +``` + +corresponde a: + +```text +2029-12-16T07:15:33Z +``` + +Los vectores definitivos MUST generarse desde la implementación de referencia y congelarse antes de `v1.0`. + +--- + +## 17. Ataque de ronda pasada + +Verificar únicamente una firma BLS válida NO basta. + +El SDK MUST exigir, cuando disponga de la fecha solicitada: + +```text +received_round == locally_resolved_round +``` + +y: + +```text +round_time(received_round) >= requested_unlock_at +``` + +--- + +## 18. Representación `dk1_` + +Por compatibilidad con el prototipo V1 conserva: + +```text +dk1_ +``` + +con JSON UTF-8. + +Payload canónico: + +```json +{"version":1,"network":"datekeys:quicknet:v1","round":66884212} +``` + +Representación: + +```text +dk1_ +``` + +sin padding. + +--- + +## 19. Canonicalidad `dk1_` + +Existe una única cadena válida para una DateKey V1. + +El parser MUST: + +1. Base64URL-decodificar. +2. Parsear JSON. +3. Validar campos. +4. Reemitir el JSON canónico exactamente. +5. Recrear `dk1_...`. +6. Comparar byte a byte con la entrada. + +Si no coincide: + +```text +ERR_DATEKEY_NON_CANONICAL +``` + +--- + +## 20. Extensiones de archivo + +```text +.dkc → DateKeyCap +.dkk → DateKeys Access Key +``` + +La extensión no sustituye los magic bytes. + +--- + +## 21. `capsule_id` + +Cada `.dkc` MUST contener un `capsule_id`. + +Debe ser: + +- aleatorio; +- opaco; +- independiente de identidad, fecha o servicio; +- de al menos 128 bits de entropía. + +V1 recomienda exactamente: + +```text +16 random bytes +``` + +generados por CSPRNG. + +--- + +## 22. Framing `.dkc` V1 + +V1 fija el siguiente prelude: + +```text +offset size field +0 4 MAGIC = "DKC1" +4 1 VERSION = 1 +5 1 FLAGS = 0 +6 2 RESERVED = 0 +8 4 PUBLIC_HEADER_LEN (uint32 BE) +12 4 SEALED_CONTROL_LEN (uint32 BE) +16 ... PUBLIC_HEADER +... ... SEALED_CONTROL +... EOF PAYLOAD_AGE +``` + +No existe `PAYLOAD_LEN`. + +El payload es el resto del fichero hasta EOF. + +V1 MUST exigir: + +```text +FLAGS == 0 +RESERVED == 0 +``` + +--- + +## 23. PRELUDE + +`PRELUDE` son exactamente los primeros 16 bytes: + +```text +MAGIC +VERSION +FLAGS +RESERVED +PUBLIC_HEADER_LEN +SEALED_CONTROL_LEN +``` + +El binding criptográfico de la cabecera utiliza estos bytes exactos. + +--- + +## 24. PUBLIC_HEADER + +V1 almacena la cabecera directamente como **Deterministic CBOR**. + +Schema: + +```text +0 → "datekeycap" +1 → 1 +2 → capsule_id (16 bytes) +3 → compact_datekey (text, canonical dk1_) +4 → access_policy +5 → critical_extensions +6 → noncritical_extensions +``` + +No se almacena `profile_id` por separado. + +El perfil se obtiene de la DateKey, evitando dos fuentes de verdad. + +--- + +## 25. Política de acceso declarada + +Valores V1: + +```text +0 → time_only +1 → time_and_key +``` + +El parser MUST comprobar que la estructura criptográfica real de `SEALED_CONTROL` coincide con la política declarada. + +Una discrepancia: + +```text +ERR_POLICY_STRUCTURE_MISMATCH +``` + +--- + +## 26. Header binding + +`CONTROL_CBOR` MUST contener: + +```text +header_binding = +SHA-256( + PRELUDE || + PUBLIC_HEADER_BYTES +) +``` + +donde `PUBLIC_HEADER_BYTES` son exactamente los bytes CBOR almacenados. + +La implementación NO DEBE reserializar la cabecera para calcular el binding. + +--- + +## 27. Validación pre-unlock + +Antes de abrir el control pueden verificarse: + +- magic; +- version; +- flags; +- reserved; +- longitudes; +- CBOR canónico; +- `capsule_id`; +- DateKey canónica; +- provider profile pinneado; +- coherencia fecha/ronda cuando la fecha solicitada esté disponible; +- extensions críticas conocidas. + +La implementación SHOULD inspeccionar, antes de utilizar secretos o realizar una petición de red, la estructura visible de los ficheros `age` accesibles en ese momento: + +- `OUTER_TIME_AGE`: número y tipo de stanzas; +- `PAYLOAD_AGE`: número y tipo de stanzas; +- argumentos visibles del stanza `tlock`, cuando la implementación pueda inspeccionarlos de forma segura. + +Esta inspección previa permite rechazar una cápsula mal formada antes de solicitar un release. Además de reducir trabajo innecesario, evita que una cápsula inválida genere una consulta observable en la Release API o en los relays. + +Estas comprobaciones de stanzas previas al descifrado son estructurales. Su autenticidad frente a modificaciones de terceros queda confirmada únicamente cuando la cabecera `age` correspondiente supera la verificación del MAC durante la apertura. Frente a un creador que incluya vías alternativas de descifrado mediante stanzas adicionales, el MAC es válido por construcción y la comprobación estructural es la defensa del protocolo. + +La inspección pre-unlock es una optimización de validación y privacidad y, por tanto, es un SHOULD. La aplicación de las reglas de cardinalidad de las secciones 29, 32 y 33 es un MUST y DEBE realizarse, como mínimo, en el momento de abrir cada fichero `age`: la identity que desenvuelve la file key MUST rechazar el fichero si el conjunto completo de stanzas recibido viola la regla correspondiente. + +La autenticidad criptográfica definitiva de `PUBLIC_HEADER` se comprueba mediante `header_binding` tras abrir el control, salvo que una extensión adicional aporte autenticidad previa. + +--- + +## 28. Tres ficheros `age` + +La construcción `time_and_key` V1 utiliza tres ficheros `age` estándar e independientes: + +```text +1. PAYLOAD_AGE +2. INNER_ACCESS_AGE +3. OUTER_TIME_AGE +``` + +Cada fichero `age` genera su propia file key aleatoria de 16 bytes. + +Se nombran: + +```text +FK_PAYLOAD +FK_ACCESS +FK_TIME +``` + +No se reutiliza ninguna de ellas. + +--- + +## 29. PAYLOAD_AGE + +El payload del usuario se cifra como un **fichero age v1 estándar completo**. + +Durante la creación se genera: + +```text +I_PAYLOAD = X25519 identity aleatoria de 32 bytes +R_PAYLOAD = X25519 public recipient correspondiente +``` + +Entonces: + +```text +PAYLOAD_AGE = +age.Encrypt( + recipient = R_PAYLOAD, + plaintext = user payload +) +``` + +`PAYLOAD_AGE` MUST contener exactamente un stanza, de tipo X25519, para `R_PAYLOAD`. + +`age` genera internamente: + +```text +FK_PAYLOAD = 16 random bytes +``` + +y la protege para `R_PAYLOAD`. + +`I_PAYLOAD` se almacena dentro de `CONTROL_CBOR`. + +--- + +## 30. Por qué PAYLOAD_AGE es un fichero age completo + +V1 NO expone ni reimplementa STREAM como un subformato DateKeys. + +El STREAM de ChaCha20-Poly1305, chunks de 64 KiB, nonce y final-chunk marker forman parte interna del formato `age`. + +DateKeys trata `PAYLOAD_AGE` como bytes de un fichero `age` estándar. + +Esto permite reutilizar: + +- header MAC; +- recipient stanza; +- file key de 16 bytes; +- STREAM; +- detección de truncado; +- vectores y librerías existentes. + +--- + +## 30.1 Binding `CONTROL_CBOR` ↔ `PAYLOAD_AGE` + +El binding entre control y payload se obtiene mediante `I_PAYLOAD`. + +`CONTROL_CBOR` contiene exactamente la identity privada `I_PAYLOAD` generada para esa cápsula. Esa identity solo abre el `PAYLOAD_AGE` cifrado para su recipient correspondiente. + +Por tanto: + +```text +CONTROL_A + PAYLOAD_AGE_B +``` + +MUST fallar porque `I_PAYLOAD_A` no puede desenvolver la file key de `PAYLOAD_AGE_B`. + +Este binding criptográfico es independiente de cualquier digest auxiliar del fichero. + +--- + +## 31. CONTROL_CBOR + +Una vez abierto, el control es **siempre Deterministic CBOR**. Una codificación no canónica MUST rechazarse aunque `CONTROL_CBOR` no participe directamente en un hash. + +Schema base: + +```text +0 → "datekeys-control" +1 → 1 +2 → header_binding (32 bytes) +3 → payload_identity (32 raw bytes, I_PAYLOAD) +4 → critical_extensions +5 → noncritical_extensions +``` + +No contiene el payload grande. + +DateKeys V1 utiliza **un único mecanismo de extensión**. No existe un campo core separado para aplicaciones o semánticas superiores. Cualquier semántica adicional se registra como una extensión identificada por namespace. + +Una extensión usa conceptualmente: + +```text +0 → extension_id +1 → extension_version +2 → data +``` + +Reglas: + +- `extension_id` MUST ser UTF-8 válido; +- `(extension_id, extension_version)` identifica el schema; +- un mismo `extension_id` MUST NOT aparecer simultáneamente en `critical_extensions` y `noncritical_extensions` dentro del mismo objeto; +- salvo que el schema registrado permita multiplicidad, un mismo `extension_id` MUST NOT aparecer más de una vez dentro del mismo objeto, aunque cambie `extension_version`; +- el orden del array NO tiene semántica; +- el encoder canónico MUST ordenar por bytes UTF-8 de `extension_id` y después por versión; +- una extensión crítica desconocida MUST provocar rechazo; +- una extensión no crítica desconocida MAY ignorarse. + +El protocolo base no interpreta `data`. + +--- + +## 32. Política `time_only` + +Construcción: + +```text +CONTROL_CBOR + ↓ +OUTER_TIME_AGE +``` + +donde: + +```text +OUTER_TIME_AGE = +age file +recipient = tlock(DateKey) +plaintext = CONTROL_CBOR +``` + +`OUTER_TIME_AGE` MUST contener exactamente un stanza, de tipo tlock. + +`age` genera: + +```text +FK_TIME = 16 random bytes +``` + +y tlock envuelve `FK_TIME` para la ronda futura. + +No existe `INNER_ACCESS_AGE`. + +--- + +## 33. Política `time_and_key` + +Construcción: + +```text +CONTROL_CBOR + ↓ +INNER_ACCESS_AGE + ↓ +OUTER_TIME_AGE +``` + +Primero: + +```text +INNER_ACCESS_AGE = +age file +recipient(s) = X25519 +plaintext = CONTROL_CBOR +``` + +`INNER_ACCESS_AGE` MUST contener uno o más stanzas, todos ellos de tipo X25519, exactamente uno por recipient. + +`age` genera: + +```text +FK_ACCESS = 16 random bytes +``` + +Después: + +```text +OUTER_TIME_AGE = +age file +recipient = tlock(DateKey) +plaintext = exact bytes of INNER_ACCESS_AGE +``` + +`age` genera: + +```text +FK_TIME = 16 random bytes +``` + +La política implementa: + +```text +TIME AND KEY +``` + +No dos envolturas paralelas. + +--- + +## 34. `SEALED_CONTROL` + +En `.dkc`: + +```text +SEALED_CONTROL = +exact bytes of OUTER_TIME_AGE +``` + +Debe ser siempre un fichero age completo. + +Para `time_only`, el plaintext interno es `CONTROL_CBOR`. + +Para `time_and_key`, el plaintext interno es `INNER_ACCESS_AGE`. + +--- + +## 35. tlock strict mode + +El recipient/identity tlock V1 MUST usar: + +- chain hash pinneado; +- public key pinneada; +- scheme pinneado; +- round esperado. + +DateKeys MUST desactivar confianza automática en chain hash suministrado por el ciphertext. + +La root of trust procede del Provider Profile local. + +--- + +## 36. Comprobación política ↔ estructura + +Tras abrir `OUTER_TIME_AGE`: + +### Si `access_policy = time_only` + +el resultado MUST ser directamente un `CONTROL_CBOR` canónico válido. + +### Si `access_policy = time_and_key` + +el resultado MUST ser un fichero age v1 válido cuyo header contenga uno o más stanzas, todos ellos de tipo X25519. + +Si la estructura no coincide: + +```text +ERR_POLICY_STRUCTURE_MISMATCH +``` + +--- + +## 36.1 Semántica de autenticidad + +`time_only` **NO proporciona autenticidad del creador**, ni antes ni después de madurar la condición temporal, salvo que una extensión de firma explícita añada esa propiedad. + +La falsificación es posible desde el momento de creación: cifrar hacia una ronda futura solo requiere la clave pública del Provider Profile y la condición temporal pública. Además, `header_binding` se calcula únicamente a partir de bytes públicos (`PRELUDE` y `PUBLIC_HEADER`). Un tercero puede construir otro `CONTROL_CBOR` con un `header_binding` correcto, generar su propio `I_PAYLOAD`, crear su propio `PAYLOAD_AGE` y sellar ese control hacia la misma DateKey sin esperar a que la ronda madure. + +Por tanto `capsule_id`, DateKey y `header_binding` proporcionan **coherencia interna**, no autoría. + +`time_and_key` añade una barrera adicional de acceso: un tercero que no posea la identity/capacidad requerida no puede abrir el `INNER_ACCESS_AGE`. Esta propiedad **NO sustituye una firma del creador** y no debe presentarse como prueba de autoría. + +--- + +## 37. X25519 recipient V1 + +V1 adopta el recipient X25519 estándar de `age`. + +Una identity X25519: + +```text +I = 32 random bytes from CSPRNG +``` + +El recipient correspondiente se deriva según la especificación `age`. + +No se define un KEM propio. + +--- + +## 38. Portable Access Key + +Cuando el creador quiere generar una credencial portable, genera: + +```text +I_ACCESS = 32 random bytes +R_ACCESS = corresponding X25519 recipient +``` + +`R_ACCESS` se usa como recipient de `INNER_ACCESS_AGE`. + +`I_ACCESS` se almacena como **32 bytes crudos** dentro del `.dkk`. + +Una `I_ACCESS` portable MUST generarse específicamente para **una única `capsule_id`** y MUST NOT reutilizarse en otra DateKeyCap. La reutilización convertiría una misma `.dkk` en capacidad válida para varias cápsulas y rompería el aislamiento esperado entre objetos. + +La representación Bech32 `AGE-SECRET-KEY-...` MAY mostrarse/exportarse para interoperabilidad humana, pero NO es la representación binaria canónica del campo. + +--- + +## 39. Múltiples recipients + +`INNER_ACCESS_AGE` MAY contener múltiples stanzas X25519. + +Todas envuelven la misma: + +```text +FK_ACCESS +``` + +Por tanto el `CONTROL_CBOR` y `PAYLOAD_AGE` no se duplican. + +--- + +## 40. Framing `.dkk` V1 + +```text +offset size field +0 4 MAGIC = "DKK1" +4 1 VERSION = 1 +5 1 FLAGS = 0 +6 2 RESERVED = 0 +8 4 BODY_LEN (uint32 BE) +12 ... BODY_CBOR +``` + +V1 MUST exigir: + +```text +FLAGS == 0 +RESERVED == 0 +``` + +--- + +## 41. `.dkk` BODY_CBOR + +Schema: + +```text +0 → "datekeys-access-key" +1 → 1 +2 → credential_id (16 random bytes) +3 → capsule_id (16 bytes) +4 → access_type +5 → access_material +6 → verification_metadata +7 → critical_extensions +8 → noncritical_extensions +``` + +Para V1 portable X25519: + +```text +access_type = "x25519" +access_material = 32 raw identity bytes +``` + +--- + +## 42. `credential_id` + +Debe ser: + +```text +16 random bytes +``` + +generados por CSPRNG. + +Es un identificador opaco. + +No deriva de la key ni de identidad personal. + +--- + +## 43. `verification_metadata` + +V1 define opcionalmente: + +```text +0 → capsule_digest +``` + +donde: + +```text +capsule_digest = +SHA-256(exact .dkc bytes) +``` + +Su función es: + +- fallo rápido; +- detección de fichero equivocado; +- UX; +- deduplicación. + +NO constituye una propiedad de seguridad necesaria para el acceso. + +Una identity X25519 solo podrá abrir el `INNER_ACCESS_AGE` para el que fue utilizada; el digest no sustituye esa propiedad criptográfica. + +Si no existe metadata de verificación, la clave `verification_metadata` MUST omitirse. Un mapa vacío no es una representación canónica válida de ausencia en V1. + +--- + +## 44. Extensiones de aplicación en `.dkk` + +Información de integración que no pertenezca al protocolo base DEBE ir en: + +```text +key 8 → noncritical_extensions +``` + +salvo que una futura especificación DateKeys registre una extensión crítica concreta. + +De este modo las implementaciones no inventan campos core incompatibles. + +--- + +## 45. Release API + +La API recomendada se indexa por condición: + +```text +GET /v1/releases/{profile}/{condition} +``` + +Ejemplo Quicknet: + +```text +GET /v1/releases/datekeys:quicknet:v1/66884212 +``` + +No requiere `capsule_id`. + +--- + +## 46. Release Queue + +La unidad de trabajo es: + +```text +profile + condition +``` + +No cápsula. + +Muchas cápsulas de una misma ronda comparten un único release. + +--- + +## 47. Release Cache + +Un release publicado puede almacenarse como: + +```text +profile +condition +release_material +verified +verified_at +``` + +El servidor MUST verificarlo antes de marcarlo como válido. + +El SDK MUST verificarlo de nuevo. + +--- + +## 48. Multi-relay + +La implementación SHOULD soportar varios relays independientes por disponibilidad. + +La autenticidad procede de la verificación BLS. + +No del hostname. + +--- + +## 49. Recuperación directa contra el proveedor + +Una implementación conforme SHOULD poder obtener un release directamente del proveedor temporal, sin pasar por la API DateKeys. + +Para Quicknet: + +```text +.dkc ++ +Provider Profile pinneado ++ +release obtenido de un relay drand ++ +.dkk si la política la exige +``` + +debe ser suficiente para ejecutar el flujo de apertura. + +La API DateKeys es una capa de conveniencia, disponibilidad y caché, no una autoridad criptográfica obligatoria. + +--- + +## 50. Dependencia del histórico de releases + +La recuperación años después depende de que el release histórico necesario siga disponible. + +Para Quicknet, esto puede provenir de: + +- un relay drand que conserve/entregue rondas históricas; o +- una Release Cache válida conservada por otra fuente. + +El protocolo NO debe asumir silenciosamente que cualquier proveedor conservará histórico indefinidamente. + +Una aplicación que prometa horizontes largos SHOULD documentar esta dependencia. + +--- + +## 51. Verificación de release Quicknet + +El SDK MUST comprobar: + +```text +expected Provider Profile +expected chain hash +expected round +valid BLS signature +``` + +Un campo remoto: + +```text +verified = true +``` + +no tiene valor de seguridad. + +--- + +## 52. DNS / MITM + +Controlar DNS, TLS termination o un relay no debe permitir fabricar un release válido mientras: + +- la raíz de confianza esté pinneada; +- la condición esperada se calcule localmente; +- la firma se verifique. + +--- + +## 53. Harvest now, decrypt later + +El SDK oficial SHOULD advertir al usuario en horizontes temporales largos. + +Debe explicar: + +- Quicknet V1 no es post-cuántico; +- el ciphertext puede permanecer disponible durante años; +- la seguridad futura depende del provider y de la criptografía subyacente. + +El umbral temporal de la advertencia es política de producto, no parte de la semántica criptográfica del protocolo. + +--- + +## 54. Extensiones + +`PUBLIC_HEADER`, `CONTROL_CBOR` y `.dkk` pueden incluir extensiones mediante el mismo mecanismo. + +Cada extensión declara: + +```text +0 → extension_id +1 → extension_version +2 → data +``` + +El par: + +```text +(extension_id, extension_version) +``` + +identifica el schema de la extensión. + +La posición del array determina si la extensión es: + +```text +critical +``` + +o: + +```text +noncritical +``` + +Reglas: + +- una extensión crítica desconocida → MUST reject; +- una extensión no crítica desconocida → MAY ignore; +- un mismo `extension_id` MUST NOT aparecer simultáneamente en `critical_extensions` y `noncritical_extensions` dentro del mismo objeto; +- salvo que un schema registrado permita expresamente multiplicidad, un mismo `extension_id` MUST NOT aparecer más de una vez en el mismo objeto, aunque cambie `extension_version`. + +La información específica de una aplicación que no pertenezca al núcleo DateKeys —incluidos datos de transporte o descubrimiento si una aplicación los necesita— MUST ir en `noncritical_extensions` y no en campos core del protocolo. + +--- + +## 55. Integridad auxiliar + +Hashes públicos MAY utilizarse para: + +- identificación; +- caché; +- deduplicación; +- auditoría; +- UX. + +No sustituyen: + +- header MAC de age; +- X25519 wrapping; +- tlock; +- STREAM authentication; +- `header_binding`. + +--- + +## 56. Atomic plaintext output + +El descifrado SHOULD usar: + +- fichero temporal; +- stream transaccional; +- mecanismo equivalente. + +No debe presentar plaintext parcial como válido si falla cualquier autenticación posterior. + +--- + +## 57. Límites del parser + +V1 recomienda: + +```text +PUBLIC_HEADER <= 1 MiB +SEALED_CONTROL <= 64 MiB +DKK BODY <= 16 MiB +``` + +`PAYLOAD_AGE` se procesa en streaming. + +Los tamaños se validan antes de reservar memoria. + +--- + +## 58. Canonical CBOR + +Todas las estructuras CBOR del protocolo MUST: + +- usar Deterministic CBOR; +- rechazar siempre codificaciones no canónicas; +- usar enteros de clave según los schemas normativos. + +--- + +## 58.1 Regla global para campos opcionales + +V1 usa una única convención: + +> **Un campo opcional semánticamente ausente MUST omitirse.** + +No se debe representar ausencia mediante: + +```text +{} +[] +null +"" +``` + +salvo que el schema de ese campo defina expresamente uno de esos valores como dato real. + +Por tanto: + +- metadata opcional ausente → clave omitida; +- arrays opcionales sin elementos → clave omitida; +- mapas opcionales sin entradas → clave omitida; +- listas de extensiones vacías → clave omitida. + +Esto reduce representaciones equivalentes y simplifica vectores canónicos. + +--- + +## 59. Supply-chain security + +Implementaciones oficiales SHOULD: + +- pinnear dependencias criptográficas; +- publicar SBOM; +- firmar releases; +- publicar hashes; +- publicar perfiles firmados; +- usar builds reproducibles cuando sea viable; +- fuzzear parsers; +- publicar vectores; +- someter v1.0 a revisión criptográfica externa. + +--- + +## 60. Interfaces Go conceptuales + +```go +type Condition any +type Release any + +type TimeProvider interface { + ProfileID() string + + Resolve(time.Time) (Condition, error) + EffectiveTime(Condition) (time.Time, error) + + FetchRelease( + context.Context, + Condition, + ) (Release, error) + + VerifyRelease( + Condition, + Release, + ) error +} +``` + +--- + +## 61. Flujo de cifrado `time_only` + +```text +1. Resolver DateKey localmente. +2. Generar capsule_id. +3. Generar I_PAYLOAD X25519 (32 bytes). +4. Crear PAYLOAD_AGE: + age genera FK_PAYLOAD (16 bytes) + recipient = R_PAYLOAD + plaintext = payload +5. Construir PUBLIC_HEADER. +6. Construir PRELUDE. +7. Calcular header_binding. +8. Crear CONTROL_CBOR: + header_binding + I_PAYLOAD + critical_extensions + noncritical_extensions +9. Crear OUTER_TIME_AGE: + age genera FK_TIME (16 bytes) + recipient = tlock(DateKey) + plaintext = CONTROL_CBOR +10. SEALED_CONTROL = OUTER_TIME_AGE. +11. Escribir PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE. +``` + +File keys utilizadas: + +```text +FK_PAYLOAD +FK_TIME +``` + +--- + +## 62. Flujo de cifrado `time_and_key` + +```text +1. Resolver DateKey localmente. +2. Generar capsule_id. +3. Generar I_PAYLOAD X25519. +4. Crear PAYLOAD_AGE: + age genera FK_PAYLOAD + recipient = R_PAYLOAD + plaintext = payload +5. Construir PUBLIC_HEADER. +6. Construir PRELUDE. +7. Calcular header_binding. +8. Crear CONTROL_CBOR. +9. Crear INNER_ACCESS_AGE: + age genera FK_ACCESS + recipients = R_ACCESS[...] + plaintext = CONTROL_CBOR +10. Crear OUTER_TIME_AGE: + age genera FK_TIME + recipient = tlock(DateKey) + plaintext = exact INNER_ACCESS_AGE bytes +11. SEALED_CONTROL = OUTER_TIME_AGE. +12. Escribir .dkc. +13. Si se generó una identity portable: + escribir I_ACCESS cruda en .dkk. +``` + +Las tres file keys son: + +```text +FK_PAYLOAD +FK_ACCESS +FK_TIME +``` + +y MUST ser independientes. + +--- + +## 63. Flujo de descifrado + +```text +1. Parsear DKC1. + +2. Validar PRELUDE, version, flags, reserved, longitudes y límites. + +3. Leer PUBLIC_HEADER exacto. + +4. Validar CBOR canónico, DateKey canónica y Provider Profile pinneado. + +5. SHOULD: inspeccionar OUTER_TIME_AGE antes de usar red o secretos: + exactamente un stanza; + de tipo tlock. + +6. SHOULD: localizar PAYLOAD_AGE mediante las longitudes del PRELUDE + e inspeccionar únicamente su cabecera age: + exactamente un stanza; + de tipo X25519. + +7. Resolver/verificar localmente la condición temporal. + +8. SHOULD, cuando se haya realizado la inspección previa: + comprobar que la ronda del stanza tlock coincide con DateKey.round; + comprobar que el chain hash del stanza tlock coincide con el + Provider Profile pinneado. + Una discrepancia debe producir: + ERR_ROUND_MISMATCH o ERR_PROFILE_MISMATCH. + +9. Obtener el release. + +10. Verificar el release localmente. + +11. Abrir OUTER_TIME_AGE. + La identity tlock MUST recibir y validar el conjunto completo de stanzas + y MUST rechazar el fichero salvo que contenga exactamente un stanza tlock. + La ronda y el chain hash MUST coincidir con la DateKey y con el + Provider Profile pinneado. + La apertura autentica además la cabecera age mediante su MAC. + +12. Verificar que la estructura resultante coincide con access_policy. + +13. Si time_and_key: + la identity X25519 que abre INNER_ACCESS_AGE MUST recibir y validar + el conjunto completo de stanzas; + MUST existir uno o más stanzas; + todos MUST ser X25519; + debe existir exactamente un stanza por recipient; + después, abrir INNER_ACCESS_AGE con la identity adecuada. + +14. Parsear CONTROL_CBOR canónico. + +15. Verificar header_binding. + +16. Recuperar I_PAYLOAD. + +17. Abrir PAYLOAD_AGE usando I_PAYLOAD. + La identity de payload MUST recibir y validar el conjunto completo de + stanzas y MUST rechazar el fichero salvo que contenga exactamente un + stanza X25519 para R_PAYLOAD. + +18. Commit del plaintext solo si age completa sin error. +``` + +La inspección de los pasos 5, 6 y 8 es un SHOULD de fail-fast. La aplicación de las reglas de cardinalidad durante los pasos 11, 13 y 17 es un MUST. Una implementación no puede considerar válido un `.dkc` únicamente porque `age` haya podido desenvolver una file key: debe verificar también que el conjunto completo de stanzas cumple la política DateKeys V1. + +`PAYLOAD_AGE` comienza exactamente en: + +```text +16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN +``` + +Una implementación MAY saltar directamente a ese offset y leer solo la cabecera `age` necesaria para la inspección previa. No es necesario leer ni descifrar el payload completo y esta comprobación no rompe el procesamiento en streaming. + +Siempre que sea viable, una implementación SHOULD validar todo lo verificable localmente antes de realizar una petición de red o utilizar un secreto. Además de fallar antes, esta regla evita que cápsulas inválidas generen consultas observables en relays o en la Release API. + +--- + +## 64. Mutation tests obligatorios + +Antes de v1.0 deben fallar, como mínimo: + +```text +PUBLIC_HEADER_A + SEALED_CONTROL_B +SEALED_CONTROL_A + PAYLOAD_AGE_B +DateKey A + release de round B +chain hash cambiado +version cambiada +flags != 0 +reserved != 0 +payload truncado +payload age modificado +control modificado +dk1_ JSON no canónico +perfil desconocido +release de otra ronda +access_policy=time_only con estructura time_and_key +access_policy=time_and_key con estructura time_only +stanza adicional en OUTER_TIME_AGE +stanza adicional en PAYLOAD_AGE +stanza de tipo distinto de X25519 en INNER_ACCESS_AGE +ronda del stanza tlock distinta de DateKey.round +chain hash del stanza tlock distinto del Provider Profile pinneado +``` + +--- + +## 65. Test vectors Quicknet + +Deben incluir: + +- timestamp exactamente en frontera; +- un segundo antes; +- un segundo después; +- timestamp con fracción de segundo inmediatamente posterior a una frontera de ronda; +- fecha 2030-01-01; +- fechas próximas al genesis. + +Cada vector: + +```text +requested timestamp +expected round +effective timestamp +``` + +--- + +## 66. Test vectors `dk1_` + +Cada vector: + +```text +logical object +exact canonical JSON bytes +exact Base64URL bytes +final dk1_ string +``` + +La cadena textual debe ser única. + +--- + +## 67. Test vectors `.dkc` + +Los vectores `.dkc` V1 son **fixtures de descifrado y validación**, no pruebas que exijan reproducir byte a byte una llamada pública a `age.Encrypt`. + +La implementación de `age` genera internamente randomness que su API pública no permite inyectar de forma estable. El protocolo NO exige parchear `age` ni `tlock` para controlar: + +- file keys internas; +- efímeros X25519; +- nonces internos. + +Cada vector oficial incluirá: + +- bytes `.dkc` fijos previamente generados; +- `PUBLIC_HEADER` esperado; +- PRELUDE esperado; +- DateKey esperada; +- `header_binding` esperado; +- estructura esperada (`time_only` o `time_and_key`); +- identity `.dkk` cuando corresponda; +- `CONTROL_CBOR` esperado después de abrir; +- `I_PAYLOAD` esperado; +- plaintext final esperado; +- resultado esperado de cada etapa de verificación. + +La conformidad se demuestra descifrando/verificando el fixture y comparando resultados intermedios y plaintext. + +--- + +## 68. Test vectors `.dkk` + +Los vectores `.dkk` son igualmente fixtures de parseo, validación y uso. + +Cada vector incluirá: + +- bytes `.dkk` fijos; +- `credential_id` esperado; +- `capsule_id` esperado; +- `access_type` esperado; +- 32 bytes crudos esperados de X25519 identity; +- `verification_metadata` esperado cuando exista; +- extensiones esperadas; +- resultado esperado al utilizar la identity contra el `INNER_ACCESS_AGE` asociado. + +No se exige reproducir los bytes de una `.dkk` partiendo de una generación aleatoria nueva. + +--- + +## 69. Errores normativos + +```text +ERR_INVALID_MAGIC +ERR_UNSUPPORTED_VERSION +ERR_INVALID_FLAGS +ERR_NON_CANONICAL_CBOR +ERR_UNKNOWN_PROFILE +ERR_PROFILE_MISMATCH +ERR_DATEKEY_INVALID +ERR_DATEKEY_NON_CANONICAL +ERR_ROUND_MISMATCH +ERR_RELEASE_UNAVAILABLE +ERR_RELEASE_INVALID +ERR_ACCESS_REQUIRED +ERR_ACCESS_INVALID +ERR_POLICY_STRUCTURE_MISMATCH +ERR_HEADER_BINDING +ERR_INTEGRITY +ERR_EXTENSION_CRITICAL_UNKNOWN +``` + +--- + +## 70. Compatibilidad + +Una implementación V1: + +- MUST aceptar `DKC1` y `DKK1`; +- MUST rechazar major versions desconocidas; +- MUST rechazar critical extensions desconocidas; +- MAY ignorar noncritical extensions desconocidas; +- MUST mantener inmutable la interpretación de perfiles publicados. + +--- + +## 71. Registro de perfiles + +DateKeys SHOULD publicar un registro de Provider Profiles. + +Cada entrada debería incluir: + +- exact canonical CBOR; +- SHA-256; +- firma offline; +- fecha de publicación; +- estado. + +--- + +## 72. Registro de extensiones + +DateKeys MAY publicar un registro de `extension_id`. + +Registrar una extensión no cambia el núcleo del protocolo. + +--- + +## 73. Decisiones canónicas v0.8.1 + +```text +DateKeys += plataforma / protocolo + +DateKey += condición temporal pública + +DateKeyCap += .dkc + +DateKeys Access Key += .dkk + +Quicknet += provider V1 + +dk1_ += JSON canónico heredado del prototipo + +Provider Profile += Deterministic CBOR + genesis_seed + +capsule_id += 16 bytes aleatorios y opacos + +DKC framing += sin PAYLOAD_LEN + +PUBLIC_HEADER += Deterministic CBOR exacto + +profile_id duplicado en header += eliminado; la DateKey es la fuente única + +header binding += SHA-256(PRELUDE || exact PUBLIC_HEADER bytes) + +payload += fichero age v1 estándar completo + +payload access += X25519 identity I_PAYLOAD dentro de CONTROL_CBOR + +time_only += age(tlock → CONTROL_CBOR) + +time_and_key += age(tlock → age(X25519 recipient(s) → CONTROL_CBOR)) + +portable .dkk += X25519 identity de 32 bytes crudos + +extensions += único mecanismo genérico de extensibilidad en PUBLIC_HEADER, CONTROL_CBOR y .dkk + +recovery += puede obtener release directamente del provider + +historical release availability += dependencia explícita del horizonte de recuperación +``` + +--- + +## 74. Aspectos todavía provisionales + +Antes de v1.0 quedan por cerrar: + +- schema CBOR final byte-a-byte de `PUBLIC_HEADER`; +- schema CBOR final byte-a-byte de `CONTROL_CBOR`; +- schema CBOR final byte-a-byte de `.dkk`; +- formato exacto de extensiones; +- límites definitivos de campos; +- vectores definitivos de Provider Profile; +- suite exacta de tests interoperables. + +El framing base, la ausencia de `PAYLOAD_LEN`, el uso de age files estándar y la identity X25519 cruda de `.dkk` dejan de considerarse provisionales en este borrador. + +--- + +## 75. Requisitos bloqueantes antes de v1.0 + +1. Schemas CBOR congelados. +2. `profile_hash` vector oficial. +3. Quicknet vectors oficiales. +4. `dk1_` vectors canónicos. +5. fixtures oficiales `.dkc` de descifrado/validación. +6. fixtures oficiales `.dkk` de parseo/uso. +7. mutation tests completos. +8. strict tlock tests. +9. parser fuzzing. +10. revisión criptográfica externa. + +--- + +## 76. Política de cambios del borrador v0.8.1 + +La v0.8.1 congela el diseño normativo del borrador para la fase de implementación e interoperabilidad. + +A partir de esta versión, un cambio normativo posterior SHOULD responder a un caso reproducible descubierto mediante al menos una de estas fuentes: + +- implementación de referencia; +- schema `datekeys.cddl`; +- fixture oficial; +- mutation test; +- prueba de interoperabilidad; +- fuzzing; +- segunda implementación independiente; +- revisión criptográfica o técnica externa. + +Nuevas ideas, preferencias editoriales o posibilidades futuras que no estén respaldadas por uno de esos casos SHOULD documentarse fuera del núcleo normativo hasta que exista evidencia suficiente para modificarlo. + +Esta política no impide correcciones editoriales que no alteren la semántica normativa. + +--- + +## 77. Referencias + +- drand Protocol Specification + https://docs.drand.love/docs/specification/ + +- drand/tlock + https://github.com/drand/tlock + +- age specification — C2SP + https://github.com/C2SP/C2SP/blob/main/age.md + +- RFC 8949 — CBOR + +- RFC 2119 / RFC 8174 — normative terminology + +--- + +## 78. Principio final + +> **DateKey define cuándo.** + +> **DateKeyCap protege qué.** + +> **`.dkk` transporta la capacidad adicional de acceso cuando la política la exige.** + +> **DateKeys facilita, registra y acelera; el cliente verifica.** diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..bc8b2c5 --- /dev/null +++ b/spec/README.md @@ -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): . +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. diff --git a/spec/datekeys.cddl b/spec/datekeys.cddl new file mode 100644 index 0000000..627d4a3 --- /dev/null +++ b/spec/datekeys.cddl @@ -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":,"round":}, round in 1..2^53-1. +compact-datekey = tstr .regexp "dk1_[A-Za-z0-9_-]+" diff --git a/testdata/fixtures/empty_payload.dkc b/testdata/fixtures/empty_payload.dkc new file mode 100644 index 0000000..f94e3b0 Binary files /dev/null and b/testdata/fixtures/empty_payload.dkc differ diff --git a/testdata/fixtures/empty_payload.json b/testdata/fixtures/empty_payload.json new file mode 100644 index 0000000..f119aaa --- /dev/null +++ b/testdata/fixtures/empty_payload.json @@ -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 + } + ] +} diff --git a/testdata/fixtures/empty_payload.plaintext b/testdata/fixtures/empty_payload.plaintext new file mode 100644 index 0000000..e69de29 diff --git a/testdata/fixtures/time_and_key_portable.dkc b/testdata/fixtures/time_and_key_portable.dkc new file mode 100644 index 0000000..b2fb3c5 Binary files /dev/null and b/testdata/fixtures/time_and_key_portable.dkc differ diff --git a/testdata/fixtures/time_and_key_portable.dkk b/testdata/fixtures/time_and_key_portable.dkk new file mode 100644 index 0000000..08166da Binary files /dev/null and b/testdata/fixtures/time_and_key_portable.dkk differ diff --git a/testdata/fixtures/time_and_key_portable.dkk.json b/testdata/fixtures/time_and_key_portable.dkk.json new file mode 100644 index 0000000..a9d3e07 --- /dev/null +++ b/testdata/fixtures/time_and_key_portable.dkk.json @@ -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" +} diff --git a/testdata/fixtures/time_and_key_portable.json b/testdata/fixtures/time_and_key_portable.json new file mode 100644 index 0000000..866d858 --- /dev/null +++ b/testdata/fixtures/time_and_key_portable.json @@ -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 + } + ] +} diff --git a/testdata/fixtures/time_and_key_portable.plaintext b/testdata/fixtures/time_and_key_portable.plaintext new file mode 100644 index 0000000..6d69ec8 --- /dev/null +++ b/testdata/fixtures/time_and_key_portable.plaintext @@ -0,0 +1 @@ +DateKeys fixture opened with a portable .dkk. diff --git a/testdata/fixtures/time_and_key_recipients.dkc b/testdata/fixtures/time_and_key_recipients.dkc new file mode 100644 index 0000000..b9ab9cc Binary files /dev/null and b/testdata/fixtures/time_and_key_recipients.dkc differ diff --git a/testdata/fixtures/time_and_key_recipients.dkk b/testdata/fixtures/time_and_key_recipients.dkk new file mode 100644 index 0000000..c38ea6d Binary files /dev/null and b/testdata/fixtures/time_and_key_recipients.dkk differ diff --git a/testdata/fixtures/time_and_key_recipients.dkk.json b/testdata/fixtures/time_and_key_recipients.dkk.json new file mode 100644 index 0000000..9067520 --- /dev/null +++ b/testdata/fixtures/time_and_key_recipients.dkk.json @@ -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" +} diff --git a/testdata/fixtures/time_and_key_recipients.json b/testdata/fixtures/time_and_key_recipients.json new file mode 100644 index 0000000..44fd9c1 --- /dev/null +++ b/testdata/fixtures/time_and_key_recipients.json @@ -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 + } + ] +} diff --git a/testdata/fixtures/time_and_key_recipients.plaintext b/testdata/fixtures/time_and_key_recipients.plaintext new file mode 100644 index 0000000..21f440d --- /dev/null +++ b/testdata/fixtures/time_and_key_recipients.plaintext @@ -0,0 +1 @@ +DateKeys fixture for several recipients. diff --git a/testdata/fixtures/time_only.dkc b/testdata/fixtures/time_only.dkc new file mode 100644 index 0000000..9332b7c Binary files /dev/null and b/testdata/fixtures/time_only.dkc differ diff --git a/testdata/fixtures/time_only.json b/testdata/fixtures/time_only.json new file mode 100644 index 0000000..21bf71d --- /dev/null +++ b/testdata/fixtures/time_only.json @@ -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 + } + ] +} diff --git a/testdata/fixtures/time_only.plaintext b/testdata/fixtures/time_only.plaintext new file mode 100644 index 0000000..6b8a44f --- /dev/null +++ b/testdata/fixtures/time_only.plaintext @@ -0,0 +1,1000 @@ +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. +DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk. diff --git a/testdata/fixtures/time_only_extensions.dkc b/testdata/fixtures/time_only_extensions.dkc new file mode 100644 index 0000000..db72ae2 Binary files /dev/null and b/testdata/fixtures/time_only_extensions.dkc differ diff --git a/testdata/fixtures/time_only_extensions.json b/testdata/fixtures/time_only_extensions.json new file mode 100644 index 0000000..7deb42e --- /dev/null +++ b/testdata/fixtures/time_only_extensions.json @@ -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 + } + ] +} diff --git a/testdata/fixtures/time_only_extensions.plaintext b/testdata/fixtures/time_only_extensions.plaintext new file mode 100644 index 0000000..9bab1c0 --- /dev/null +++ b/testdata/fixtures/time_only_extensions.plaintext @@ -0,0 +1 @@ +DateKeys fixture with extensions. diff --git a/testdata/vectors/dk1.json b/testdata/vectors/dk1.json new file mode 100644 index 0000000..a3655d2 --- /dev/null +++ b/testdata/vectors/dk1.json @@ -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" + } + ] +} diff --git a/testdata/vectors/profile_quicknet.json b/testdata/vectors/profile_quicknet.json new file mode 100644 index 0000000..4fa8959 --- /dev/null +++ b/testdata/vectors/profile_quicknet.json @@ -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" +} diff --git a/testdata/vectors/quicknet_rounds.json b/testdata/vectors/quicknet_rounds.json new file mode 100644 index 0000000..f631d03 --- /dev/null +++ b/testdata/vectors/quicknet_rounds.json @@ -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" + } + ] +}