Reference implementation in Go, built from the implementation plan (milestones M0 to M5): datekey, profile, provider, codec, agewrap, extension, capsule, accesskey, the datekeys CLI, official vectors and fixtures, the mutation corpus, fuzz targets, interop and live tests, CI workflows, traceability and policy documents. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>v0.8.2
commit
0bd38f18cf
@ -0,0 +1,7 @@
|
||||
# LF everywhere, whatever core.autocrlf says (it is true on Windows by default).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Official fixtures are exact bytes: their SHA-256 is part of the test suite.
|
||||
*.dkc binary
|
||||
*.dkk binary
|
||||
*.plaintext binary
|
||||
@ -0,0 +1,18 @@
|
||||
# Patch updates only. Any change to age, tlock, drand or kyber is reviewed by
|
||||
# hand against SECURITY.md before merging, even when Dependabot proposes it.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels: [dependencies]
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
labels: [dependencies]
|
||||
@ -0,0 +1,135 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }}, Go ${{ matrix.go }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go: [stable, oldstable]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- run: go mod verify
|
||||
- run: go vet ./...
|
||||
- run: go test -race -count=1 ./...
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: at least 90 % in codec, capsule, accesskey, datekey and agewrap
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for pkg in codec capsule accesskey datekey agewrap; do
|
||||
pct=$(go test -count=1 -cover "./$pkg" | sed -n 's/.*coverage: \([0-9.]*\)% of statements.*/\1/p')
|
||||
echo "$pkg: $pct%"
|
||||
awk -v p="$pct" 'BEGIN { exit !(p >= 90) }'
|
||||
done
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.14.0
|
||||
- name: gosec (advisory)
|
||||
continue-on-error: true
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.14.0
|
||||
args: --enable-only gosec
|
||||
|
||||
vuln:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
|
||||
|
||||
fuzz-short:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: every parser, 20 s each
|
||||
shell: bash
|
||||
run: ./scripts/fuzz.sh 20s
|
||||
|
||||
interop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: official age and tle command-line tools
|
||||
run: |
|
||||
go install filippo.io/age/cmd/age@v1.3.2
|
||||
go install github.com/drand/tlock/cmd/tle@v1.2.0
|
||||
go test -tags interop -count=1 -v ./capsule -run Interop
|
||||
|
||||
sbom:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- run: go run github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1.12.0 mod -licenses -json -output sbom.cdx.json
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sbom
|
||||
path: sbom.cdx.json
|
||||
|
||||
fixtures:
|
||||
name: vectors reproduce and fixtures are frozen
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- run: |
|
||||
go run ./internal/testkit/genfixtures -out testdata
|
||||
git diff --exit-code testdata
|
||||
@ -0,0 +1,55 @@
|
||||
name: nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: live Quicknet
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- run: go test -tags integration -count=1 -v -timeout 10m ./capsule ./provider/drand -run Live
|
||||
|
||||
fuzz-long:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: every parser, 10 min each
|
||||
shell: bash
|
||||
env:
|
||||
FUZZ_MINIMIZE: 10s
|
||||
run: ./scripts/fuzz.sh 10m
|
||||
- name: keep failing inputs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fuzz-corpus
|
||||
path: "**/testdata/fuzz/**"
|
||||
|
||||
vuln:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: stable
|
||||
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
|
||||
@ -0,0 +1,32 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # publish the GitHub release
|
||||
id-token: write # keyless cosign signature
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go test -count=1 ./...
|
||||
- run: go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1.12.0
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -0,0 +1,9 @@
|
||||
# Build outputs
|
||||
/datekeys
|
||||
/datekeys.exe
|
||||
/dist/
|
||||
*.test
|
||||
|
||||
# Coverage and profiles
|
||||
*.out
|
||||
*.cdx.json
|
||||
@ -0,0 +1,28 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- staticcheck
|
||||
- ineffassign
|
||||
- unparam
|
||||
exclusions:
|
||||
# Unchecked Close of read-only files, fmt.Fprint* to the terminal and
|
||||
# os.Remove of temporary files, as in golangci-lint v1.
|
||||
presets:
|
||||
- std-error-handling
|
||||
rules:
|
||||
# Tests may ignore errors of set-up writes and helpers.
|
||||
- path: _test\.go
|
||||
linters: [errcheck, unparam]
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/datekeys/datekeys-go
|
||||
@ -0,0 +1,65 @@
|
||||
# Reproducible release of the datekeys CLI (plan §8, spec §59).
|
||||
version: 2
|
||||
project_name: datekeys
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod verify
|
||||
|
||||
builds:
|
||||
- id: datekeys
|
||||
main: ./cmd/datekeys
|
||||
binary: datekeys
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -buildid=
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- README.es.md
|
||||
- SECURITY.md
|
||||
- TRADEMARKS.md
|
||||
- CHANGELOG.md
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
algorithm: sha256
|
||||
|
||||
sboms:
|
||||
- id: cyclonedx
|
||||
artifacts: binary
|
||||
cmd: cyclonedx-gomod
|
||||
documents:
|
||||
- "{{ .ArtifactName }}.cdx.json"
|
||||
args: ["bin", "-json", "-output", "$document", "$artifact"]
|
||||
|
||||
signs:
|
||||
# Keyless signature of the checksum file with the workflow's OIDC identity.
|
||||
- cmd: cosign
|
||||
artifacts: checksum
|
||||
signature: "${artifact}.sig"
|
||||
certificate: "${artifact}.pem"
|
||||
args:
|
||||
- sign-blob
|
||||
- --output-signature=${signature}
|
||||
- --output-certificate=${certificate}
|
||||
- ${artifact}
|
||||
- --yes
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
@ -0,0 +1,59 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for helping. This module is the reference implementation of a
|
||||
specification, so a few rules matter more than usual.
|
||||
|
||||
## The specification decides
|
||||
|
||||
- Code adds no semantics. If an implementation question reveals a gap or a
|
||||
problem in the specification, open an issue with a **reproducible case**: a
|
||||
failing test, a fixture, a mutation or a fuzzing input (spec §76).
|
||||
- Every change to normative code updates `docs/traceability.md` in the same
|
||||
pull request, and `spec/datekeys.cddl` when a schema is affected.
|
||||
- Official vectors and fixtures in `testdata/` are frozen. Changing one needs a
|
||||
specification change first.
|
||||
|
||||
## No cryptography of our own
|
||||
|
||||
Only `age`, `tlock` and drand's BLS verification. New cryptographic
|
||||
dependencies are not accepted without prior discussion.
|
||||
|
||||
## Style
|
||||
|
||||
- Identifiers, code comments, error messages and commit messages in English.
|
||||
User documentation in English with a Spanish version.
|
||||
- `gofmt`, `goimports`, `go vet`, `staticcheck` and `golangci-lint` (see
|
||||
`.golangci.yml`) must pass.
|
||||
- Package and function comments cite the section they implement, for example
|
||||
`// Spec §26`.
|
||||
- Every protocol failure wraps exactly one sentinel of `errors.go` with `%w`
|
||||
and context. Never replace an error with a more convenient one.
|
||||
- Parsers check limits before allocating, never panic on input, and have a
|
||||
fuzz target.
|
||||
- No mutable global state. The profile registry and the clock are passed
|
||||
explicitly; only `cmd/datekeys` reads the wall clock.
|
||||
- Secrets never appear in logs or `String()` output, and our own buffers are
|
||||
wiped when done.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
go test -race ./...
|
||||
FUZZ_PARALLEL=4 ./scripts/fuzz.sh 60s # every parser; each worker uses a 100 MB temp file
|
||||
go test -tags interop ./capsule # needs the age and tle CLIs
|
||||
go test -tags integration ./... # live Quicknet
|
||||
```
|
||||
|
||||
Coverage must stay at or above 90 % for `codec`, `capsule`, `accesskey`,
|
||||
`datekey` and `agewrap`.
|
||||
|
||||
## Fixtures
|
||||
|
||||
`go run ./internal/testkit/genfixtures -out testdata` regenerates the vectors
|
||||
and creates missing fixtures. It never overwrites existing fixtures unless
|
||||
`-force` is given, which is reserved for specification changes.
|
||||
|
||||
## Commits and releases
|
||||
|
||||
Semantic versioning; `v0.x` until the specification reaches v1.0. Each release
|
||||
updates `CHANGELOG.md`.
|
||||
@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@ -0,0 +1,86 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report vulnerabilities privately, not in public issues:
|
||||
|
||||
- GitHub private vulnerability reporting on this repository (Security → Report
|
||||
a vulnerability), once the `datekeys` organisation hosts it.
|
||||
- Until then, contact the maintainers privately and ask for an encrypted
|
||||
channel.
|
||||
|
||||
Include a reproducible case: ideally a `.dkc` or `.dkk` file, or a test in the
|
||||
style of `capsule/mutation_test.go`. We aim to acknowledge reports within
|
||||
three working days.
|
||||
|
||||
## Supported versions
|
||||
|
||||
The module is pre-1.0 (`v0.x`). Only the latest `v0.x` release receives fixes.
|
||||
|
||||
## Scope and assumptions
|
||||
|
||||
In scope: every rule of the DateKeys Protocol Specification v0.8.1 this module
|
||||
implements (see `docs/traceability.md`), the CLI, and the handling of
|
||||
untrusted input (`.dkc`, `.dkk`, relay responses).
|
||||
|
||||
The protocol's own limits, which are not vulnerabilities of this module:
|
||||
|
||||
- The Quicknet timelock is not post-quantum; long-lived ciphertexts are exposed
|
||||
to harvest-now, decrypt-later (spec §7.7, §53).
|
||||
- Time confidentiality depends on drand's threshold assumption (spec §7.6).
|
||||
- `time_only` and `time_and_key` do not authenticate the creator (spec §36.1).
|
||||
- Opening a mature capsule years later needs the historical release (spec §50).
|
||||
- A compromised device can copy plaintext or secrets (spec §7.8).
|
||||
- Go cannot guarantee that secrets are erased from memory. The module wipes
|
||||
its own buffers (`I_PAYLOAD`, `CONTROL_CBOR`, `.dkk` material) on a best
|
||||
effort basis and promises no more.
|
||||
|
||||
## Cryptographic dependencies
|
||||
|
||||
No cryptography is implemented in this module. It depends on:
|
||||
|
||||
| Dependency | Role |
|
||||
|---|---|
|
||||
| `filippo.io/age` v1.3.2 | age files, X25519, STREAM, header MAC |
|
||||
| `github.com/drand/tlock` v1.2.0 | `TimeLock`, `TimeUnlock`, ciphertext encoding |
|
||||
| `github.com/drand/drand/v2` v2.1.7 | BLS verification (`crypto.Scheme`), chain-info hash |
|
||||
| `github.com/drand/kyber`, `github.com/drand/kyber-bls12381` | BLS12-381 pairing |
|
||||
| `github.com/fxamacker/cbor/v2` v2.9.4 | Deterministic CBOR |
|
||||
|
||||
All versions are pinned in `go.mod` and verified through `go.sum`. Changes to
|
||||
`age`, `tlock`, `drand` or `kyber` are reviewed manually.
|
||||
|
||||
### Known risks under watch
|
||||
|
||||
- **`github.com/kilic/bls12-381` is archived.** `kyber-bls12381` builds on it,
|
||||
and both `drand` and `tlock` depend on that stack. Mitigation: pinned
|
||||
versions, `govulncheck` on every change and nightly, and this plan if a
|
||||
vulnerability appears or the dependency becomes untenable: (1) move to a
|
||||
maintained fork adopted by drand, or (2) extract BLS verification onto a
|
||||
maintained BLS12-381 implementation, keeping the golden vectors and fixtures
|
||||
as the acceptance test.
|
||||
- **`tlock` has had no tagged release since August 2024.** Only its exported
|
||||
core (`TimeLock`, `TimeUnlock`, `CiphertextToBytes`, `BytesToCiphertext`) is
|
||||
used, pinned by version.
|
||||
- **`drand/v2` brings gRPC and protobuf into the binary** through
|
||||
`common/chain`, used for the chain-hash self-check of profiles. With the
|
||||
`google.golang.org/grpc` v1.81.1 that `drand/v2` v2.1.7 selects,
|
||||
`govulncheck` reported GO-2026-6348 and GO-2026-6061 as reachable, so
|
||||
`go.mod` requires grpc v1.84.0, and `golang.org/x/crypto` v0.57.0 for
|
||||
GO-2026-6354 and GO-2026-6355. With those, `govulncheck` v1.8.0 on
|
||||
Go 1.26.8 finds no reachable vulnerability (25 September 2026); two
|
||||
unreachable advisories without a released fix remain, GO-2026-6443 (grpc)
|
||||
and GO-2026-5932 (x/crypto). Build with a patched Go toolchain: Go 1.26.0
|
||||
itself has reachable standard-library advisories fixed in 1.26.1 and later.
|
||||
Plan §11 item 5 (extracting BLS verification onto `kyber-bls12381` alone)
|
||||
would remove gRPC from the graph entirely.
|
||||
- **Fixtures over past rounds** rely on the embedded signatures being genuine;
|
||||
every test run verifies them against the pinned public key.
|
||||
|
||||
## Supply chain
|
||||
|
||||
- Reproducible builds: `-trimpath`, `CGO_ENABLED=0`, pinned toolchain in CI.
|
||||
- Releases publish SHA-256 checksums and a CycloneDX SBOM, and are signed with
|
||||
cosign once the organisation's signing identity exists.
|
||||
- The Quicknet root of trust is compiled into the binary and checked against
|
||||
its pinned `profile_hash` (`4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4`).
|
||||
@ -0,0 +1,20 @@
|
||||
# Trademarks
|
||||
|
||||
"DateKeys" is a reserved name of the DateKeys project. The Apache-2.0 license
|
||||
of the code and the CC-BY-4.0 license of the specification do not grant any
|
||||
right to use it as a product, service or organisation name (Apache-2.0 §6).
|
||||
|
||||
Allowed without asking:
|
||||
|
||||
- Stating compatibility in plain words, for example "implements the DateKey
|
||||
protocol", "reads and writes DateKeyCap (.dkc) files" or "compatible with
|
||||
DateKeys v0.8.1", as long as it is true for the version named.
|
||||
- Referring to this project, its specification or its file formats by name in
|
||||
documentation, articles and talks.
|
||||
|
||||
Not allowed without written permission:
|
||||
|
||||
- Naming a product, service, package, domain or organisation "DateKeys" or a
|
||||
confusingly similar name.
|
||||
- Suggesting endorsement by, or affiliation with, the DateKeys project.
|
||||
- Calling a modified or incompatible implementation "DateKeys".
|
||||
@ -0,0 +1,243 @@
|
||||
// Package accesskey implements the DateKeys Access Key, the portable .dkk
|
||||
// credential (spec §38, §40-§44).
|
||||
//
|
||||
// A .dkk is a sensitive capability (spec §7.4). Its X25519 identity is stored
|
||||
// as 32 raw bytes; the Bech32 AGE-SECRET-KEY-1... form is only an export
|
||||
// format for humans (spec §38). No type in this package prints the material.
|
||||
package accesskey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
)
|
||||
|
||||
// Framing and schema constants (spec §40, §41).
|
||||
const (
|
||||
Magic = "DKK1"
|
||||
FramingVersion = 1
|
||||
PreludeSize = 12
|
||||
// MaxBodyLen is the parser limit of spec §57, checked before allocating.
|
||||
MaxBodyLen = 16 << 20
|
||||
TypeTag = "datekeys-access-key"
|
||||
SchemaVersion = 1
|
||||
// TypeX25519 is the only access_type of V1 (spec §41).
|
||||
TypeX25519 = "x25519"
|
||||
|
||||
idSize = 16
|
||||
digestSize = 32
|
||||
x25519Size = 32
|
||||
)
|
||||
|
||||
// AccessKey is a decoded .dkk.
|
||||
type AccessKey struct {
|
||||
CredentialID [16]byte // key 2, random and opaque (spec §42)
|
||||
CapsuleID [16]byte // key 3, the only capsule this credential is for (spec §38)
|
||||
Type string // key 4, access_type
|
||||
Material []byte // key 5, access_material: 32 raw X25519 identity bytes. SECRET.
|
||||
// Verification is key 6, optional; nil when absent (spec §43, §58.1).
|
||||
Verification *Verification
|
||||
Critical []extension.Extension // key 7
|
||||
Noncritical []extension.Extension // key 8
|
||||
}
|
||||
|
||||
// Verification is verification_metadata (spec §43). It supports fast failure
|
||||
// and UX only; it is not a security property.
|
||||
type Verification struct {
|
||||
CapsuleDigest []byte // key 0, SHA-256 of the exact .dkc bytes
|
||||
}
|
||||
|
||||
type bodyWire struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
CredentialID []byte `cbor:"2,keyasint"`
|
||||
CapsuleID []byte `cbor:"3,keyasint"`
|
||||
AccessType string `cbor:"4,keyasint"`
|
||||
Material []byte `cbor:"5,keyasint"`
|
||||
Verification *verificationWire `cbor:"6,keyasint,omitempty"`
|
||||
Critical []extension.Wire `cbor:"7,keyasint,omitempty"`
|
||||
Noncritical []extension.Wire `cbor:"8,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
type verificationWire struct {
|
||||
CapsuleDigest []byte `cbor:"0,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// String describes k without its material.
|
||||
func (k AccessKey) String() string {
|
||||
return fmt.Sprintf("AccessKey{credential_id=%x capsule_id=%x type=%s material=REDACTED}", k.CredentialID, k.CapsuleID, k.Type)
|
||||
}
|
||||
|
||||
// GoString describes k without its material.
|
||||
func (k AccessKey) GoString() string { return k.String() }
|
||||
|
||||
// Identity returns the age identity of an x25519 access key.
|
||||
func (k *AccessKey) Identity() (age.Identity, error) {
|
||||
if err := k.validateMaterial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := agewrap.X25519IdentityFromRaw(k.Material)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("accesskey: %v: %w", err, datekeys.ErrAccessInvalid)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Wipe overwrites the material in place. It is best effort: Go may have made
|
||||
// copies that cannot be reached.
|
||||
func (k *AccessKey) Wipe() { clear(k.Material) }
|
||||
|
||||
func (k *AccessKey) validateMaterial() error {
|
||||
if k.Type != TypeX25519 {
|
||||
return fmt.Errorf("accesskey: access_type %q is not supported by V1: %w", k.Type, datekeys.ErrAccessInvalid)
|
||||
}
|
||||
if len(k.Material) != x25519Size {
|
||||
return fmt.Errorf("accesskey: x25519 access_material is %d bytes, want %d: %w", len(k.Material), x25519Size, datekeys.ErrAccessInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalBody returns BODY_CBOR, the Deterministic CBOR body of k (spec §41).
|
||||
func (k *AccessKey) MarshalBody() ([]byte, error) {
|
||||
if err := k.validateMaterial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := bodyWire{
|
||||
Type: TypeTag,
|
||||
Version: SchemaVersion,
|
||||
CredentialID: k.CredentialID[:],
|
||||
CapsuleID: k.CapsuleID[:],
|
||||
AccessType: k.Type,
|
||||
Material: k.Material,
|
||||
}
|
||||
if k.Verification != nil {
|
||||
if len(k.Verification.CapsuleDigest) != digestSize {
|
||||
// An empty map is not a canonical representation of absence (spec §43).
|
||||
return nil, fmt.Errorf("accesskey: capsule_digest must be %d bytes: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
w.Verification = &verificationWire{CapsuleDigest: k.Verification.CapsuleDigest}
|
||||
}
|
||||
var err error
|
||||
if w.Critical, err = extension.Encode(k.Critical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.Noncritical, err = extension.Encode(k.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := codec.Marshal(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > MaxBodyLen {
|
||||
return nil, fmt.Errorf("accesskey: body of %d bytes exceeds %d", len(b), MaxBodyLen)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Encode writes k as a complete .dkk: prelude and BODY_CBOR (spec §40).
|
||||
func Encode(w io.Writer, k *AccessKey) error {
|
||||
body, err := k.MarshalBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pre [PreludeSize]byte
|
||||
copy(pre[0:4], Magic)
|
||||
pre[4] = FramingVersion
|
||||
binary.BigEndian.PutUint32(pre[8:12], uint32(len(body)))
|
||||
if _, err := w.Write(pre[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode reads exactly one .dkk from r and validates its framing, its
|
||||
// canonical body and its fields. Bytes after BODY_CBOR are rejected.
|
||||
//
|
||||
// Decode does not decide whether critical extensions are known; the consumer
|
||||
// checks them against its extension.Registry (capsule.Open does).
|
||||
func Decode(r io.Reader) (*AccessKey, error) {
|
||||
var pre [PreludeSize]byte
|
||||
n, err := io.ReadFull(r, pre[:])
|
||||
if n < 4 || string(pre[0:4]) != Magic {
|
||||
return nil, fmt.Errorf("accesskey: %w", datekeys.ErrInvalidMagic)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("accesskey: truncated prelude: %w", datekeys.ErrIntegrity)
|
||||
}
|
||||
if pre[4] != FramingVersion {
|
||||
return nil, fmt.Errorf("accesskey: framing version %d: %w", pre[4], datekeys.ErrUnsupportedVersion)
|
||||
}
|
||||
if pre[5] != 0 || pre[6] != 0 || pre[7] != 0 {
|
||||
return nil, fmt.Errorf("accesskey: flags %#x, reserved %#x%02x: %w", pre[5], pre[6], pre[7], datekeys.ErrInvalidFlags)
|
||||
}
|
||||
bodyLen := binary.BigEndian.Uint32(pre[8:12])
|
||||
if bodyLen > MaxBodyLen {
|
||||
return nil, fmt.Errorf("accesskey: BODY_LEN %d exceeds the %d-byte limit: %w", bodyLen, MaxBodyLen, datekeys.ErrIntegrity)
|
||||
}
|
||||
// The buffer grows with the data actually read, so a short file that
|
||||
// declares a large BODY_LEN does not force an allocation of that size.
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.CopyN(&buf, r, int64(bodyLen)); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: truncated body: %w", datekeys.ErrIntegrity)
|
||||
}
|
||||
body := buf.Bytes()
|
||||
var extra [1]byte
|
||||
switch n, err := io.ReadFull(r, extra[:]); {
|
||||
case n != 0:
|
||||
return nil, fmt.Errorf("accesskey: data after BODY_CBOR: %w", datekeys.ErrIntegrity)
|
||||
case !errors.Is(err, io.EOF):
|
||||
return nil, fmt.Errorf("accesskey: reading after BODY_CBOR: %w", err)
|
||||
}
|
||||
return DecodeBody(body)
|
||||
}
|
||||
|
||||
// DecodeBody validates and decodes BODY_CBOR.
|
||||
func DecodeBody(body []byte) (*AccessKey, error) {
|
||||
if err := codec.CheckSchema(body, TypeTag, SchemaVersion); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: %w", err)
|
||||
}
|
||||
var w bodyWire
|
||||
if err := codec.Unmarshal(body, &w); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: %w", err)
|
||||
}
|
||||
if len(w.CredentialID) != idSize || len(w.CapsuleID) != idSize {
|
||||
return nil, fmt.Errorf("accesskey: credential_id and capsule_id must be %d bytes: %w", idSize, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
k := &AccessKey{Type: w.AccessType, Material: bytes.Clone(w.Material)}
|
||||
copy(k.CredentialID[:], w.CredentialID)
|
||||
copy(k.CapsuleID[:], w.CapsuleID)
|
||||
if w.Verification != nil {
|
||||
if len(w.Verification.CapsuleDigest) != digestSize {
|
||||
return nil, fmt.Errorf("accesskey: verification_metadata must hold a %d-byte capsule_digest: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
k.Verification = &Verification{CapsuleDigest: bytes.Clone(w.Verification.CapsuleDigest)}
|
||||
}
|
||||
var err error
|
||||
if k.Critical, err = extension.Decode(w.Critical); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: critical_extensions: %w", err)
|
||||
}
|
||||
if k.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: noncritical_extensions: %w", err)
|
||||
}
|
||||
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("accesskey: %w", err)
|
||||
}
|
||||
if err := k.validateMaterial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clear(w.Material)
|
||||
return k, nil
|
||||
}
|
||||
@ -0,0 +1,284 @@
|
||||
package accesskey_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
const fixtures = "../testdata/fixtures"
|
||||
|
||||
func loadDKK(t *testing.T, name string) ([]byte, testkit.DKKFixture) {
|
||||
t.Helper()
|
||||
var f testkit.DKKFixture
|
||||
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".dkk.json"), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(fixtures, f.File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, f
|
||||
}
|
||||
|
||||
// Spec §68: parse, validate and use the official .dkk fixtures.
|
||||
func TestFixtures(t *testing.T) {
|
||||
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
b, f := loadDKK(t, name)
|
||||
k, err := accesskey.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(k.CredentialID[:]) != f.CredentialID || hex.EncodeToString(k.CapsuleID[:]) != f.CapsuleID ||
|
||||
k.Type != f.AccessType || hex.EncodeToString(k.Material) != f.Material ||
|
||||
k.Verification == nil || hex.EncodeToString(k.Verification.CapsuleDigest) != f.CapsuleDigest ||
|
||||
len(k.Critical)+len(k.Noncritical) != len(f.Extensions) {
|
||||
t.Fatalf("decoded values differ from the fixture: %v", k)
|
||||
}
|
||||
// Encode(Decode(x)) == x.
|
||||
var out bytes.Buffer
|
||||
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), b) {
|
||||
t.Fatal("re-encoding differs from the fixture bytes")
|
||||
}
|
||||
// Expected result: the identity opens the INNER_ACCESS_AGE of its capsule.
|
||||
var cf testkit.DKCFixture
|
||||
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".json"), &cf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dkc, _ := os.ReadFile(filepath.Join(fixtures, f.Capsule))
|
||||
parts, _ := testkit.Split(dkc)
|
||||
timeID, _ := agewrap.NewTimeIdentity(testkitProfile(), cf.Release.Round, testkit.Release(cf.Release.Round))
|
||||
inner := mustDecrypt(t, parts.Sealed, timeID)
|
||||
id, err := k.Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acc, _ := agewrap.NewAccessIdentity(id)
|
||||
if control := mustDecrypt(t, inner, acc); hex.EncodeToString(control) != cf.ControlCBOR {
|
||||
t.Fatal("the .dkk does not yield the expected CONTROL_CBOR")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsAreNotPrinted(t *testing.T) {
|
||||
b, f := loadDKK(t, "time_and_key_portable")
|
||||
k, _ := accesskey.Decode(bytes.NewReader(b))
|
||||
for _, format := range []string{"%v", "%+v", "%#v", "%s"} {
|
||||
for _, v := range []any{k, *k} {
|
||||
if s := fmt.Sprintf(format, v); strings.Contains(s, f.Material) || !strings.Contains(s, "REDACTED") {
|
||||
t.Fatalf("%s leaks or hides nothing: %s", format, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func frame(body []byte) []byte {
|
||||
pre := make([]byte, accesskey.PreludeSize)
|
||||
copy(pre, accesskey.Magic)
|
||||
pre[4] = accesskey.FramingVersion
|
||||
binary.BigEndian.PutUint32(pre[8:], uint32(len(body)))
|
||||
return append(pre, body...)
|
||||
}
|
||||
|
||||
func TestDecodeRejects(t *testing.T) {
|
||||
good, _ := loadDKK(t, "time_and_key_portable")
|
||||
body := good[accesskey.PreludeSize:]
|
||||
var w map[uint64]any
|
||||
if err := codec.Unmarshal(body, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
with := func(edit func(m map[uint64]any)) []byte {
|
||||
m := map[uint64]any{}
|
||||
for k, v := range w {
|
||||
m[k] = v
|
||||
}
|
||||
edit(m)
|
||||
b, err := codec.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return frame(b)
|
||||
}
|
||||
set := func(b []byte, i int, v byte) []byte { c := bytes.Clone(b); c[i] = v; return c }
|
||||
bigLen := bytes.Clone(good)
|
||||
binary.BigEndian.PutUint32(bigLen[8:], accesskey.MaxBodyLen+1)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []byte
|
||||
want error
|
||||
}{
|
||||
{"magic", set(good, 3, '2'), datekeys.ErrInvalidMagic},
|
||||
{"a .dkc", append([]byte("DKC1"), good[4:]...), datekeys.ErrInvalidMagic},
|
||||
{"empty", nil, datekeys.ErrInvalidMagic},
|
||||
{"framing version", set(good, 4, 2), datekeys.ErrUnsupportedVersion},
|
||||
{"flags", set(good, 5, 1), datekeys.ErrInvalidFlags},
|
||||
{"reserved", set(good, 7, 1), datekeys.ErrInvalidFlags},
|
||||
{"body length above the limit", bigLen, datekeys.ErrIntegrity},
|
||||
{"truncated prelude", good[:10], datekeys.ErrIntegrity},
|
||||
{"truncated body", good[:len(good)-1], datekeys.ErrIntegrity},
|
||||
{"trailing data", append(bytes.Clone(good), 0), datekeys.ErrIntegrity},
|
||||
{"schema version", with(func(m map[uint64]any) { m[1] = uint64(2) }), datekeys.ErrUnsupportedVersion},
|
||||
{"type tag", with(func(m map[uint64]any) { m[0] = "datekeycap" }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"empty verification map", with(func(m map[uint64]any) { m[6] = map[uint64]any{} }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"empty extension array", with(func(m map[uint64]any) { m[8] = []any{} }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"null verification", with(func(m map[uint64]any) { m[6] = nil }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"unknown key", with(func(m map[uint64]any) { m[9] = "x" }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"short capsule_id", with(func(m map[uint64]any) { m[3] = make([]byte, 15) }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"short digest", with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 31)} }), datekeys.ErrNonCanonicalCBOR},
|
||||
{"unknown access type", with(func(m map[uint64]any) { m[4] = "mlkem768" }), datekeys.ErrAccessInvalid},
|
||||
{"short material", with(func(m map[uint64]any) { m[5] = make([]byte, 31) }), datekeys.ErrAccessInvalid},
|
||||
{"non-canonical body", frame(append([]byte{0xb9, 0x00, 0x07}, body[1:]...)), datekeys.ErrNonCanonicalCBOR},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := accesskey.Decode(bytes.NewReader(tc.in)); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRejectsAbsenceAsEmptyMap(t *testing.T) {
|
||||
id, _ := age.GenerateX25519Identity()
|
||||
raw, _ := agewrap.RawX25519Identity(id)
|
||||
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: raw, Verification: &accesskey.Verification{}}
|
||||
if err := accesskey.Encode(io.Discard, k); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("empty verification map encoded: %v", err)
|
||||
}
|
||||
k.Verification = nil
|
||||
k.Noncritical = []extension.Extension{{ID: "org.example.delivery", Version: 1}}
|
||||
var b bytes.Buffer
|
||||
if err := accesskey.Encode(&b, k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := accesskey.Decode(&b)
|
||||
if err != nil || back.Verification != nil || len(back.Noncritical) != 1 {
|
||||
t.Fatalf("%v %v", back, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityWipeAndEncodeErrors(t *testing.T) {
|
||||
b, _ := loadDKK(t, "time_and_key_portable")
|
||||
k, _ := accesskey.Decode(bytes.NewReader(b))
|
||||
other := *k
|
||||
other.Type = "mlkem768"
|
||||
if _, err := other.Identity(); !errors.Is(err, datekeys.ErrAccessInvalid) {
|
||||
t.Fatalf("unsupported type: %v", err)
|
||||
}
|
||||
dup := []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
|
||||
for name, edit := range map[string]func(k *accesskey.AccessKey){
|
||||
"short material": func(k *accesskey.AccessKey) { k.Material = k.Material[:31] },
|
||||
"repeated critical": func(k *accesskey.AccessKey) { k.Critical = dup },
|
||||
"repeated noncritical": func(k *accesskey.AccessKey) { k.Noncritical = dup },
|
||||
"both arrays": func(k *accesskey.AccessKey) { k.Critical, k.Noncritical = dup[:1], dup[1:] },
|
||||
} {
|
||||
c := *k
|
||||
c.Material = bytes.Clone(k.Material)
|
||||
edit(&c)
|
||||
if err := accesskey.Encode(io.Discard, &c); err == nil {
|
||||
t.Errorf("%s: encoded", name)
|
||||
}
|
||||
}
|
||||
if err := accesskey.Encode(failingWriter{}, k); err == nil {
|
||||
t.Fatal("write error ignored")
|
||||
}
|
||||
k.Wipe()
|
||||
if !bytes.Equal(k.Material, make([]byte, 32)) {
|
||||
t.Fatal("material not wiped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBodyExtensionRules(t *testing.T) {
|
||||
good, _ := loadDKK(t, "time_and_key_portable")
|
||||
var m map[uint64]any
|
||||
if err := codec.Unmarshal(good[accesskey.PreludeSize:], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ext := func(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
|
||||
for name, edit := range map[string]func(m map[uint64]any){
|
||||
"critical out of order": func(m map[uint64]any) { m[7] = []any{ext("b", 1), ext("a", 1)} },
|
||||
"noncritical repeated": func(m map[uint64]any) { m[8] = []any{ext("a", 1), ext("a", 2)} },
|
||||
"both arrays": func(m map[uint64]any) { m[7] = []any{ext("a", 1)}; m[8] = []any{ext("a", 1)} },
|
||||
// Raw data is copied verbatim by the outer re-encoding, so the
|
||||
// extension layer must reject 1 encoded in two bytes on its own.
|
||||
"non-canonical ext data": func(m map[uint64]any) {
|
||||
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbor.RawMessage{0x18, 0x01}}}
|
||||
},
|
||||
"empty critical array": func(m map[uint64]any) { m[7] = []any{} },
|
||||
"extension id not string": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: uint64(1), 1: uint64(1)}} },
|
||||
} {
|
||||
c := map[uint64]any{}
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
edit(c)
|
||||
b, err := codec.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := accesskey.DecodeBody(b); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
|
||||
|
||||
func FuzzDecode(f *testing.F) {
|
||||
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
|
||||
b, err := os.ReadFile(filepath.Join(fixtures, name+".dkk"))
|
||||
if err == nil {
|
||||
f.Add(b)
|
||||
}
|
||||
}
|
||||
f.Add([]byte("DKK1\x01\x00\x00\x00\x00\x00\x00\x01\xa0"))
|
||||
f.Fuzz(func(t *testing.T, in []byte) {
|
||||
k, err := accesskey.Decode(bytes.NewReader(in))
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), in) {
|
||||
t.Fatal("accepted a .dkk that does not re-encode to its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testkitProfile() *profile.Profile { return profile.Quicknet() }
|
||||
|
||||
func mustDecrypt(t *testing.T, file []byte, id age.Identity) []byte {
|
||||
t.Helper()
|
||||
r, err := age.Decrypt(bytes.NewReader(file), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -0,0 +1,407 @@
|
||||
// Package agewrap holds the age recipients and identities that DateKeys wraps
|
||||
// around standard age files (spec §28-§37), plus the structural stanza rules
|
||||
// every DateKeys age file must satisfy.
|
||||
//
|
||||
// The cryptography is age, tlock and drand's BLS verification. This package
|
||||
// adds only the rules of the protocol:
|
||||
//
|
||||
// - OUTER_TIME_AGE holds exactly one tlock stanza for the expected round and
|
||||
// the pinned chain hash (spec §32, §35, §63 step 11).
|
||||
// - PAYLOAD_AGE holds exactly one X25519 stanza, for R_PAYLOAD (spec §29,
|
||||
// §63 step 17).
|
||||
// - INNER_ACCESS_AGE holds one or more stanzas, all X25519, one per
|
||||
// recipient (spec §33, §63 step 13).
|
||||
//
|
||||
// The rules are enforced inside Identity.Unwrap, which age calls with the
|
||||
// complete set of stanzas of the file, so that no file is accepted just
|
||||
// because age managed to unwrap a file key (spec §27, §63). The same checks
|
||||
// are exposed for the pre-unlock inspection, which reads the stanzas through a
|
||||
// probe identity without decrypting anything or touching secrets.
|
||||
package agewrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/drand/drand/v2/common"
|
||||
"github.com/drand/drand/v2/crypto"
|
||||
"github.com/drand/kyber"
|
||||
"github.com/drand/tlock"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec/bech32"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// Stanza types of V1.
|
||||
const (
|
||||
StanzaTLock = "tlock"
|
||||
StanzaX25519 = "X25519"
|
||||
)
|
||||
|
||||
// FileKeySize is the size of every age file key: FK_PAYLOAD, FK_ACCESS and
|
||||
// FK_TIME (spec §28).
|
||||
const FileKeySize = 16
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural rules
|
||||
|
||||
// CheckTimeStanzas enforces the OUTER_TIME_AGE rule: exactly one stanza, of
|
||||
// type tlock, whose round is the DateKey round and whose chain hash is the one
|
||||
// of the pinned profile (spec §32, §35, §63 steps 5, 8 and 11).
|
||||
func CheckTimeStanzas(stanzas []*age.Stanza, p *profile.Profile, round uint64) error {
|
||||
if len(stanzas) != 1 {
|
||||
return fmt.Errorf("agewrap: OUTER_TIME_AGE has %d stanzas, want exactly one tlock stanza: %w", len(stanzas), datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
s := stanzas[0]
|
||||
if s.Type != StanzaTLock {
|
||||
return fmt.Errorf("agewrap: OUTER_TIME_AGE stanza type %q, want %q: %w", s.Type, StanzaTLock, datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
if len(s.Args) != 2 {
|
||||
return fmt.Errorf("agewrap: tlock stanza has %d arguments, want 2: %w", len(s.Args), datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
if want := strconv.FormatUint(round, 10); s.Args[0] != want {
|
||||
return fmt.Errorf("agewrap: tlock stanza round %q, DateKey round %s: %w", s.Args[0], want, datekeys.ErrRoundMismatch)
|
||||
}
|
||||
if want := p.ChainHashHex(); s.Args[1] != want {
|
||||
return fmt.Errorf("agewrap: tlock stanza chain hash %q, pinned profile %s uses %s: %w", s.Args[1], p.ID, want, datekeys.ErrProfileMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPayloadStanzas enforces the PAYLOAD_AGE rule: exactly one stanza, of
|
||||
// type X25519 (spec §29, §63 steps 6 and 17).
|
||||
func CheckPayloadStanzas(stanzas []*age.Stanza) error {
|
||||
if len(stanzas) != 1 {
|
||||
return fmt.Errorf("agewrap: PAYLOAD_AGE has %d stanzas, want exactly one X25519 stanza: %w", len(stanzas), datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
if t := stanzas[0].Type; t != StanzaX25519 {
|
||||
return fmt.Errorf("agewrap: PAYLOAD_AGE stanza type %q, want %q: %w", t, StanzaX25519, datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAccessStanzas enforces the INNER_ACCESS_AGE rule: one or more stanzas,
|
||||
// all of type X25519 (spec §33, §36, §63 step 13). Two stanzas with the same
|
||||
// ephemeral share would be two stanzas for one recipient and are rejected.
|
||||
func CheckAccessStanzas(stanzas []*age.Stanza) error {
|
||||
if len(stanzas) == 0 {
|
||||
return fmt.Errorf("agewrap: INNER_ACCESS_AGE has no stanzas: %w", datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
seen := make(map[string]bool, len(stanzas))
|
||||
for i, s := range stanzas {
|
||||
if s.Type != StanzaX25519 {
|
||||
return fmt.Errorf("agewrap: INNER_ACCESS_AGE stanza %d has type %q, want %q: %w", i, s.Type, StanzaX25519, datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
if len(s.Args) == 1 {
|
||||
if seen[s.Args[0]] {
|
||||
return fmt.Errorf("agewrap: INNER_ACCESS_AGE stanza %d repeats an ephemeral share: %w", i, datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
seen[s.Args[0]] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inspection probe
|
||||
|
||||
var errProbe = errors.New("agewrap: probe finished")
|
||||
|
||||
// probe records the stanzas age hands to Unwrap and stops decryption with an
|
||||
// error that does not wrap age.ErrIncorrectIdentity, so age returns it as is.
|
||||
type probe struct{ stanzas []*age.Stanza }
|
||||
|
||||
func (p *probe) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
p.stanzas = cloneStanzas(stanzas)
|
||||
return nil, errProbe
|
||||
}
|
||||
|
||||
// Stanzas parses the age header at the start of r with age itself and returns
|
||||
// its recipient stanzas. It decrypts nothing and uses no secret: the header is
|
||||
// extracted with age.ExtractHeader and handed to age.DecryptHeader with a
|
||||
// probe identity (spec §27, §63 steps 5 and 6).
|
||||
//
|
||||
// The result is structural. Its authenticity is only established when the
|
||||
// header MAC is verified while opening the file (spec §27).
|
||||
func Stanzas(r io.Reader) ([]*age.Stanza, error) {
|
||||
hdr, err := age.ExtractHeader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: not a valid age file: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
var p probe
|
||||
if _, err := age.DecryptHeader(hdr, &p); !errors.Is(err, errProbe) {
|
||||
return nil, fmt.Errorf("agewrap: unexpected result inspecting the age header: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
return p.stanzas, nil
|
||||
}
|
||||
|
||||
func cloneStanzas(in []*age.Stanza) []*age.Stanza {
|
||||
out := make([]*age.Stanza, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = &age.Stanza{Type: s.Type, Args: append([]string(nil), s.Args...), Body: bytes.Clone(s.Body)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tlock recipient and identity (OUTER_TIME_AGE)
|
||||
|
||||
// TimeRecipient wraps the file key with tlock for one round of a pinned
|
||||
// profile and emits the stanza "tlock <round> <chainhash>", byte-compatible
|
||||
// with the stanza of the tlock library and the tle CLI (spec §32, §35). It
|
||||
// uses only the exported core of tlock: TimeLock and CiphertextToBytes.
|
||||
type TimeRecipient struct {
|
||||
chainHash string
|
||||
round uint64
|
||||
scheme *crypto.Scheme
|
||||
key kyber.Point
|
||||
}
|
||||
|
||||
var _ age.RecipientWithLabels = (*TimeRecipient)(nil)
|
||||
|
||||
// NewTimeRecipient returns the tlock recipient of round under p, using only
|
||||
// the pinned parameters of p (strict mode, spec §35).
|
||||
func NewTimeRecipient(p *profile.Profile, round uint64) (*TimeRecipient, error) {
|
||||
scheme, key, err := pinned(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if round == 0 || round > p.MaxRound() {
|
||||
return nil, fmt.Errorf("agewrap: round %d outside the range of %s: %w", round, p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
return &TimeRecipient{chainHash: p.ChainHashHex(), round: round, scheme: scheme, key: key}, nil
|
||||
}
|
||||
|
||||
// Wrap implements age.Recipient.
|
||||
func (r *TimeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
ct, err := tlock.TimeLock(*r.scheme, r.key, r.round, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: tlock: %w", err)
|
||||
}
|
||||
body, err := tlock.CiphertextToBytes(*r.scheme, ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: tlock ciphertext: %w", err)
|
||||
}
|
||||
return []*age.Stanza{{
|
||||
Type: StanzaTLock,
|
||||
Args: []string{strconv.FormatUint(r.round, 10), r.chainHash},
|
||||
Body: body,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// WrapWithLabels implements age.RecipientWithLabels with a random label, so
|
||||
// that age refuses to mix this recipient with any other one in the same file:
|
||||
// OUTER_TIME_AGE must hold exactly one tlock stanza.
|
||||
func (r *TimeRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
|
||||
s, err := r.Wrap(fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var label [16]byte
|
||||
_, _ = rand.Read(label[:]) // never fails since Go 1.24
|
||||
return s, []string{"datekeys-tlock-" + hex.EncodeToString(label[:])}, nil
|
||||
}
|
||||
|
||||
// TimeIdentity opens OUTER_TIME_AGE under the strict rules of spec §35 and
|
||||
// §63 step 11. Unwrap validates the complete stanza set, verifies the release
|
||||
// locally and calls tlock.TimeUnlock, which verifies the beacon again before
|
||||
// decrypting. Every failure keeps its own normative error: none is turned
|
||||
// into "too early".
|
||||
type TimeIdentity struct {
|
||||
profile *profile.Profile
|
||||
round uint64
|
||||
release provider.Release
|
||||
scheme *crypto.Scheme
|
||||
key kyber.Point
|
||||
}
|
||||
|
||||
var _ age.Identity = (*TimeIdentity)(nil)
|
||||
|
||||
// NewTimeIdentity returns the identity that opens OUTER_TIME_AGE for round
|
||||
// with release.
|
||||
func NewTimeIdentity(p *profile.Profile, round uint64, release provider.Release) (*TimeIdentity, error) {
|
||||
scheme, key, err := pinned(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TimeIdentity{profile: p.Clone(), round: round, release: release, scheme: scheme, key: key}, nil
|
||||
}
|
||||
|
||||
// Unwrap implements age.Identity.
|
||||
func (i *TimeIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
if err := CheckTimeStanzas(stanzas, i.profile, i.round); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := provider.Verify(i.profile, provider.Condition{Round: i.round}, i.release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct, err := tlock.BytesToCiphertext(*i.scheme, stanzas[0].Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: malformed tlock stanza body: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
beacon := common.Beacon{Round: i.release.Round, Signature: i.release.Signature}
|
||||
fileKey, err := tlock.TimeUnlock(*i.scheme, i.key, beacon, ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: tlock unwrap failed: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
if len(fileKey) != FileKeySize {
|
||||
return nil, fmt.Errorf("agewrap: tlock stanza wraps a %d-byte file key: %w", len(fileKey), datekeys.ErrIntegrity)
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
func pinned(p *profile.Profile) (*crypto.Scheme, kyber.Point, error) {
|
||||
scheme, err := p.DrandScheme()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
key := scheme.KeyGroup.Point()
|
||||
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("agewrap: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if key.Equal(key.Null()) {
|
||||
return nil, nil, fmt.Errorf("agewrap: pinned public key of %s is the identity element: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
return scheme, key, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// X25519 identities (PAYLOAD_AGE and INNER_ACCESS_AGE)
|
||||
|
||||
// PayloadIdentity opens PAYLOAD_AGE with I_PAYLOAD (spec §29, §30.1, §63 step
|
||||
// 17). It rejects the file unless it holds exactly one X25519 stanza and that
|
||||
// stanza is for R_PAYLOAD.
|
||||
type PayloadIdentity struct {
|
||||
id *age.X25519Identity
|
||||
}
|
||||
|
||||
var _ age.Identity = (*PayloadIdentity)(nil)
|
||||
|
||||
// NewPayloadIdentity returns the identity for the raw 32-byte I_PAYLOAD.
|
||||
func NewPayloadIdentity(raw []byte) (*PayloadIdentity, error) {
|
||||
id, err := X25519IdentityFromRaw(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PayloadIdentity{id: id}, nil
|
||||
}
|
||||
|
||||
// Unwrap implements age.Identity.
|
||||
func (i *PayloadIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
if err := CheckPayloadStanzas(stanzas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileKey, err := i.id.Unwrap(stanzas)
|
||||
if errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
// CONTROL_A + PAYLOAD_AGE_B: I_PAYLOAD_A cannot unwrap FK_PAYLOAD_B (spec §30.1).
|
||||
return nil, fmt.Errorf("agewrap: PAYLOAD_AGE is not encrypted to this control's R_PAYLOAD: %w", datekeys.ErrIntegrity)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: malformed PAYLOAD_AGE stanza: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// AccessIdentity opens INNER_ACCESS_AGE with the caller's X25519 identities,
|
||||
// including the one of a portable .dkk (spec §33, §38, §63 step 13). It
|
||||
// validates the complete stanza set first, and rejects the file if one
|
||||
// identity unwraps more than one stanza, which would be two stanzas for the
|
||||
// same recipient.
|
||||
type AccessIdentity struct {
|
||||
ids []age.Identity
|
||||
}
|
||||
|
||||
var _ age.Identity = (*AccessIdentity)(nil)
|
||||
|
||||
// NewAccessIdentity returns an AccessIdentity trying ids in order. At least
|
||||
// one identity is required.
|
||||
func NewAccessIdentity(ids ...age.Identity) (*AccessIdentity, error) {
|
||||
var clean []age.Identity
|
||||
for _, id := range ids {
|
||||
if id != nil {
|
||||
clean = append(clean, id)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return nil, fmt.Errorf("agewrap: time_and_key needs an access identity: %w", datekeys.ErrAccessRequired)
|
||||
}
|
||||
return &AccessIdentity{ids: clean}, nil
|
||||
}
|
||||
|
||||
// Unwrap implements age.Identity.
|
||||
func (a *AccessIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
if err := CheckAccessStanzas(stanzas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range a.ids {
|
||||
var fileKey []byte
|
||||
matches := 0
|
||||
for _, s := range stanzas {
|
||||
fk, err := id.Unwrap([]*age.Stanza{s})
|
||||
if errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: malformed INNER_ACCESS_AGE stanza: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
matches++
|
||||
if fileKey == nil {
|
||||
fileKey = fk
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
return nil, fmt.Errorf("agewrap: one identity opens %d INNER_ACCESS_AGE stanzas, want one per recipient: %w", matches, datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
if matches == 1 {
|
||||
return fileKey, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("agewrap: no supplied identity is a recipient of INNER_ACCESS_AGE: %w", datekeys.ErrAccessInvalid)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw X25519 keys
|
||||
|
||||
// X25519IdentityFromRaw converts 32 raw identity bytes, the canonical form
|
||||
// inside CONTROL_CBOR and .dkk (spec §31, §38), to an age identity.
|
||||
func X25519IdentityFromRaw(raw []byte) (*age.X25519Identity, error) {
|
||||
if len(raw) != 32 {
|
||||
return nil, fmt.Errorf("agewrap: X25519 identity is %d bytes, want 32: %w", len(raw), datekeys.ErrIntegrity)
|
||||
}
|
||||
s, err := bech32.Encode("AGE-SECRET-KEY-", raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: encode identity: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
id, err := age.ParseX25519Identity(strings.ToUpper(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agewrap: parse identity: %v: %w", err, datekeys.ErrIntegrity)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RawX25519Identity returns the 32 raw bytes of an age X25519 identity.
|
||||
func RawX25519Identity(id *age.X25519Identity) ([]byte, error) {
|
||||
hrp, raw, err := bech32.Decode(id.String())
|
||||
if err != nil || hrp != "AGE-SECRET-KEY-" || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("agewrap: unexpected age identity encoding")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// RawX25519Recipient returns the 32 raw bytes of an age X25519 recipient.
|
||||
func RawX25519Recipient(r *age.X25519Recipient) ([]byte, error) {
|
||||
hrp, raw, err := bech32.Decode(r.String())
|
||||
if err != nil || hrp != "age" || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("agewrap: unexpected age recipient encoding")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@ -0,0 +1,379 @@
|
||||
package agewrap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/drand/drand/v2/crypto"
|
||||
"github.com/drand/kyber"
|
||||
"github.com/drand/tlock"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// tlockNetwork adapts the pinned profile to tlock.Network, to run the
|
||||
// official tlock code path against our stanzas.
|
||||
type tlockNetwork struct {
|
||||
p *profile.Profile
|
||||
release provider.Release
|
||||
}
|
||||
|
||||
func (n tlockNetwork) ChainHash() string { return n.p.ChainHashHex() }
|
||||
func (n tlockNetwork) Current(time.Time) uint64 { return 1 << 40 }
|
||||
func (n tlockNetwork) SwitchChainHash(string) error { return errors.New("forbidden") }
|
||||
func (n tlockNetwork) Scheme() crypto.Scheme {
|
||||
s, _ := n.p.DrandScheme()
|
||||
return *s
|
||||
}
|
||||
func (n tlockNetwork) PublicKey() kyber.Point {
|
||||
s, _ := n.p.DrandScheme()
|
||||
k := s.KeyGroup.Point()
|
||||
_ = k.UnmarshalBinary(n.p.PublicKey)
|
||||
return k
|
||||
}
|
||||
func (n tlockNetwork) Signature(round uint64) ([]byte, error) {
|
||||
if round != n.release.Round {
|
||||
return nil, errors.New("unknown round")
|
||||
}
|
||||
return n.release.Signature, nil
|
||||
}
|
||||
|
||||
func encrypt(t *testing.T, plaintext []byte, r ...age.Recipient) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
w, err := age.Encrypt(&b, r...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write(plaintext)
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func decrypt(file []byte, id age.Identity) ([]byte, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(file), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
func TestTimeRecipientStanzaAndRoundTrip(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
rec, err := agewrap.NewTimeRecipient(p, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := encrypt(t, []byte("control"), rec)
|
||||
st, err := agewrap.Stanzas(bytes.NewReader(file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st) != 1 || st[0].Type != "tlock" || len(st[0].Args) != 2 || st[0].Args[0] != "1000" || st[0].Args[1] != p.ChainHashHex() || len(st[0].Body) != 128 {
|
||||
t.Fatalf("stanza %+v", st)
|
||||
}
|
||||
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
|
||||
if got, err := decrypt(file, id); err != nil || string(got) != "control" {
|
||||
t.Fatalf("round trip: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The stanza is the one of the tlock library and the tle CLI, in both
|
||||
// directions (spec §32, plan §3.3).
|
||||
func TestInteroperabilityWithTlockLibrary(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
net := tlockNetwork{p: p, release: testkit.Release(1000)}
|
||||
|
||||
rec, _ := agewrap.NewTimeRecipient(p, 1000)
|
||||
ours := encrypt(t, []byte("from datekeys"), rec)
|
||||
var out bytes.Buffer
|
||||
if err := tlock.New(net).Strict().Decrypt(&out, bytes.NewReader(ours)); err != nil || out.String() != "from datekeys" {
|
||||
t.Fatalf("tlock cannot open our file: %v", err)
|
||||
}
|
||||
|
||||
var theirs bytes.Buffer
|
||||
if err := tlock.New(net).Strict().Encrypt(&theirs, strings.NewReader("from tlock"), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
|
||||
if got, err := decrypt(theirs.Bytes(), id); err != nil || string(got) != "from tlock" {
|
||||
t.Fatalf("we cannot open a tlock file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeRecipientCannotBeMixed(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
a, _ := agewrap.NewTimeRecipient(p, 1000)
|
||||
b, _ := agewrap.NewTimeRecipient(p, 1000)
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
for _, rs := range [][]age.Recipient{{a, x.Recipient()}, {a, b}} {
|
||||
if _, err := age.Encrypt(io.Discard, rs...); err == nil {
|
||||
t.Fatal("tlock recipient mixed with another recipient")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule is enforced in Unwrap even when the header MAC is valid, which
|
||||
// is what a malicious creator produces (spec §27, §63).
|
||||
func TestTimeIdentityStrictness(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
rec, _ := agewrap.NewTimeRecipient(p, 1000)
|
||||
file, fk, err := testkit.Encrypt([]byte("control"), rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewrite := func(edit func([]*age.Stanza) []*age.Stanza) []byte {
|
||||
out, err := testkit.RewriteAge(file, fk, edit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The rewritten header is authentic for age: the injected file key opens it.
|
||||
if _, err := decrypt(out, age.NewInjectedFileKeyIdentity(fk)); err != nil {
|
||||
t.Fatalf("rewritten file is not a valid age file: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
backdoor, backdoorID, _ := testkit.X25519Stanza(fk)
|
||||
cases := []struct {
|
||||
name string
|
||||
file []byte
|
||||
want error
|
||||
}{
|
||||
{"extra X25519 stanza", rewrite(func(s []*age.Stanza) []*age.Stanza { return append(s, backdoor) }), datekeys.ErrPolicyStructureMismatch},
|
||||
{"stanza type changed", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Type = "tlock2"; return s }), datekeys.ErrPolicyStructureMismatch},
|
||||
{"third argument", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args = append(s[0].Args, "x"); return s }), datekeys.ErrPolicyStructureMismatch},
|
||||
{"other round", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }), datekeys.ErrRoundMismatch},
|
||||
{"round with leading zero", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "01000"; return s }), datekeys.ErrRoundMismatch},
|
||||
{"other chain hash", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[1] = strings.Repeat("0", 64); return s }), datekeys.ErrProfileMismatch},
|
||||
{"uppercase chain hash", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Args[1] = strings.ToUpper(s[0].Args[1]); return s }), datekeys.ErrProfileMismatch},
|
||||
{"corrupted tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body[100] ^= 1; return s }), datekeys.ErrIntegrity},
|
||||
{"truncated tlock body", rewrite(func(s []*age.Stanza) []*age.Stanza { s[0].Body = s[0].Body[:127]; return s }), datekeys.ErrIntegrity},
|
||||
}
|
||||
id, _ := agewrap.NewTimeIdentity(p, 1000, testkit.Release(1000))
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := decrypt(tc.file, id); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Without the DateKeys rule, the backdoor stanza would open the file.
|
||||
if got, err := decrypt(cases[0].file, backdoorID); err != nil || string(got) != "control" {
|
||||
t.Fatalf("backdoor model broken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeIdentityRelease(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
rec, _ := agewrap.NewTimeRecipient(p, 1000)
|
||||
file := encrypt(t, []byte("control"), rec)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rel provider.Release
|
||||
want error
|
||||
}{
|
||||
{"release of another round", testkit.Release(1001), datekeys.ErrRoundMismatch},
|
||||
{"relabelled signature", provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}, datekeys.ErrReleaseInvalid},
|
||||
{"empty signature", provider.Release{Round: 1000}, datekeys.ErrReleaseInvalid},
|
||||
} {
|
||||
id, _ := agewrap.NewTimeIdentity(p, 1000, tc.rel)
|
||||
if _, err := decrypt(file, id); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
// An identity for another round refuses the stanza before using the release.
|
||||
id, _ := agewrap.NewTimeIdentity(p, 1001, testkit.Release(1001))
|
||||
if _, err := decrypt(file, id); !errors.Is(err, datekeys.ErrRoundMismatch) {
|
||||
t.Fatalf("identity for round 1001: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadIdentityStrictness(t *testing.T) {
|
||||
iPayload, _ := age.GenerateX25519Identity()
|
||||
raw, err := agewrap.RawX25519Identity(iPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := agewrap.NewPayloadIdentity(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, fk, _ := testkit.Encrypt([]byte("payload"), iPayload.Recipient())
|
||||
if got, err := decrypt(file, id); err != nil || string(got) != "payload" {
|
||||
t.Fatalf("round trip: %v", err)
|
||||
}
|
||||
backdoor, _, _ := testkit.X25519Stanza(fk)
|
||||
extra, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, backdoor) })
|
||||
// Plain age accepts the file with I_PAYLOAD: the MAC is valid.
|
||||
if _, err := decrypt(extra, iPayload); err != nil {
|
||||
t.Fatalf("model broken: %v", err)
|
||||
}
|
||||
if _, err := decrypt(extra, id); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("extra stanza in PAYLOAD_AGE: %v", err)
|
||||
}
|
||||
other, _ := age.GenerateX25519Identity()
|
||||
if _, err := decrypt(encrypt(t, []byte("x"), other.Recipient()), id); !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("payload of another control: %v", err)
|
||||
}
|
||||
pw, _ := age.NewScryptRecipient("password")
|
||||
pw.SetWorkFactor(10)
|
||||
if _, err := decrypt(encrypt(t, []byte("x"), pw), id); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("scrypt payload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessIdentityStrictness(t *testing.T) {
|
||||
a, _ := age.GenerateX25519Identity()
|
||||
b, _ := age.GenerateX25519Identity()
|
||||
file, fk, _ := testkit.Encrypt([]byte("control"), a.Recipient(), b.Recipient())
|
||||
for _, id := range []*age.X25519Identity{a, b} {
|
||||
acc, _ := agewrap.NewAccessIdentity(id)
|
||||
if got, err := decrypt(file, acc); err != nil || string(got) != "control" {
|
||||
t.Fatalf("recipient cannot open: %v", err)
|
||||
}
|
||||
}
|
||||
// A non-X25519 stanza is rejected although plain age would accept the file.
|
||||
odd := &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)}
|
||||
withOdd, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, odd) })
|
||||
if _, err := decrypt(withOdd, a); err != nil {
|
||||
t.Fatalf("model broken: %v", err)
|
||||
}
|
||||
acc, _ := agewrap.NewAccessIdentity(a)
|
||||
if _, err := decrypt(withOdd, acc); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("non-X25519 stanza: %v", err)
|
||||
}
|
||||
// Two stanzas for the same recipient.
|
||||
dup, _ := a.Recipient().Wrap(fk)
|
||||
withDup, _ := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza { return append(s, dup[0]) })
|
||||
if _, err := decrypt(withDup, acc); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("two stanzas for one recipient: %v", err)
|
||||
}
|
||||
stranger, _ := age.GenerateX25519Identity()
|
||||
accS, _ := agewrap.NewAccessIdentity(stranger)
|
||||
if _, err := decrypt(file, accS); !errors.Is(err, datekeys.ErrAccessInvalid) {
|
||||
t.Fatalf("stranger: %v", err)
|
||||
}
|
||||
if _, err := agewrap.NewAccessIdentity(); !errors.Is(err, datekeys.ErrAccessRequired) {
|
||||
t.Fatalf("no identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStanzasProbe(t *testing.T) {
|
||||
if _, err := agewrap.Stanzas(strings.NewReader("not age")); !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("garbage: %v", err)
|
||||
}
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
file := encrypt(t, []byte("x"), x.Recipient())
|
||||
st, err := agewrap.Stanzas(bytes.NewReader(file))
|
||||
if err != nil || len(st) != 1 || st[0].Type != "X25519" {
|
||||
t.Fatalf("%+v %v", st, err)
|
||||
}
|
||||
// Probing never needs a secret and leaves the stanzas untouched for age.
|
||||
if _, err := decrypt(file, x); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawKeys(t *testing.T) {
|
||||
id, _ := age.GenerateX25519Identity()
|
||||
raw, err := agewrap.RawX25519Identity(id)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := agewrap.X25519IdentityFromRaw(raw)
|
||||
if err != nil || back.String() != id.String() {
|
||||
t.Fatal("identity round trip")
|
||||
}
|
||||
pub, err := agewrap.RawX25519Recipient(id.Recipient())
|
||||
if err != nil || len(pub) != 32 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := agewrap.X25519IdentityFromRaw(raw[:31]); err == nil {
|
||||
t.Fatal("31-byte identity accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorsRejectInvalidInput(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, round := range []uint64{0, p.MaxRound() + 1} {
|
||||
if _, err := agewrap.NewTimeRecipient(p, round); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Errorf("round %d: %v", round, err)
|
||||
}
|
||||
}
|
||||
for name, edit := range map[string]func(p *profile.Profile){
|
||||
"unknown scheme": func(p *profile.Profile) { p.Scheme = "nope" },
|
||||
"public key not a point": func(p *profile.Profile) { p.PublicKey = bytes.Repeat([]byte{0xff}, 96) },
|
||||
"identity element": func(p *profile.Profile) { p.PublicKey = append([]byte{0xc0}, make([]byte, 95)...) },
|
||||
} {
|
||||
bad := profile.Quicknet()
|
||||
edit(bad)
|
||||
if _, err := agewrap.NewTimeRecipient(bad, 1000); !errors.Is(err, datekeys.ErrUnknownProfile) {
|
||||
t.Errorf("recipient, %s: %v", name, err)
|
||||
}
|
||||
if _, err := agewrap.NewTimeIdentity(bad, 1000, testkit.Release(1000)); !errors.Is(err, datekeys.ErrUnknownProfile) {
|
||||
t.Errorf("identity, %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := agewrap.NewPayloadIdentity(make([]byte, 31)); err == nil {
|
||||
t.Fatal("31-byte I_PAYLOAD accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedX25519Stanzas(t *testing.T) {
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
file, fk, _ := testkit.Encrypt([]byte("data"), x.Recipient())
|
||||
malformed, err := testkit.RewriteAge(file, fk, func(s []*age.Stanza) []*age.Stanza {
|
||||
s[0].Args = append(s[0].Args, "extra")
|
||||
return s
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := agewrap.RawX25519Identity(x)
|
||||
pid, _ := agewrap.NewPayloadIdentity(raw)
|
||||
if _, err := decrypt(malformed, pid); !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
acc, _ := agewrap.NewAccessIdentity(x)
|
||||
if _, err := decrypt(malformed, acc); !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("access: %v", err)
|
||||
}
|
||||
st, _ := agewrap.Stanzas(bytes.NewReader(file))
|
||||
if err := agewrap.CheckAccessStanzas(append(st, st[0])); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("repeated stanza: %v", err)
|
||||
}
|
||||
if err := agewrap.CheckAccessStanzas(nil); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("no stanza: %v", err)
|
||||
}
|
||||
if err := agewrap.CheckPayloadStanzas(append(st, st[0])); !errors.Is(err, datekeys.ErrPolicyStructureMismatch) {
|
||||
t.Fatalf("two payload stanzas: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzStanzas(f *testing.F) {
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
var b bytes.Buffer
|
||||
w, _ := age.Encrypt(&b, x.Recipient())
|
||||
w.Close()
|
||||
f.Add(b.Bytes())
|
||||
f.Add([]byte("age-encryption.org/v1\n-> X25519 a\n\n--- AAAA\n"))
|
||||
f.Fuzz(func(t *testing.T, in []byte) {
|
||||
st, err := agewrap.Stanzas(bytes.NewReader(in))
|
||||
if err != nil && !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("unexpected error class: %v", err)
|
||||
}
|
||||
_ = agewrap.CheckPayloadStanzas(st)
|
||||
_ = agewrap.CheckAccessStanzas(st)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
const fixtureDir = "../testdata/fixtures"
|
||||
|
||||
var fixtureNames = []string{"time_only", "time_only_extensions", "time_and_key_portable", "time_and_key_recipients", "empty_payload"}
|
||||
|
||||
type fixture struct {
|
||||
testkit.DKCFixture
|
||||
dkc []byte
|
||||
plaintext []byte
|
||||
release provider.Release
|
||||
dkk *accesskey.AccessKey
|
||||
ids []age.Identity
|
||||
}
|
||||
|
||||
func loadFixture(t testing.TB, name string) *fixture {
|
||||
t.Helper()
|
||||
f := &fixture{}
|
||||
if err := testkit.ReadJSON(filepath.Join(fixtureDir, name+".json"), &f.DKCFixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
if f.dkc, err = os.ReadFile(filepath.Join(fixtureDir, f.File)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.plaintext, err = os.ReadFile(filepath.Join(fixtureDir, f.PlaintextFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sig, err := hex.DecodeString(f.Release.Signature)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.release = provider.Release{Round: f.Release.Round, Signature: sig}
|
||||
if f.AccessKeyFile != "" {
|
||||
b, err := os.ReadFile(filepath.Join(fixtureDir, f.AccessKeyFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.dkk, err = accesskey.Decode(bytes.NewReader(b)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, s := range f.Identities {
|
||||
id, err := age.ParseX25519Identity(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.ids = append(f.ids, id)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) unlock(t testing.TB) time.Time {
|
||||
u, err := time.Parse(time.RFC3339, f.UnlockAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (f *fixture) openOptions(t testing.TB) capsule.OpenOptions {
|
||||
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(f.release), Now: testkit.Fixed(f.unlock(t))}
|
||||
if f.AccessPolicy == "time_and_key" {
|
||||
o.AccessKey = f.dkk
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func decryptAge(t *testing.T, file []byte, id age.Identity) []byte {
|
||||
t.Helper()
|
||||
r, err := age.Decrypt(bytes.NewReader(file), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func stanzaList(in []capsule.StanzaInfo) []testkit.FixtureStanza {
|
||||
out := make([]testkit.FixtureStanza, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Spec §67: conformance is shown by decrypting and verifying each official
|
||||
// fixture and comparing every intermediate value and the plaintext.
|
||||
func TestConformanceFixtures(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, name := range fixtureNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := loadFixture(t, name)
|
||||
if sum := sha256.Sum256(f.dkc); hex.EncodeToString(sum[:]) != f.SHA256 {
|
||||
t.Fatal("fixture bytes changed")
|
||||
}
|
||||
if sum := sha256.Sum256(f.plaintext); hex.EncodeToString(sum[:]) != f.PlaintextSHA256 {
|
||||
t.Fatal("plaintext file changed")
|
||||
}
|
||||
if err := provider.Verify(p, provider.Condition{Round: f.release.Round}, f.release); err != nil {
|
||||
t.Fatalf("embedded release: %v", err)
|
||||
}
|
||||
|
||||
// PRELUDE, PUBLIC_HEADER and the pre-unlock view (steps 1 to 8).
|
||||
parts, err := testkit.Split(f.dkc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(parts.Prelude) != f.Prelude || hex.EncodeToString(parts.Header) != f.PublicHeader {
|
||||
t.Fatal("PRELUDE or PUBLIC_HEADER differ")
|
||||
}
|
||||
in, err := capsule.Inspect(bytes.NewReader(f.dkc), capsule.InspectOptions{Registry: testkit.Registry()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.Header.DateKey.Compact() != f.DateKey || in.Header.CapsuleIDHex() != f.CapsuleID ||
|
||||
in.Header.Policy.String() != f.AccessPolicy || in.UnlockAt.Format(time.RFC3339) != f.UnlockAt {
|
||||
t.Fatalf("inspection differs: %+v", in.Header)
|
||||
}
|
||||
if !reflect.DeepEqual(stanzaList(in.OuterStanzas), f.OuterStanzas) || !reflect.DeepEqual(stanzaList(in.PayloadStanzas), f.PayloadStanzas) {
|
||||
t.Fatal("stanzas differ")
|
||||
}
|
||||
for _, c := range in.Checks {
|
||||
if !c.OK || c.Step > 8 {
|
||||
t.Fatalf("unexpected inspection check %+v", c)
|
||||
}
|
||||
}
|
||||
var pre [capsule.PreludeSize]byte
|
||||
copy(pre[:], parts.Prelude)
|
||||
if b := capsule.HeaderBinding(pre, parts.Header); hex.EncodeToString(b[:]) != f.HeaderBinding {
|
||||
t.Fatal("header_binding differs")
|
||||
}
|
||||
h, err := capsule.DecodeHeader(parts.Header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if re, _ := capsule.EncodeHeader(h); !bytes.Equal(re, parts.Header) {
|
||||
t.Fatal("EncodeHeader(DecodeHeader(x)) != x")
|
||||
}
|
||||
if len(h.Critical)+len(h.Noncritical) != len(f.HeaderExtensions) {
|
||||
t.Fatal("header extensions differ")
|
||||
}
|
||||
|
||||
// Opening layer by layer: OUTER_TIME_AGE, INNER_ACCESS_AGE, CONTROL_CBOR.
|
||||
timeID, _ := agewrap.NewTimeIdentity(p, f.release.Round, f.release)
|
||||
inner := decryptAge(t, parts.Sealed, timeID)
|
||||
control := inner
|
||||
if f.Structure == "time_and_key" {
|
||||
st, err := agewrap.Stanzas(bytes.NewReader(inner))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]testkit.FixtureStanza, len(st))
|
||||
for i, s := range st {
|
||||
got[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
|
||||
}
|
||||
if !reflect.DeepEqual(got, f.InnerStanzas) {
|
||||
t.Fatal("INNER_ACCESS_AGE stanzas differ")
|
||||
}
|
||||
kid, err := f.dkk.Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acc, _ := agewrap.NewAccessIdentity(kid)
|
||||
control = decryptAge(t, inner, acc)
|
||||
}
|
||||
if hex.EncodeToString(control) != f.ControlCBOR {
|
||||
t.Fatal("CONTROL_CBOR differs")
|
||||
}
|
||||
ctrl, err := capsule.DecodeControl(control)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(ctrl.PayloadIdentity[:]) != f.PayloadIdentity || hex.EncodeToString(ctrl.HeaderBinding[:]) != f.HeaderBinding {
|
||||
t.Fatal("I_PAYLOAD or header_binding in CONTROL_CBOR differ")
|
||||
}
|
||||
if re, _ := capsule.EncodeControl(ctrl); !bytes.Equal(re, control) {
|
||||
t.Fatal("EncodeControl(DecodeControl(x)) != x")
|
||||
}
|
||||
if len(ctrl.Critical)+len(ctrl.Noncritical) != len(f.ControlExt) {
|
||||
t.Fatal("control extensions differ")
|
||||
}
|
||||
payloadID, _ := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:])
|
||||
if got := decryptAge(t, parts.Payload, payloadID); !bytes.Equal(got, f.plaintext) {
|
||||
t.Fatal("PAYLOAD_AGE plaintext differs")
|
||||
}
|
||||
|
||||
// The complete flow through the public API, stage by stage.
|
||||
var out bytes.Buffer
|
||||
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(out.Bytes(), f.plaintext) {
|
||||
t.Fatal("plaintext differs")
|
||||
}
|
||||
var stages []testkit.FixtureStage
|
||||
for _, c := range opened.Inspection.Checks {
|
||||
stages = append(stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
|
||||
}
|
||||
if !reflect.DeepEqual(stages, f.Stages) {
|
||||
t.Fatalf("stages differ:\n got %+v\nwant %+v", stages, f.Stages)
|
||||
}
|
||||
if len(opened.ControlNoncritical) != len(ctrl.Noncritical) {
|
||||
t.Fatal("Open does not report the control extensions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every known recipient of a multi-recipient fixture opens it on its own.
|
||||
func TestFixtureRecipients(t *testing.T) {
|
||||
f := loadFixture(t, "time_and_key_recipients")
|
||||
if len(f.ids) != 2 {
|
||||
t.Fatal("fixture should have two known recipients")
|
||||
}
|
||||
for i, id := range f.ids {
|
||||
o := f.openOptions(t)
|
||||
o.AccessKey = nil
|
||||
o.Identities = []age.Identity{id}
|
||||
var out bytes.Buffer
|
||||
if _, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), o); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
|
||||
t.Fatalf("recipient %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-seekable reader works too: only the capsule_digest shortcut is skipped.
|
||||
func TestOpenFromPlainReader(t *testing.T) {
|
||||
for _, name := range []string{"time_only", "time_and_key_portable"} {
|
||||
f := loadFixture(t, name)
|
||||
var out bytes.Buffer
|
||||
r := struct{ io.Reader }{bytes.NewReader(f.dkc)}
|
||||
if _, err := capsule.Open(context.Background(), &out, r, f.openOptions(t)); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,284 @@
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// EncryptOptions configures Encrypt.
|
||||
type EncryptOptions struct {
|
||||
// Profile is the pinned Provider Profile. Required.
|
||||
Profile *profile.Profile
|
||||
// UnlockAt is the requested instant. It resolves locally to the first
|
||||
// round at or after it (spec §15) and must be after Now.
|
||||
UnlockAt time.Time
|
||||
// Policy is time_only or time_and_key (spec §25).
|
||||
Policy Policy
|
||||
// Recipients are the X25519 recipients of known holders, for
|
||||
// time_and_key (spec §33, §37, §39). Only *age.X25519Recipient is
|
||||
// accepted: INNER_ACCESS_AGE must hold X25519 stanzas only.
|
||||
Recipients []age.Recipient
|
||||
// NewPortableKey generates a fresh I_ACCESS for this capsule only and
|
||||
// returns it as a .dkk (spec §38). An I_ACCESS is never reused: Encrypt
|
||||
// accepts no existing one.
|
||||
NewPortableKey bool
|
||||
// Critical and Noncritical are the PUBLIC_HEADER extensions (visible to
|
||||
// anyone holding the .dkc).
|
||||
Critical, Noncritical []extension.Extension
|
||||
// ControlCritical and ControlNoncritical are the CONTROL_CBOR extensions,
|
||||
// sealed with the control.
|
||||
ControlCritical, ControlNoncritical []extension.Extension
|
||||
// Now is the clock. Required: no package of this module reads the wall
|
||||
// clock on its own.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Result describes a capsule written by Encrypt.
|
||||
type Result struct {
|
||||
DateKey datekey.DateKey
|
||||
UnlockAt time.Time // effective round time, never before the requested instant
|
||||
CapsuleID [CapsuleIDSize]byte
|
||||
// PortableKey is the .dkk generated when NewPortableKey is set. Encode it
|
||||
// with accesskey.Encode and treat it as a sensitive capability.
|
||||
PortableKey *accesskey.AccessKey
|
||||
}
|
||||
|
||||
// Encrypt writes a .dkc for the payload read from src (spec §61 for
|
||||
// time_only, §62 for time_and_key). It needs no network: the round is
|
||||
// resolved locally and tlock uses only the pinned public key.
|
||||
//
|
||||
// PAYLOAD_AGE is streamed after the small, in-memory SEALED_CONTROL, so the
|
||||
// payload is never held in memory. On error dst may hold a partial capsule
|
||||
// that must be discarded.
|
||||
func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error) {
|
||||
p := opts.Profile
|
||||
if p == nil {
|
||||
return nil, errors.New("capsule: EncryptOptions.Profile is required")
|
||||
}
|
||||
if opts.Now == nil {
|
||||
return nil, errors.New("capsule: EncryptOptions.Now is required")
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !opts.UnlockAt.After(opts.Now()) {
|
||||
return nil, fmt.Errorf("capsule: unlock time %s is not in the future", opts.UnlockAt.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
// Step 1: resolve the DateKey locally.
|
||||
dk, err := datekey.Resolve(p, opts.UnlockAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unlock := dk.UnlockAt(p)
|
||||
// Spec §17: round_time(round) >= requested_unlock_at, never earlier.
|
||||
if unlock.Before(opts.UnlockAt) {
|
||||
return nil, fmt.Errorf("capsule: resolved round %d opens before the requested time: %w", dk.Round, datekeys.ErrRoundMismatch)
|
||||
}
|
||||
|
||||
access, portable, err := accessRecipients(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var portableRaw []byte
|
||||
if portable != nil {
|
||||
if portableRaw, err = agewrap.RawX25519Identity(portable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer clear(portableRaw)
|
||||
}
|
||||
|
||||
// Step 2: capsule_id, 16 random bytes (spec §21).
|
||||
var capsuleID [CapsuleIDSize]byte
|
||||
_, _ = rand.Read(capsuleID[:]) // never fails since Go 1.24
|
||||
|
||||
// Step 3: I_PAYLOAD, a fresh X25519 identity (spec §29).
|
||||
payloadID, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadRaw, err := agewrap.RawX25519Identity(payloadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer clear(payloadRaw)
|
||||
|
||||
// Step 5: PUBLIC_HEADER.
|
||||
header := &Header{CapsuleID: capsuleID, DateKey: dk, Policy: opts.Policy, Critical: opts.Critical, Noncritical: opts.Noncritical}
|
||||
headerBytes, err := EncodeHeader(header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(headerBytes) > MaxPublicHeaderLen {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER of %d bytes exceeds %d", len(headerBytes), MaxPublicHeaderLen)
|
||||
}
|
||||
|
||||
timeRecipient, err := agewrap.NewTimeRecipient(p, dk.Round)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seal := func(control []byte) ([]byte, error) {
|
||||
plaintext := control
|
||||
if opts.Policy == TimeAndKey {
|
||||
// INNER_ACCESS_AGE: FK_ACCESS wrapped for every access recipient.
|
||||
innerAge, err := encryptAll(control, access...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stanzas, err := agewrap.Stanzas(bytes.NewReader(innerAge))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := agewrap.CheckAccessStanzas(stanzas); err != nil || len(stanzas) != len(access) {
|
||||
return nil, fmt.Errorf("capsule: INNER_ACCESS_AGE self-check failed: %w", datekeys.ErrPolicyStructureMismatch)
|
||||
}
|
||||
plaintext = innerAge
|
||||
}
|
||||
// OUTER_TIME_AGE: FK_TIME wrapped with tlock for the DateKey round.
|
||||
return encryptAll(plaintext, timeRecipient)
|
||||
}
|
||||
|
||||
// Steps 6 to 10. PRELUDE carries SEALED_CONTROL_LEN and header_binding
|
||||
// covers PRELUDE, so the length is measured first by sealing a control of
|
||||
// identical size with a zero binding and a zero identity. age output
|
||||
// lengths depend only on plaintext length and stanza shapes; the real
|
||||
// seal is checked to have the same length.
|
||||
ctrl := &Control{Critical: opts.ControlCritical, Noncritical: opts.ControlNoncritical}
|
||||
draft, err := EncodeControl(ctrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
draftSealed, err := seal(draft)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(draftSealed) > MaxSealedControlLen {
|
||||
return nil, fmt.Errorf("capsule: SEALED_CONTROL of %d bytes exceeds %d", len(draftSealed), MaxSealedControlLen)
|
||||
}
|
||||
prelude := Prelude{PublicHeaderLen: uint32(len(headerBytes)), SealedControlLen: uint32(len(draftSealed))}
|
||||
preludeBytes := prelude.Bytes()
|
||||
|
||||
// Step 7: header_binding = SHA-256(PRELUDE || PUBLIC_HEADER_BYTES).
|
||||
ctrl.HeaderBinding = HeaderBinding(preludeBytes, headerBytes)
|
||||
copy(ctrl.PayloadIdentity[:], payloadRaw)
|
||||
defer clear(ctrl.PayloadIdentity[:])
|
||||
|
||||
// Step 8: CONTROL_CBOR.
|
||||
controlBytes, err := EncodeControl(ctrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer clear(controlBytes)
|
||||
|
||||
// Steps 9 and 10: SEALED_CONTROL = OUTER_TIME_AGE.
|
||||
sealed, err := seal(controlBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sealed) != len(draftSealed) {
|
||||
return nil, fmt.Errorf("capsule: internal error: SEALED_CONTROL is %d bytes, measured %d", len(sealed), len(draftSealed))
|
||||
}
|
||||
|
||||
// Step 11: PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE.
|
||||
digest := sha256.New()
|
||||
w := io.MultiWriter(dst, digest)
|
||||
for _, b := range [][]byte{preludeBytes[:], headerBytes, sealed} {
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Step 4: PAYLOAD_AGE, a standard age file for R_PAYLOAD (FK_PAYLOAD is
|
||||
// generated by age). It is written last and streamed.
|
||||
aw, err := age.Encrypt(w, payloadID.Recipient())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(aw, src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := aw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &Result{DateKey: dk, UnlockAt: unlock, CapsuleID: capsuleID}
|
||||
if portable != nil {
|
||||
// Step 13: the portable identity as 32 raw bytes in a .dkk.
|
||||
k := &accesskey.AccessKey{
|
||||
CapsuleID: capsuleID,
|
||||
Type: accesskey.TypeX25519,
|
||||
Material: bytes.Clone(portableRaw),
|
||||
Verification: &accesskey.Verification{CapsuleDigest: digest.Sum(nil)},
|
||||
}
|
||||
_, _ = rand.Read(k.CredentialID[:]) // spec §42; never fails since Go 1.24
|
||||
res.PortableKey = k
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// accessRecipients validates the policy options and returns the recipients of
|
||||
// INNER_ACCESS_AGE, including R_ACCESS when a portable key is requested.
|
||||
func accessRecipients(opts EncryptOptions) ([]age.Recipient, *age.X25519Identity, error) {
|
||||
switch opts.Policy {
|
||||
case TimeOnly:
|
||||
if len(opts.Recipients) != 0 || opts.NewPortableKey {
|
||||
return nil, nil, errors.New("capsule: time_only takes no recipients and no portable key")
|
||||
}
|
||||
return nil, nil, nil
|
||||
case TimeAndKey:
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("capsule: unknown access policy %d", opts.Policy)
|
||||
}
|
||||
var out []age.Recipient
|
||||
seen := make(map[string]bool)
|
||||
for i, r := range opts.Recipients {
|
||||
x, ok := r.(*age.X25519Recipient)
|
||||
if !ok || x == nil {
|
||||
return nil, nil, fmt.Errorf("capsule: recipient %d is %T; time_and_key accepts X25519 recipients only", i, r)
|
||||
}
|
||||
if seen[x.String()] {
|
||||
return nil, nil, fmt.Errorf("capsule: recipient %s listed twice; INNER_ACCESS_AGE holds one stanza per recipient", x)
|
||||
}
|
||||
seen[x.String()] = true
|
||||
out = append(out, x)
|
||||
}
|
||||
var portable *age.X25519Identity
|
||||
if opts.NewPortableKey {
|
||||
var err error
|
||||
if portable, err = age.GenerateX25519Identity(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out = append(out, portable.Recipient())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil, errors.New("capsule: time_and_key needs at least one recipient or a portable key")
|
||||
}
|
||||
return out, portable, nil
|
||||
}
|
||||
|
||||
// encryptAll produces a complete in-memory age file.
|
||||
func encryptAll(plaintext []byte, recipients ...age.Recipient) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := age.Encrypt(&buf, recipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, writeErr := w.Write(plaintext)
|
||||
if err := errors.Join(writeErr, w.Close()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
func past(t *testing.T, round uint64) capsule.EncryptOptions {
|
||||
t.Helper()
|
||||
p := profile.Quicknet()
|
||||
unlock, err := datekey.RoundTime(p, round)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}
|
||||
}
|
||||
|
||||
func open(t *testing.T, dkc []byte, o capsule.OpenOptions) ([]byte, error) {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
_, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o)
|
||||
return out.Bytes(), err
|
||||
}
|
||||
|
||||
func defaultOpen(round uint64) capsule.OpenOptions {
|
||||
return capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(round)), Now: time.Now}
|
||||
}
|
||||
|
||||
func TestEncryptRoundTripBothPolicies(t *testing.T) {
|
||||
msg := strings.Repeat("0123456789abcdef", 20000) // several STREAM chunks
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(o *capsule.EncryptOptions) []age.Identity
|
||||
}{
|
||||
{"time_only", func(o *capsule.EncryptOptions) []age.Identity { return nil }},
|
||||
{"time_and_key, portable", func(o *capsule.EncryptOptions) []age.Identity {
|
||||
o.Policy, o.NewPortableKey = capsule.TimeAndKey, true
|
||||
return nil
|
||||
}},
|
||||
{"time_and_key, three recipients", func(o *capsule.EncryptOptions) []age.Identity {
|
||||
o.Policy = capsule.TimeAndKey
|
||||
var ids []age.Identity
|
||||
for range 3 {
|
||||
id, _ := age.GenerateX25519Identity()
|
||||
o.Recipients = append(o.Recipients, id.Recipient())
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := past(t, 1000)
|
||||
ids := tc.setup(&opts)
|
||||
var dkc bytes.Buffer
|
||||
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.DateKey.Round != 1000 || !res.UnlockAt.Equal(opts.UnlockAt) {
|
||||
t.Fatalf("result %+v", res)
|
||||
}
|
||||
o := defaultOpen(1000)
|
||||
o.Identities = ids
|
||||
if res.PortableKey != nil {
|
||||
o.AccessKey = res.PortableKey
|
||||
}
|
||||
got, err := open(t, dkc.Bytes(), o)
|
||||
if err != nil || string(got) != msg {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
o := defaultOpen(1000)
|
||||
o.Identities = []age.Identity{id}
|
||||
if got, err := open(t, dkc.Bytes(), o); err != nil || string(got) != msg {
|
||||
t.Fatalf("recipient %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptRejectsInvalidOptions(t *testing.T) {
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
scrypt, _ := age.NewScryptRecipient("pw")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(o *capsule.EncryptOptions)
|
||||
}{
|
||||
{"no profile", func(o *capsule.EncryptOptions) { o.Profile = nil }},
|
||||
{"no clock", func(o *capsule.EncryptOptions) { o.Now = nil }},
|
||||
{"unlock time in the past", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt.Add(time.Second)) }},
|
||||
{"unlock time equal to now", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt) }},
|
||||
{"time_only with recipients", func(o *capsule.EncryptOptions) { o.Recipients = []age.Recipient{x.Recipient()} }},
|
||||
{"time_only with a portable key", func(o *capsule.EncryptOptions) { o.NewPortableKey = true }},
|
||||
{"time_and_key without recipients", func(o *capsule.EncryptOptions) { o.Policy = capsule.TimeAndKey }},
|
||||
{"non-X25519 recipient", func(o *capsule.EncryptOptions) {
|
||||
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{scrypt}
|
||||
}},
|
||||
{"recipient listed twice", func(o *capsule.EncryptOptions) {
|
||||
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{x.Recipient(), x.Recipient()}
|
||||
}},
|
||||
{"unknown policy", func(o *capsule.EncryptOptions) { o.Policy = 7 }},
|
||||
{"invalid profile", func(o *capsule.EncryptOptions) { o.Profile.ChainHash[0] ^= 1 }},
|
||||
{"duplicate header extension", func(o *capsule.EncryptOptions) {
|
||||
o.Noncritical = []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
|
||||
}},
|
||||
{"extension both critical and noncritical", func(o *capsule.EncryptOptions) {
|
||||
o.ControlCritical = []extension.Extension{{ID: "a", Version: 1}}
|
||||
o.ControlNoncritical = []extension.Extension{{ID: "a", Version: 1}}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := past(t, 1000)
|
||||
tc.edit(&opts)
|
||||
var dkc bytes.Buffer
|
||||
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil {
|
||||
t.Fatal("accepted")
|
||||
}
|
||||
if dkc.Len() != 0 {
|
||||
t.Fatal("wrote output before validating the options")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Spec §38: an I_ACCESS is generated for one capsule only and never reused.
|
||||
func TestPortableKeysAreNeverReused(t *testing.T) {
|
||||
var dkcs [2][]byte
|
||||
var keys [2]*accesskey.AccessKey
|
||||
for i := range 2 {
|
||||
opts := past(t, 1000)
|
||||
opts.Policy, opts.NewPortableKey = capsule.TimeAndKey, true
|
||||
var b bytes.Buffer
|
||||
res, err := capsule.Encrypt(&b, strings.NewReader("x"), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dkcs[i], keys[i] = b.Bytes(), res.PortableKey
|
||||
}
|
||||
if bytes.Equal(keys[0].Material, keys[1].Material) || keys[0].CredentialID == keys[1].CredentialID || keys[0].CapsuleID == keys[1].CapsuleID {
|
||||
t.Fatal("two capsules share an I_ACCESS, credential_id or capsule_id")
|
||||
}
|
||||
// The .dkk of capsule A is refused for capsule B before any request, and
|
||||
// its identity cannot open B's access layer either.
|
||||
o := defaultOpen(1000)
|
||||
o.AccessKey = keys[0]
|
||||
src := testkit.NewSource(testkit.Release(1000))
|
||||
o.Source = src
|
||||
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) || src.Calls != 0 {
|
||||
t.Fatalf("foreign .dkk: %v (requests: %d)", err, src.Calls)
|
||||
}
|
||||
id, _ := keys[0].Identity()
|
||||
o = defaultOpen(1000)
|
||||
o.Identities = []age.Identity{id}
|
||||
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) {
|
||||
t.Fatalf("foreign identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFutureCapsuleStaysLockedWithoutRequests(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
opts := capsule.EncryptOptions{Profile: p, UnlockAt: now.Add(time.Hour), Now: testkit.Fixed(now)}
|
||||
var dkc bytes.Buffer
|
||||
res, err := capsule.Encrypt(&dkc, strings.NewReader("secret"), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.UnlockAt.Before(opts.UnlockAt) || res.UnlockAt.Sub(opts.UnlockAt) >= p.Period {
|
||||
t.Fatalf("unsafe rounding: %s for %s", res.UnlockAt, opts.UnlockAt)
|
||||
}
|
||||
src := testkit.NewSource()
|
||||
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: src, Now: testkit.Fixed(now)}
|
||||
if _, err := open(t, dkc.Bytes(), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) || src.Calls != 0 {
|
||||
t.Fatalf("locked capsule: %v (requests: %d)", err, src.Calls)
|
||||
}
|
||||
// Inspection works on a locked capsule and reports its condition.
|
||||
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
|
||||
if err != nil || in.Header.DateKey != res.DateKey || !in.UnlockAt.Equal(res.UnlockAt) {
|
||||
t.Fatalf("inspect: %+v %v", in, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionsRoundTrip(t *testing.T) {
|
||||
hExt, _ := extension.New("org.example.public", 1, []any{"a", uint64(1)})
|
||||
cExt, _ := extension.New("org.example.sealed", 3, map[string]any{"k": []byte{1, 2}})
|
||||
opts := past(t, 1000)
|
||||
opts.Noncritical = []extension.Extension{hExt}
|
||||
opts.ControlNoncritical = []extension.Extension{cExt}
|
||||
var dkc bytes.Buffer
|
||||
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, _ := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
|
||||
if len(in.Header.Noncritical) != 1 || !bytes.Equal(in.Header.Noncritical[0].Data, hExt.Data) {
|
||||
t.Fatal("header extension lost")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), defaultOpen(1000))
|
||||
if err != nil || len(opened.ControlNoncritical) != 1 || !bytes.Equal(opened.ControlNoncritical[0].Data, cExt.Data) {
|
||||
t.Fatalf("control extension lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRequiresOptions(t *testing.T) {
|
||||
f := loadFixture(t, "time_only")
|
||||
for name, o := range map[string]capsule.OpenOptions{
|
||||
"no source": {Registry: testkit.Registry(), Now: time.Now},
|
||||
"no clock": {Registry: testkit.Registry(), Source: testkit.NewSource()},
|
||||
"no registry": {Source: testkit.NewSource(), Now: time.Now},
|
||||
} {
|
||||
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(f.dkc), o); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptWriteError(t *testing.T) {
|
||||
opts := past(t, 1000)
|
||||
if _, err := capsule.Encrypt(failingWriter{}, strings.NewReader("x"), opts); err == nil {
|
||||
t.Fatal("write error ignored")
|
||||
}
|
||||
if _, err := capsule.Encrypt(io.Discard, failingReader{}, opts); err == nil {
|
||||
t.Fatal("read error ignored")
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
|
||||
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
|
||||
@ -0,0 +1,87 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// The published Quicknet signature of round 1000. In real use the release
|
||||
// comes from drand.New(), which verifies it the same way.
|
||||
var round1000, _ = hex.DecodeString("b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39")
|
||||
|
||||
func Example() {
|
||||
reg, err := profile.Default()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p := profile.Quicknet()
|
||||
|
||||
// A clock stopped at the Quicknet genesis makes round 1000
|
||||
// (2023-08-23T15:59:24Z) "the future", so the example runs offline.
|
||||
genesis := func() time.Time { return time.Unix(p.GenesisTime, 0) }
|
||||
var dkc bytes.Buffer
|
||||
res, err := capsule.Encrypt(&dkc, strings.NewReader("hello from the past"), capsule.EncryptOptions{
|
||||
Profile: p,
|
||||
UnlockAt: time.Date(2023, 8, 23, 15, 59, 24, 0, time.UTC),
|
||||
Policy: capsule.TimeAndKey,
|
||||
NewPortableKey: true,
|
||||
Now: genesis,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var dkk bytes.Buffer
|
||||
if err := accesskey.Encode(&dkk, res.PortableKey); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("round", res.DateKey.Round, "unlocks at", res.UnlockAt.Format(time.RFC3339))
|
||||
|
||||
// Before the round: no request is made.
|
||||
key, _ := accesskey.Decode(&dkk)
|
||||
src := provider.ReleaseSourceFunc(func(_ context.Context, _ *profile.Profile, c provider.Condition) (provider.Release, error) {
|
||||
return provider.Release{Round: c.Round, Signature: round1000}, nil
|
||||
})
|
||||
opts := capsule.OpenOptions{Registry: reg, Source: src, AccessKey: key, Now: genesis}
|
||||
_, err = capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), opts)
|
||||
fmt.Println("too early:", errors.Is(err, datekeys.ErrReleaseUnavailable))
|
||||
|
||||
// After the round: the release is verified locally and the capsule opens.
|
||||
opts.Now = time.Now
|
||||
var plain bytes.Buffer
|
||||
if _, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), opts); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(plain.String())
|
||||
// Output:
|
||||
// round 1000 unlocks at 2023-08-23T15:59:24Z
|
||||
// too early: true
|
||||
// hello from the past
|
||||
}
|
||||
|
||||
func ExampleInspect() {
|
||||
reg, _ := profile.Default()
|
||||
p := profile.Quicknet()
|
||||
var dkc bytes.Buffer
|
||||
_, _ = capsule.Encrypt(&dkc, strings.NewReader("x"), capsule.EncryptOptions{
|
||||
Profile: p,
|
||||
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
Now: func() time.Time { return time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: reg})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(in.Header.Policy, in.Header.DateKey.Round, in.UnlockAt.Format(time.RFC3339), len(in.Checks), "checks passed")
|
||||
// Output: time_only 66884212 2030-01-01T00:00:00Z 8 checks passed
|
||||
}
|
||||
@ -0,0 +1,314 @@
|
||||
// Package capsule implements the DateKeyCap .dkc container (spec §20-§39):
|
||||
// framing, PUBLIC_HEADER, CONTROL_CBOR, header_binding, the time_only and
|
||||
// time_and_key constructions, and the encryption (spec §61, §62) and
|
||||
// decryption (spec §63) flows.
|
||||
//
|
||||
// A .dkc is PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE, where
|
||||
// SEALED_CONTROL and PAYLOAD_AGE are complete standard age files and the
|
||||
// payload runs to EOF (spec §22, §28-§34).
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
)
|
||||
|
||||
// Framing constants (spec §22, §23) and parser limits (spec §57).
|
||||
const (
|
||||
Magic = "DKC1"
|
||||
FramingVersion = 1
|
||||
PreludeSize = 16
|
||||
|
||||
MaxPublicHeaderLen = 1 << 20 // 1 MiB
|
||||
MaxSealedControlLen = 64 << 20 // 64 MiB
|
||||
|
||||
HeaderTypeTag = "datekeycap"
|
||||
HeaderVersion = 1
|
||||
ControlTypeTag = "datekeys-control"
|
||||
ControlVersion = 1
|
||||
|
||||
CapsuleIDSize = 16
|
||||
)
|
||||
|
||||
// Policy is the declared access policy of PUBLIC_HEADER (spec §25).
|
||||
type Policy uint8
|
||||
|
||||
// Access policies of V1.
|
||||
const (
|
||||
TimeOnly Policy = 0
|
||||
TimeAndKey Policy = 1
|
||||
)
|
||||
|
||||
func (p Policy) String() string {
|
||||
switch p {
|
||||
case TimeOnly:
|
||||
return "time_only"
|
||||
case TimeAndKey:
|
||||
return "time_and_key"
|
||||
}
|
||||
return fmt.Sprintf("policy(%d)", uint8(p))
|
||||
}
|
||||
|
||||
// ParsePolicy parses "time_only" or "time_and_key".
|
||||
func ParsePolicy(s string) (Policy, error) {
|
||||
switch s {
|
||||
case "time_only":
|
||||
return TimeOnly, nil
|
||||
case "time_and_key":
|
||||
return TimeAndKey, nil
|
||||
}
|
||||
return 0, fmt.Errorf("capsule: unknown access policy %q", s)
|
||||
}
|
||||
|
||||
func (p Policy) valid() bool { return p == TimeOnly || p == TimeAndKey }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRELUDE
|
||||
|
||||
// Prelude is the fixed 16-byte PRELUDE (spec §22, §23).
|
||||
type Prelude struct {
|
||||
PublicHeaderLen uint32
|
||||
SealedControlLen uint32
|
||||
}
|
||||
|
||||
// Bytes returns the exact 16 prelude bytes, the ones covered by
|
||||
// header_binding.
|
||||
func (p Prelude) Bytes() [PreludeSize]byte {
|
||||
var b [PreludeSize]byte
|
||||
copy(b[0:4], Magic)
|
||||
b[4] = FramingVersion
|
||||
// FLAGS (b[5]) and RESERVED (b[6:8]) are zero in V1.
|
||||
binary.BigEndian.PutUint32(b[8:12], p.PublicHeaderLen)
|
||||
binary.BigEndian.PutUint32(b[12:16], p.SealedControlLen)
|
||||
return b
|
||||
}
|
||||
|
||||
// PayloadOffset is where PAYLOAD_AGE starts:
|
||||
// 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN (spec §63).
|
||||
func (p Prelude) PayloadOffset() int64 {
|
||||
return PreludeSize + int64(p.PublicHeaderLen) + int64(p.SealedControlLen)
|
||||
}
|
||||
|
||||
// ParsePrelude validates the prelude (spec §22, §63 step 2): magic, version,
|
||||
// FLAGS == 0, RESERVED == 0 and the length limits of spec §57.
|
||||
func ParsePrelude(b []byte) (Prelude, error) {
|
||||
if len(b) < 4 || string(b[0:4]) != Magic {
|
||||
return Prelude{}, fmt.Errorf("capsule: %w", datekeys.ErrInvalidMagic)
|
||||
}
|
||||
if len(b) < PreludeSize {
|
||||
return Prelude{}, fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity)
|
||||
}
|
||||
if b[4] != FramingVersion {
|
||||
return Prelude{}, fmt.Errorf("capsule: framing version %d: %w", b[4], datekeys.ErrUnsupportedVersion)
|
||||
}
|
||||
if b[5] != 0 || b[6] != 0 || b[7] != 0 {
|
||||
return Prelude{}, fmt.Errorf("capsule: flags %#x, reserved %#02x%02x: %w", b[5], b[6], b[7], datekeys.ErrInvalidFlags)
|
||||
}
|
||||
p := Prelude{
|
||||
PublicHeaderLen: binary.BigEndian.Uint32(b[8:12]),
|
||||
SealedControlLen: binary.BigEndian.Uint32(b[12:16]),
|
||||
}
|
||||
if p.PublicHeaderLen == 0 || p.PublicHeaderLen > MaxPublicHeaderLen {
|
||||
return Prelude{}, fmt.Errorf("capsule: PUBLIC_HEADER_LEN %d outside 1..%d: %w", p.PublicHeaderLen, MaxPublicHeaderLen, datekeys.ErrIntegrity)
|
||||
}
|
||||
if p.SealedControlLen == 0 || p.SealedControlLen > MaxSealedControlLen {
|
||||
return Prelude{}, fmt.Errorf("capsule: SEALED_CONTROL_LEN %d outside 1..%d: %w", p.SealedControlLen, MaxSealedControlLen, datekeys.ErrIntegrity)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// HeaderBinding returns SHA-256(PRELUDE || PUBLIC_HEADER_BYTES) over the exact
|
||||
// stored bytes; the header is never re-serialized for it (spec §26).
|
||||
func HeaderBinding(prelude [PreludeSize]byte, publicHeader []byte) [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write(prelude[:])
|
||||
h.Write(publicHeader)
|
||||
var out [32]byte
|
||||
h.Sum(out[:0])
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUBLIC_HEADER
|
||||
|
||||
// Header is PUBLIC_HEADER (spec §24). There is no separate profile_id: the
|
||||
// profile comes from the DateKey, the single source of truth.
|
||||
type Header struct {
|
||||
CapsuleID [CapsuleIDSize]byte // key 2
|
||||
DateKey datekey.DateKey // key 3, canonical dk1_
|
||||
Policy Policy // key 4, access_policy
|
||||
Critical []extension.Extension // key 5
|
||||
Noncritical []extension.Extension // key 6
|
||||
}
|
||||
|
||||
type headerWire struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
CapsuleID []byte `cbor:"2,keyasint"`
|
||||
DateKey string `cbor:"3,keyasint"`
|
||||
Policy uint64 `cbor:"4,keyasint"`
|
||||
Critical []extension.Wire `cbor:"5,keyasint,omitempty"`
|
||||
Noncritical []extension.Wire `cbor:"6,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// CapsuleIDHex returns the capsule_id in hexadecimal.
|
||||
func (h *Header) CapsuleIDHex() string { return hex.EncodeToString(h.CapsuleID[:]) }
|
||||
|
||||
// EncodeHeader returns the Deterministic CBOR bytes of h.
|
||||
func EncodeHeader(h *Header) ([]byte, error) {
|
||||
compact := h.DateKey.Compact()
|
||||
if compact == "" {
|
||||
return nil, fmt.Errorf("capsule: invalid DateKey: %w", datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
if !h.Policy.valid() {
|
||||
return nil, fmt.Errorf("capsule: unknown access policy %d", h.Policy)
|
||||
}
|
||||
w := headerWire{
|
||||
Type: HeaderTypeTag,
|
||||
Version: HeaderVersion,
|
||||
CapsuleID: h.CapsuleID[:],
|
||||
DateKey: compact,
|
||||
Policy: uint64(h.Policy),
|
||||
}
|
||||
var err error
|
||||
if w.Critical, err = extension.Encode(h.Critical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.Noncritical, err = extension.Encode(h.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return codec.Marshal(w)
|
||||
}
|
||||
|
||||
// DecodeHeader validates and decodes PUBLIC_HEADER bytes (spec §24, §27,
|
||||
// §63 step 4): canonical CBOR, the schema, a 16-byte capsule_id, a canonical
|
||||
// DateKey, a V1 access policy and well-formed extension arrays. Whether the
|
||||
// profile is pinned and the critical extensions known is decided by the
|
||||
// caller.
|
||||
func DecodeHeader(b []byte) (*Header, error) {
|
||||
if err := codec.CheckSchema(b, HeaderTypeTag, HeaderVersion); err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
|
||||
}
|
||||
var w headerWire
|
||||
if err := codec.Unmarshal(b, &w); err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
|
||||
}
|
||||
if len(w.CapsuleID) != CapsuleIDSize {
|
||||
return nil, fmt.Errorf("capsule: capsule_id is %d bytes, want %d: %w", len(w.CapsuleID), CapsuleIDSize, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
dk, err := datekey.Parse(w.DateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
|
||||
}
|
||||
if w.Policy > 1 {
|
||||
return nil, fmt.Errorf("capsule: access_policy %d is not defined in V1: %w", w.Policy, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
h := &Header{DateKey: dk, Policy: Policy(w.Policy)}
|
||||
copy(h.CapsuleID[:], w.CapsuleID)
|
||||
if h.Critical, err = extension.Decode(w.Critical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER critical_extensions: %w", err)
|
||||
}
|
||||
if h.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER noncritical_extensions: %w", err)
|
||||
}
|
||||
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONTROL_CBOR
|
||||
|
||||
// Control is CONTROL_CBOR (spec §31). PayloadIdentity is I_PAYLOAD, a secret.
|
||||
type Control struct {
|
||||
HeaderBinding [32]byte // key 2
|
||||
PayloadIdentity [32]byte // key 3, raw X25519 identity bytes. SECRET.
|
||||
Critical []extension.Extension // key 4
|
||||
Noncritical []extension.Extension // key 5
|
||||
}
|
||||
|
||||
type controlWire struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
HeaderBinding []byte `cbor:"2,keyasint"`
|
||||
PayloadIdentity []byte `cbor:"3,keyasint"`
|
||||
Critical []extension.Wire `cbor:"4,keyasint,omitempty"`
|
||||
Noncritical []extension.Wire `cbor:"5,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// String describes c without I_PAYLOAD.
|
||||
func (c Control) String() string {
|
||||
return fmt.Sprintf("Control{header_binding=%x payload_identity=REDACTED}", c.HeaderBinding)
|
||||
}
|
||||
|
||||
// GoString describes c without I_PAYLOAD.
|
||||
func (c Control) GoString() string { return c.String() }
|
||||
|
||||
// EncodeControl returns the Deterministic CBOR bytes of c. The caller must
|
||||
// wipe the result: it contains I_PAYLOAD.
|
||||
func EncodeControl(c *Control) ([]byte, error) {
|
||||
w := controlWire{
|
||||
Type: ControlTypeTag,
|
||||
Version: ControlVersion,
|
||||
HeaderBinding: c.HeaderBinding[:],
|
||||
PayloadIdentity: c.PayloadIdentity[:],
|
||||
}
|
||||
var err error
|
||||
if w.Critical, err = extension.Encode(c.Critical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.Noncritical, err = extension.Encode(c.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return codec.Marshal(w)
|
||||
}
|
||||
|
||||
// DecodeControl validates and decodes CONTROL_CBOR (spec §31, §63 step 14).
|
||||
// A non-canonical encoding is rejected even though CONTROL_CBOR is not hashed.
|
||||
func DecodeControl(b []byte) (*Control, error) {
|
||||
if err := codec.CheckSchema(b, ControlTypeTag, ControlVersion); err != nil {
|
||||
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
|
||||
}
|
||||
var w controlWire
|
||||
if err := codec.Unmarshal(b, &w); err != nil {
|
||||
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
|
||||
}
|
||||
defer clear(w.PayloadIdentity)
|
||||
if len(w.HeaderBinding) != 32 || len(w.PayloadIdentity) != 32 {
|
||||
return nil, fmt.Errorf("capsule: header_binding and payload_identity must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
c := &Control{}
|
||||
copy(c.HeaderBinding[:], w.HeaderBinding)
|
||||
copy(c.PayloadIdentity[:], w.PayloadIdentity)
|
||||
var err error
|
||||
if c.Critical, err = extension.Decode(w.Critical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: CONTROL_CBOR critical_extensions: %w", err)
|
||||
}
|
||||
if c.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: CONTROL_CBOR noncritical_extensions: %w", err)
|
||||
}
|
||||
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
|
||||
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// looksLikeAge reports whether b starts with the age v1 intro line.
|
||||
func looksLikeAge(b []byte) bool {
|
||||
return bytes.HasPrefix(b, []byte("age-encryption.org/v1\n"))
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
func TestPolicyNames(t *testing.T) {
|
||||
for _, p := range []capsule.Policy{capsule.TimeOnly, capsule.TimeAndKey} {
|
||||
got, err := capsule.ParsePolicy(p.String())
|
||||
if err != nil || got != p {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := capsule.ParsePolicy("time_or_key"); err == nil {
|
||||
t.Fatal("unknown policy parsed")
|
||||
}
|
||||
if capsule.Policy(9).String() != "policy(9)" {
|
||||
t.Fatal("unknown policy name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIsNotPrinted(t *testing.T) {
|
||||
c := capsule.Control{}
|
||||
for i := range c.PayloadIdentity {
|
||||
c.PayloadIdentity[i] = 0xab
|
||||
}
|
||||
for _, s := range []string{fmt.Sprint(c), fmt.Sprintf("%+v", &c), fmt.Sprintf("%#v", c)} {
|
||||
if strings.Contains(s, "abab") || !strings.Contains(s, "REDACTED") {
|
||||
t.Fatalf("I_PAYLOAD printed: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeHeaderAndControlReject(t *testing.T) {
|
||||
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
|
||||
dup := []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
|
||||
for name, h := range map[string]capsule.Header{
|
||||
"invalid DateKey": {},
|
||||
"unknown policy": {DateKey: dk, Policy: 5},
|
||||
"repeated critical": {DateKey: dk, Critical: dup},
|
||||
"repeated noncritical": {DateKey: dk, Noncritical: dup},
|
||||
"both arrays": {DateKey: dk, Critical: dup[:1], Noncritical: dup[1:]},
|
||||
} {
|
||||
if _, err := capsule.EncodeHeader(&h); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
for name, c := range map[string]capsule.Control{
|
||||
"repeated critical": {Critical: dup},
|
||||
"repeated noncritical": {Noncritical: dup},
|
||||
"both arrays": {Critical: dup[:1], Noncritical: dup[1:]},
|
||||
} {
|
||||
if _, err := capsule.EncodeControl(&c); err == nil {
|
||||
t.Errorf("control %s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func marshal(t *testing.T, m map[uint64]any) []byte {
|
||||
t.Helper()
|
||||
b, err := codec.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func ext(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
|
||||
|
||||
func TestDecodeHeaderRejects(t *testing.T) {
|
||||
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
|
||||
base := func() map[uint64]any {
|
||||
return map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: dk, 4: uint64(0)}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(m map[uint64]any)
|
||||
want error
|
||||
}{
|
||||
{"short capsule_id", func(m map[uint64]any) { m[2] = make([]byte, 15) }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"invalid DateKey", func(m map[uint64]any) { m[3] = "dk1_x" }, datekeys.ErrDateKeyInvalid},
|
||||
{"type tag", func(m map[uint64]any) { m[0] = capsule.ControlTypeTag }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"critical out of order", func(m map[uint64]any) { m[5] = []any{ext("b", 1), ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"noncritical repeated", func(m map[uint64]any) { m[6] = []any{ext("a", 1), ext("a", 2)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"both arrays", func(m map[uint64]any) { m[5] = []any{ext("a", 1)}; m[6] = []any{ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
} {
|
||||
m := base()
|
||||
tc.edit(m)
|
||||
if _, err := capsule.DecodeHeader(marshal(t, m)); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := capsule.DecodeHeader(marshal(t, base())); err != nil {
|
||||
t.Fatalf("valid header rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlRejects(t *testing.T) {
|
||||
base := func() map[uint64]any {
|
||||
return map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(1), 2: make([]byte, 32), 3: make([]byte, 32)}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(m map[uint64]any)
|
||||
want error
|
||||
}{
|
||||
{"schema version", func(m map[uint64]any) { m[1] = uint64(2) }, datekeys.ErrUnsupportedVersion},
|
||||
{"type tag", func(m map[uint64]any) { m[0] = capsule.HeaderTypeTag }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"short binding", func(m map[uint64]any) { m[2] = make([]byte, 31) }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"long identity", func(m map[uint64]any) { m[3] = make([]byte, 33) }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"unknown key", func(m map[uint64]any) { m[6] = "x" }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"critical repeated", func(m map[uint64]any) { m[4] = []any{ext("a", 1), ext("a", 2)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"noncritical out of order", func(m map[uint64]any) { m[5] = []any{ext("b", 1), ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
{"both arrays", func(m map[uint64]any) { m[4] = []any{ext("a", 1)}; m[5] = []any{ext("a", 1)} }, datekeys.ErrNonCanonicalCBOR},
|
||||
} {
|
||||
m := base()
|
||||
tc.edit(m)
|
||||
if _, err := capsule.DecodeControl(marshal(t, m)); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := capsule.DecodeControl([]byte("age-encryption.org/v1\n")); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("garbage control: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimit(t *testing.T) {
|
||||
big, err := extension.New("org.example.big", 1, bytes.Repeat([]byte{1}, capsule.MaxPublicHeaderLen))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := past(t, 1000)
|
||||
opts.Noncritical = []extension.Extension{big}
|
||||
var dkc bytes.Buffer
|
||||
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil || dkc.Len() != 0 {
|
||||
t.Fatalf("PUBLIC_HEADER above 1 MiB accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A .dkk whose critical extension the application does not know is refused
|
||||
// before any request.
|
||||
func TestAccessKeyCriticalExtension(t *testing.T) {
|
||||
f := loadFixture(t, "time_and_key_portable")
|
||||
k := *f.dkk
|
||||
k.Critical = []extension.Extension{{ID: "org.example.must-understand", Version: 1}}
|
||||
o := f.openOptions(t)
|
||||
o.AccessKey = &k
|
||||
src := testkit.NewSource(f.release)
|
||||
o.Source = src
|
||||
if _, err := open(t, f.dkc, o); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) || src.Calls != 0 {
|
||||
t.Fatalf("got %v (requests %d)", err, src.Calls)
|
||||
}
|
||||
o.Extensions = extension.Set{"org.example.must-understand": {1}}
|
||||
if got, err := open(t, f.dkc, o); err != nil || !bytes.Equal(got, f.plaintext) {
|
||||
t.Fatalf("known .dkk extension rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type brokenSeeker struct {
|
||||
*bytes.Reader
|
||||
seeks int
|
||||
}
|
||||
|
||||
func (b *brokenSeeker) Seek(off int64, whence int) (int64, error) {
|
||||
b.seeks++
|
||||
if b.seeks > 1 {
|
||||
return 0, errors.New("seek failed")
|
||||
}
|
||||
return b.Reader.Seek(off, whence)
|
||||
}
|
||||
|
||||
func TestCapsuleDigestSeekFailure(t *testing.T) {
|
||||
f := loadFixture(t, "time_and_key_portable")
|
||||
r := &brokenSeeker{Reader: bytes.NewReader(f.dkc)}
|
||||
if _, err := capsule.Open(context.Background(), io.Discard, r, f.openOptions(t)); err == nil {
|
||||
t.Fatal("seek failure ignored")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
)
|
||||
|
||||
func seedFixtures(f *testing.F, part func(testkit.Parts) []byte) {
|
||||
for _, name := range fixtureNames {
|
||||
b, err := os.ReadFile(filepath.Join(fixtureDir, name+".dkc"))
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
p, err := testkit.Split(b)
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
f.Add(part(p))
|
||||
}
|
||||
}
|
||||
|
||||
// whole keeps the first 512 bytes of the payload: the pre-unlock checks read
|
||||
// only its age header, and small inputs keep the fuzzer fast.
|
||||
func whole(p testkit.Parts) []byte {
|
||||
payload := p.Payload
|
||||
if len(payload) > 512 {
|
||||
payload = payload[:512]
|
||||
}
|
||||
return testkit.Join(p.Prelude, p.Header, p.Sealed, payload)
|
||||
}
|
||||
|
||||
func FuzzParsePrelude(f *testing.F) {
|
||||
seedFixtures(f, func(p testkit.Parts) []byte { return p.Prelude })
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
p, err := capsule.ParsePrelude(b)
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := p.Bytes()
|
||||
if !bytes.Equal(got[:], b[:capsule.PreludeSize]) {
|
||||
t.Fatal("accepted a prelude that does not re-encode to its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzDecodeHeader(f *testing.F) {
|
||||
seedFixtures(f, func(p testkit.Parts) []byte { return p.Header })
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
h, err := capsule.DecodeHeader(b)
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
re, err := capsule.EncodeHeader(h)
|
||||
if err != nil || !bytes.Equal(re, b) {
|
||||
t.Fatal("accepted a PUBLIC_HEADER that does not re-encode to its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzDecodeControl(f *testing.F) {
|
||||
for _, name := range fixtureNames {
|
||||
fx := loadFixture(f, name)
|
||||
b, _ := hexDecode(fx.ControlCBOR)
|
||||
f.Add(b)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
c, err := capsule.DecodeControl(b)
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
re, err := capsule.EncodeControl(c)
|
||||
if err != nil || !bytes.Equal(re, b) {
|
||||
t.Fatal("accepted a CONTROL_CBOR that does not re-encode to its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzInspect feeds whole capsules to the pre-unlock validation, and to Open
|
||||
// with a source that never has the release: a capsule that Inspect rejects
|
||||
// must not cause a request, and nothing may pass the release step. The
|
||||
// cryptographic steps after it are exercised by the mutation corpus; keeping
|
||||
// them out of this target keeps it fast.
|
||||
func FuzzInspect(f *testing.F) {
|
||||
seedFixtures(f, whole)
|
||||
reg := testkit.Registry()
|
||||
far := testkit.Fixed(testkit.Genesis().AddDate(5, 0, 0))
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
_, err := capsule.Inspect(bytes.NewReader(b), capsule.InspectOptions{Registry: reg})
|
||||
if err != nil && datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
src := testkit.NewSource()
|
||||
o := capsule.OpenOptions{Registry: reg, Source: src, Now: far}
|
||||
_, openErr := capsule.Open(context.Background(), io.Discard, bytes.NewReader(b), o)
|
||||
switch {
|
||||
case openErr == nil:
|
||||
t.Fatal("opened without a release")
|
||||
case datekeys.Code(openErr) == "":
|
||||
t.Fatalf("error without a normative code: %v", openErr)
|
||||
case err != nil && src.Calls != 0:
|
||||
t.Fatal("a capsule rejected by Inspect caused a release request")
|
||||
case err == nil && !errors.Is(openErr, datekeys.ErrReleaseUnavailable) && !errors.Is(openErr, datekeys.ErrAccessRequired):
|
||||
t.Fatalf("a capsule accepted by Inspect failed before the release step: %v", openErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func hexDecode(s string) ([]byte, error) { return hex.DecodeString(s) }
|
||||
@ -0,0 +1,211 @@
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// InspectOptions configures Inspect.
|
||||
type InspectOptions struct {
|
||||
// Registry holds the locally pinned profiles. Required.
|
||||
Registry profile.Registry
|
||||
// Extensions lists the critical extensions the application implements.
|
||||
// Nil knows none, the state of the base protocol V1.
|
||||
Extensions extension.Registry
|
||||
}
|
||||
|
||||
// CheckResult records one step of the flow of spec §63.
|
||||
type CheckResult struct {
|
||||
Step int `json:"step"`
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Error is the normative code of a failed step, for example
|
||||
// "ERR_ROUND_MISMATCH".
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StanzaInfo is the visible part of an age recipient stanza.
|
||||
type StanzaInfo struct {
|
||||
Type string `json:"type"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
// Inspection is the result of the pre-unlock validation, steps 1 to 8 of
|
||||
// spec §63. It is produced without network access and without secrets.
|
||||
type Inspection struct {
|
||||
Prelude Prelude
|
||||
PublicHeader []byte // exact PUBLIC_HEADER bytes
|
||||
Header *Header
|
||||
Profile *profile.Profile
|
||||
UnlockAt time.Time // effective round time of the DateKey
|
||||
PayloadOffset int64
|
||||
OuterStanzas []StanzaInfo // OUTER_TIME_AGE
|
||||
PayloadStanzas []StanzaInfo // PAYLOAD_AGE
|
||||
Checks []CheckResult
|
||||
}
|
||||
|
||||
func (in *Inspection) pass(step int, name, detail string) {
|
||||
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, OK: true, Detail: detail})
|
||||
}
|
||||
|
||||
func (in *Inspection) fail(step int, name string, err error) error {
|
||||
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, Detail: err.Error(), Error: datekeys.Code(err)})
|
||||
return err
|
||||
}
|
||||
|
||||
// parsed carries what Open needs after the inspection.
|
||||
type parsed struct {
|
||||
prelude [PreludeSize]byte
|
||||
sealed []byte // OUTER_TIME_AGE
|
||||
payload io.Reader // positioned at the start of PAYLOAD_AGE
|
||||
}
|
||||
|
||||
// Inspect runs steps 1 to 8 of spec §63 on the .dkc read from r: framing,
|
||||
// canonical PUBLIC_HEADER, canonical DateKey, pinned profile, known critical
|
||||
// extensions, the stanza structure of OUTER_TIME_AGE and PAYLOAD_AGE, and the
|
||||
// round and chain hash of the tlock stanza. It never contacts a release
|
||||
// source and never uses a secret, so an invalid capsule is rejected before it
|
||||
// can cause an observable query (spec §27, §63).
|
||||
//
|
||||
// Inspect reads the prelude, the header, SEALED_CONTROL and the age header of
|
||||
// the payload; it does not read the rest of the payload. On failure it
|
||||
// returns the partial Inspection together with the error.
|
||||
func Inspect(r io.Reader, opts InspectOptions) (*Inspection, error) {
|
||||
in, _, err := inspect(r, opts)
|
||||
return in, err
|
||||
}
|
||||
|
||||
func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) {
|
||||
in := &Inspection{}
|
||||
if opts.Registry == nil {
|
||||
return in, nil, errors.New("capsule: InspectOptions.Registry is required")
|
||||
}
|
||||
|
||||
// Steps 1 and 2: parse DKC1 and validate the prelude.
|
||||
var pre [PreludeSize]byte
|
||||
n, err := io.ReadFull(r, pre[:])
|
||||
if err != nil && n >= 4 && string(pre[:4]) == Magic {
|
||||
return in, nil, in.fail(1, "parse DKC1", fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity))
|
||||
}
|
||||
prelude, err := ParsePrelude(pre[:n])
|
||||
if errors.Is(err, datekeys.ErrInvalidMagic) {
|
||||
return in, nil, in.fail(1, "parse DKC1", err)
|
||||
}
|
||||
in.pass(1, "parse DKC1", "magic DKC1")
|
||||
if err != nil {
|
||||
return in, nil, in.fail(2, "prelude", err)
|
||||
}
|
||||
in.Prelude = prelude
|
||||
in.PayloadOffset = prelude.PayloadOffset()
|
||||
in.pass(2, "prelude", fmt.Sprintf("DKC1 v%d, PUBLIC_HEADER_LEN=%d, SEALED_CONTROL_LEN=%d",
|
||||
FramingVersion, prelude.PublicHeaderLen, prelude.SealedControlLen))
|
||||
|
||||
// Step 3: read the exact PUBLIC_HEADER bytes.
|
||||
hb, err := readExactly(r, int64(prelude.PublicHeaderLen))
|
||||
if err != nil {
|
||||
return in, nil, in.fail(3, "public header", fmt.Errorf("capsule: truncated PUBLIC_HEADER: %w", datekeys.ErrIntegrity))
|
||||
}
|
||||
in.PublicHeader = hb
|
||||
in.pass(3, "public header", fmt.Sprintf("%d bytes", len(hb)))
|
||||
|
||||
// Step 4: canonical CBOR, canonical DateKey, pinned profile, known
|
||||
// critical extensions.
|
||||
h, err := DecodeHeader(hb)
|
||||
if err != nil {
|
||||
return in, nil, in.fail(4, "header validation", err)
|
||||
}
|
||||
in.Header = h
|
||||
p, ok := opts.Registry.Lookup(h.DateKey.ProfileID)
|
||||
if !ok {
|
||||
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: profile %q is not pinned: %w", h.DateKey.ProfileID, datekeys.ErrUnknownProfile))
|
||||
}
|
||||
in.Profile = p
|
||||
if err := extension.CheckCritical(h.Critical, opts.Extensions); err != nil {
|
||||
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: PUBLIC_HEADER: %w", err))
|
||||
}
|
||||
in.pass(4, "header validation", fmt.Sprintf("capsule_id=%s datekey=%s policy=%s profile=%s",
|
||||
h.CapsuleIDHex(), h.DateKey.Compact(), h.Policy, p.ID))
|
||||
|
||||
// Step 5: OUTER_TIME_AGE holds exactly one stanza, of type tlock.
|
||||
sealed, err := readExactly(r, int64(prelude.SealedControlLen))
|
||||
if err != nil {
|
||||
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: truncated SEALED_CONTROL: %w", datekeys.ErrIntegrity))
|
||||
}
|
||||
outer, err := agewrap.Stanzas(bytes.NewReader(sealed))
|
||||
if err != nil {
|
||||
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: SEALED_CONTROL: %w", err))
|
||||
}
|
||||
in.OuterStanzas = infos(outer)
|
||||
if len(outer) != 1 || outer[0].Type != agewrap.StanzaTLock {
|
||||
err := fmt.Errorf("capsule: OUTER_TIME_AGE must hold exactly one tlock stanza, found %d: %w", len(outer), datekeys.ErrPolicyStructureMismatch)
|
||||
return in, nil, in.fail(5, "sealed control structure", err)
|
||||
}
|
||||
in.pass(5, "sealed control structure", "one tlock stanza")
|
||||
|
||||
// Step 6: PAYLOAD_AGE holds exactly one stanza, of type X25519. Only its
|
||||
// age header is read; the bytes consumed are replayed for decryption.
|
||||
var captured bytes.Buffer
|
||||
payloadStanzas, err := agewrap.Stanzas(io.TeeReader(r, &captured))
|
||||
if err != nil {
|
||||
return in, nil, in.fail(6, "payload structure", fmt.Errorf("capsule: PAYLOAD_AGE: %w", err))
|
||||
}
|
||||
in.PayloadStanzas = infos(payloadStanzas)
|
||||
if err := agewrap.CheckPayloadStanzas(payloadStanzas); err != nil {
|
||||
return in, nil, in.fail(6, "payload structure", err)
|
||||
}
|
||||
in.pass(6, "payload structure", "one X25519 stanza")
|
||||
|
||||
// Step 7: resolve and verify the time condition locally.
|
||||
if err := h.DateKey.Validate(p); err != nil {
|
||||
return in, nil, in.fail(7, "condition", err)
|
||||
}
|
||||
unlock, err := datekey.RoundTime(p, h.DateKey.Round)
|
||||
if err != nil {
|
||||
return in, nil, in.fail(7, "condition", err)
|
||||
}
|
||||
in.UnlockAt = unlock
|
||||
in.pass(7, "condition", fmt.Sprintf("round %d, unlock at %s", h.DateKey.Round, unlock.Format(time.RFC3339)))
|
||||
|
||||
// Step 8: the tlock stanza names the DateKey round and the pinned chain.
|
||||
if err := agewrap.CheckTimeStanzas(outer, p, h.DateKey.Round); err != nil {
|
||||
return in, nil, in.fail(8, "tlock stanza", err)
|
||||
}
|
||||
in.pass(8, "tlock stanza", fmt.Sprintf("round %d, chain %s", h.DateKey.Round, p.ChainHashHex()))
|
||||
|
||||
return in, &parsed{
|
||||
prelude: pre,
|
||||
sealed: sealed,
|
||||
payload: io.MultiReader(bytes.NewReader(captured.Bytes()), r),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readExactly reads n bytes. The buffer grows with the data actually read, so
|
||||
// a short file that declares a large length (within the §57 limits) does not
|
||||
// force an allocation of that size.
|
||||
func readExactly(r io.Reader, n int64) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
if _, err := io.CopyN(&b, r, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func infos(stanzas []*age.Stanza) []StanzaInfo {
|
||||
out := make([]StanzaInfo, len(stanzas))
|
||||
for i, s := range stanzas {
|
||||
out[i] = StanzaInfo{Type: s.Type, Args: s.Args}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
//go:build interop
|
||||
|
||||
// Interoperability with third-party tools (plan §7.9): SEALED_CONTROL and
|
||||
// PAYLOAD_AGE of an official fixture are standard age files, opened here by
|
||||
// the official age and tle command-line tools.
|
||||
//
|
||||
// go install filippo.io/age/cmd/age@v1.3.2
|
||||
// go install github.com/drand/tlock/cmd/tle@v1.2.0
|
||||
// go test -tags interop ./capsule -run Interop
|
||||
//
|
||||
// DATEKEYS_AGE and DATEKEYS_TLE may point at the binaries. The tle part
|
||||
// fetches round 1000 from the public drand relay.
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
)
|
||||
|
||||
func tool(t *testing.T, env, name string) string {
|
||||
t.Helper()
|
||||
if p := os.Getenv(env); p != "" {
|
||||
return p
|
||||
}
|
||||
p, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
t.Skipf("%s not found; install it or set %s", name, env)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestInteropAgeOpensPayload(t *testing.T) {
|
||||
bin := tool(t, "DATEKEYS_AGE", "age")
|
||||
f := loadFixture(t, "time_only")
|
||||
parts, _ := testkit.Split(f.dkc)
|
||||
raw, _ := hex.DecodeString(f.PayloadIdentity)
|
||||
id, err := agewrap.X25519IdentityFromRaw(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "payload.age"), parts.Payload, 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "id.txt"), []byte(id.String()+"\n"), 0o600)
|
||||
out := filepath.Join(dir, "plain")
|
||||
cmd := exec.Command(bin, "-d", "-i", filepath.Join(dir, "id.txt"), "-o", out, filepath.Join(dir, "payload.age"))
|
||||
if b, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("age: %v\n%s", err, b)
|
||||
}
|
||||
if got, _ := os.ReadFile(out); !bytes.Equal(got, f.plaintext) {
|
||||
t.Fatal("age produced a different plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteropAgeEncryptsPayloadWeOpen(t *testing.T) {
|
||||
bin := tool(t, "DATEKEYS_AGE", "age")
|
||||
id, _ := age.GenerateX25519Identity()
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "in"), []byte("written by age"), 0o600)
|
||||
out := filepath.Join(dir, "out.age")
|
||||
if b, err := exec.Command(bin, "-r", id.Recipient().String(), "-o", out, filepath.Join(dir, "in")).CombinedOutput(); err != nil {
|
||||
t.Fatalf("age: %v\n%s", err, b)
|
||||
}
|
||||
file, _ := os.ReadFile(out)
|
||||
raw, _ := agewrap.RawX25519Identity(id)
|
||||
pid, _ := agewrap.NewPayloadIdentity(raw)
|
||||
if got := decryptAge(t, file, pid); string(got) != "written by age" {
|
||||
t.Fatal("plaintext differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteropTleOpensSealedControl(t *testing.T) {
|
||||
bin := tool(t, "DATEKEYS_TLE", "tle")
|
||||
f := loadFixture(t, "time_only")
|
||||
parts, _ := testkit.Split(f.dkc)
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "sealed.age")
|
||||
os.WriteFile(in, parts.Sealed, 0o600)
|
||||
out := filepath.Join(dir, "control.cbor")
|
||||
cmd := exec.Command(bin, "-d", "-o", out, in)
|
||||
if b, err := cmd.CombinedOutput(); err != nil {
|
||||
if strings.Contains(string(b), "dial") || strings.Contains(string(b), "no such host") {
|
||||
t.Skipf("tle needs network access: %s", b)
|
||||
}
|
||||
t.Fatalf("tle: %v\n%s", err, b)
|
||||
}
|
||||
if got, _ := os.ReadFile(out); hex.EncodeToString(got) != f.ControlCBOR {
|
||||
t.Fatal("tle produced a different CONTROL_CBOR")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
//go:build integration
|
||||
|
||||
// Live integration against public Quicknet relays (plan §7.8), run nightly:
|
||||
//
|
||||
// go test -tags integration ./capsule ./provider/drand
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider/drand"
|
||||
)
|
||||
|
||||
// Encrypt to now + 30 s, check that the capsule stays locked without any
|
||||
// request, wait, and open it with a release fetched from real relays.
|
||||
func TestLiveLifecycle(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
reg, err := profile.Default()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holder, _ := age.GenerateX25519Identity()
|
||||
for _, policy := range []capsule.Policy{capsule.TimeOnly, capsule.TimeAndKey} {
|
||||
t.Run(policy.String(), func(t *testing.T) {
|
||||
unlock := time.Now().Add(30 * time.Second)
|
||||
opts := capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Policy: policy, Now: time.Now}
|
||||
if policy == capsule.TimeAndKey {
|
||||
opts.Recipients = []age.Recipient{holder.Recipient()}
|
||||
}
|
||||
const msg = "DateKeys live integration: this stays on the local machine."
|
||||
var dkc bytes.Buffer
|
||||
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := capsule.OpenOptions{Registry: reg, Source: drand.New(), Now: time.Now, Identities: []age.Identity{holder}}
|
||||
if _, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
|
||||
t.Fatalf("opened before the round: %v", err)
|
||||
}
|
||||
t.Logf("locked for round %d until %s", res.DateKey.Round, res.UnlockAt.Format(time.RFC3339))
|
||||
time.Sleep(time.Until(res.UnlockAt) + 2*time.Second)
|
||||
var out bytes.Buffer
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
_, err = capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), o)
|
||||
if err == nil || !errors.Is(err, datekeys.ErrReleaseUnavailable) || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
out.Reset()
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if err != nil || out.String() != msg {
|
||||
t.Fatalf("open after the round: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,437 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// mutation is one entry of the mutation corpus (spec §64): a function over a
|
||||
// valid fixture that must fail with one exact normative error at one step.
|
||||
type mutation struct {
|
||||
name string
|
||||
// spec is true for the twenty mutations listed in spec §64.
|
||||
spec bool
|
||||
make func(t *testing.T, env *env) (dkc []byte, opts capsule.OpenOptions)
|
||||
want *datekeys.Error
|
||||
step int
|
||||
// network reports whether the failure may happen after a release was
|
||||
// requested. Failures of steps 1 to 8 and of the access pre-checks must
|
||||
// not cause any request (spec §27, §63).
|
||||
network bool
|
||||
}
|
||||
|
||||
type env struct {
|
||||
to, tk *fixture // time_only and time_and_key_portable fixtures
|
||||
toParts testkit.Parts
|
||||
tkParts testkit.Parts
|
||||
sibling []byte // another time_only capsule for the same round
|
||||
stranger *age.X25519Identity
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
e := &env{to: loadFixture(t, "time_only"), tk: loadFixture(t, "time_and_key_portable")}
|
||||
var err error
|
||||
if e.toParts, err = testkit.Split(e.to.dkc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.tkParts, err = testkit.Split(e.tk.dkc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
p := profile.Quicknet()
|
||||
unlock, _ := datekey.RoundTime(p, 1000)
|
||||
if _, err := capsule.Encrypt(&b, strings.NewReader("sibling"), capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.sibling = b.Bytes()
|
||||
e.stranger, _ = age.GenerateX25519Identity()
|
||||
return e
|
||||
}
|
||||
|
||||
func withSource(o capsule.OpenOptions, s provider.ReleaseSource) capsule.OpenOptions {
|
||||
o.Source = s
|
||||
return o
|
||||
}
|
||||
|
||||
func set(b []byte, i int, v byte) []byte {
|
||||
c := bytes.Clone(b)
|
||||
c[i] = v
|
||||
return c
|
||||
}
|
||||
|
||||
func xorLast(b []byte) []byte {
|
||||
c := bytes.Clone(b)
|
||||
c[len(c)-1] ^= 0x01
|
||||
return c
|
||||
}
|
||||
|
||||
// build returns a capsule made by testkit.Build and the options to open it.
|
||||
func build(t *testing.T, b testkit.Build) ([]byte, capsule.OpenOptions) {
|
||||
t.Helper()
|
||||
if b.Plaintext == nil {
|
||||
b.Plaintext = []byte("malicious creator")
|
||||
}
|
||||
out, err := b.Make()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.DKC, capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(1000)), Now: testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0))}
|
||||
}
|
||||
|
||||
func headerWithDateKey(t *testing.T, e *env, dk string) []byte {
|
||||
t.Helper()
|
||||
h, err := capsule.DecodeHeader(e.toParts.Header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := testkit.RawHeader(h.CapsuleID, dk, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testkit.Reframe(e.toParts.Prelude, raw, e.toParts.Sealed, e.toParts.Payload)
|
||||
}
|
||||
|
||||
func policyByte(t *testing.T, header []byte) int {
|
||||
// The access_policy entry is the last one of a header without extensions: 0x04 <value>.
|
||||
i := len(header) - 2
|
||||
if header[i] != 0x04 {
|
||||
t.Fatalf("unexpected header layout %x", header[i:])
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
|
||||
var mutations = []mutation{
|
||||
// ---- The twenty mutations of spec §64 -------------------------------
|
||||
{name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", spec: true, want: datekeys.ErrHeaderBinding, step: 15, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
b, _ := testkit.Split(e.sibling)
|
||||
return testkit.Reframe(e.toParts.Prelude, e.toParts.Header, b.Sealed, b.Payload), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "SEALED_CONTROL_A + PAYLOAD_AGE_B", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
b, _ := testkit.Split(e.sibling)
|
||||
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, b.Payload), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "DateKey A + release of round B", spec: true, want: datekeys.ErrRoundMismatch, step: 10, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
src := provider.ReleaseSourceFunc(func(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
|
||||
return testkit.Release(1001), nil
|
||||
})
|
||||
return e.to.dkc, withSource(e.to.openOptions(t), src)
|
||||
}},
|
||||
{name: "chain hash changed", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
p := profile.Quicknet()
|
||||
other := strings.Repeat("ab", 32)
|
||||
return bytes.Replace(e.to.dkc, []byte(p.ChainHashHex()), []byte(other), 1), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "version changed", spec: true, want: datekeys.ErrUnsupportedVersion, step: 2,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return set(e.to.dkc, 4, 2), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "flags != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return set(e.to.dkc, 5, 0x80), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "reserved != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return set(e.to.dkc, 7, 1), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "payload truncated", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return e.to.dkc[:len(e.to.dkc)-1], e.to.openOptions(t)
|
||||
}},
|
||||
{name: "payload age modified", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return xorLast(e.to.dkc), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "control modified", spec: true, want: datekeys.ErrIntegrity, step: 11, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return testkit.Join(e.toParts.Prelude, e.toParts.Header, xorLast(e.toParts.Sealed), e.toParts.Payload), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "non-canonical dk1_ JSON", spec: true, want: datekeys.ErrDateKeyNonCanonical, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
dk := datekey.Prefix + b64(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`)
|
||||
return headerWithDateKey(t, e, dk), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "unknown profile", spec: true, want: datekeys.ErrUnknownProfile, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
dk := datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}
|
||||
return headerWithDateKey(t, e, dk.Compact()), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "release of another round", spec: true, want: datekeys.ErrReleaseInvalid, step: 10, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
forged := provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}
|
||||
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource(forged))
|
||||
}},
|
||||
{name: "access_policy=time_only with time_and_key structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
h := set(e.tkParts.Header, policyByte(t, e.tkParts.Header), 0)
|
||||
return testkit.Join(e.tkParts.Prelude, h, e.tkParts.Sealed, e.tkParts.Payload), e.tk.openOptions(t)
|
||||
}},
|
||||
{name: "access_policy=time_and_key with time_only structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
h := set(e.toParts.Header, policyByte(t, e.toParts.Header), 1)
|
||||
o := e.to.openOptions(t)
|
||||
o.Identities = []age.Identity{e.stranger}
|
||||
return testkit.Join(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), o
|
||||
}},
|
||||
{name: "extra stanza in OUTER_TIME_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 5,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{EditOuter: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
extra, _, _ := testkit.X25519Stanza(fk)
|
||||
return append(s, extra)
|
||||
}})
|
||||
}},
|
||||
{name: "extra stanza in PAYLOAD_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 6,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{EditPayload: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
extra, _, _ := testkit.X25519Stanza(fk)
|
||||
return append(s, extra)
|
||||
}})
|
||||
}},
|
||||
{name: "non-X25519 stanza in INNER_ACCESS_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
|
||||
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
|
||||
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
return append(s, &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)})
|
||||
}})
|
||||
o.Identities = []age.Identity{e.stranger}
|
||||
return dkc, o
|
||||
}},
|
||||
{name: "tlock stanza round differs from DateKey.round", spec: true, want: datekeys.ErrRoundMismatch, step: 8,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }})
|
||||
}},
|
||||
{name: "tlock stanza chain hash differs from the pinned profile", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza {
|
||||
s[0].Args[1] = "dbd506d6ef76e5f386f41c651dcb808c5bcbd75471cc4eafa3f4df7ad4e4c493" // drand default chain
|
||||
return s
|
||||
}})
|
||||
}},
|
||||
|
||||
// ---- Further cases ----------------------------------------------------
|
||||
{name: "magic", want: datekeys.ErrInvalidMagic, step: 1,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return set(e.to.dkc, 0, 'X'), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "a .dkk offered as a .dkc", want: datekeys.ErrInvalidMagic, step: 1,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return append([]byte("DKK1"), e.to.dkc[4:]...), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "empty file", want: datekeys.ErrInvalidMagic, step: 1,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return nil, e.to.openOptions(t) }},
|
||||
{name: "truncated prelude", want: datekeys.ErrIntegrity, step: 1,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return e.to.dkc[:10], e.to.openOptions(t) }},
|
||||
{name: "PUBLIC_HEADER_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
c := bytes.Clone(e.to.dkc)
|
||||
binary.BigEndian.PutUint32(c[8:12], capsule.MaxPublicHeaderLen+1)
|
||||
return c, e.to.openOptions(t)
|
||||
}},
|
||||
{name: "SEALED_CONTROL_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
c := bytes.Clone(e.to.dkc)
|
||||
binary.BigEndian.PutUint32(c[12:16], capsule.MaxSealedControlLen+1)
|
||||
return c, e.to.openOptions(t)
|
||||
}},
|
||||
{name: "truncated inside SEALED_CONTROL", want: datekeys.ErrIntegrity, step: 5,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return e.to.dkc[:len(e.toParts.Prelude)+len(e.toParts.Header)+10], e.to.openOptions(t)
|
||||
}},
|
||||
{name: "header schema version changed", want: datekeys.ErrUnsupportedVersion, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
// a5 00 6a "datekeycap" 01 <version>
|
||||
return set(e.to.dkc, capsule.PreludeSize+14, 2), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "unknown key in PUBLIC_HEADER", want: datekeys.ErrNonCanonicalCBOR, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
h := append(bytes.Clone(e.toParts.Header), 0x07, 0x00)
|
||||
h[0]++ // one more map entry
|
||||
return testkit.Reframe(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "undefined access_policy", want: datekeys.ErrNonCanonicalCBOR, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return testkit.Join(e.toParts.Prelude, set(e.toParts.Header, policyByte(t, e.toParts.Header), 2), e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "unknown critical PUBLIC_HEADER extension", want: datekeys.ErrExtensionCriticalUnknown, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{HeaderCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
|
||||
}},
|
||||
{name: "unknown critical CONTROL_CBOR extension", want: datekeys.ErrExtensionCriticalUnknown, step: 14, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{ControlCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
|
||||
}},
|
||||
{name: "time_and_key without credentials", want: datekeys.ErrAccessRequired, step: 9,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
o := e.tk.openOptions(t)
|
||||
o.AccessKey = nil
|
||||
return e.tk.dkc, o
|
||||
}},
|
||||
{name: ".dkk of another capsule", want: datekeys.ErrAccessInvalid, step: 9,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
o := e.tk.openOptions(t)
|
||||
other := loadFixture(t, "time_and_key_recipients")
|
||||
o.AccessKey = other.dkk
|
||||
return e.tk.dkc, o
|
||||
}},
|
||||
{name: "capsule_digest of the .dkk does not match", want: datekeys.ErrAccessInvalid, step: 9,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return xorLast(e.tk.dkc), e.tk.openOptions(t)
|
||||
}},
|
||||
{name: "identity that is not a recipient", want: datekeys.ErrAccessInvalid, step: 13, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
o := e.tk.openOptions(t)
|
||||
o.AccessKey = nil
|
||||
o.Identities = []age.Identity{e.stranger}
|
||||
return e.tk.dkc, o
|
||||
}},
|
||||
{name: "round not reached yet", want: datekeys.ErrReleaseUnavailable, step: 9,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
o := e.to.openOptions(t)
|
||||
o.Now = testkit.Fixed(e.to.unlock(t).Add(-1))
|
||||
return e.to.dkc, o
|
||||
}},
|
||||
{name: "release source unavailable", want: datekeys.ErrReleaseUnavailable, step: 9, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource())
|
||||
}},
|
||||
{name: "trailing data after PAYLOAD_AGE", want: datekeys.ErrIntegrity, step: 17, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return append(bytes.Clone(e.to.dkc), 0), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "payload stanza body modified", want: datekeys.ErrIntegrity, step: 17, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
n, err := testkit.HeaderLen(e.toParts.Payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Flip a byte of the wrapped file key: the last body line before "---".
|
||||
i := bytes.LastIndex(e.toParts.Payload[:n], []byte("\n---")) - 10
|
||||
c := byte('A')
|
||||
if e.toParts.Payload[i] == 'A' {
|
||||
c = 'B'
|
||||
}
|
||||
p := set(e.toParts.Payload, i, c)
|
||||
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, p), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "tlock round edited by a third party", want: datekeys.ErrRoundMismatch, step: 8,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return bytes.Replace(e.to.dkc, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1), e.to.openOptions(t)
|
||||
}},
|
||||
{name: "empty registry", want: datekeys.ErrUnknownProfile, step: 4,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
o := e.to.openOptions(t)
|
||||
o.Registry, _ = profile.NewRegistry()
|
||||
return e.to.dkc, o
|
||||
}},
|
||||
{name: "time_only declared, time_and_key built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
return build(t, testkit.Build{Declared: capsule.TimeOnly, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{e.stranger.Recipient()}})
|
||||
}},
|
||||
{name: "time_and_key declared, time_only built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeOnly})
|
||||
o.Identities = []age.Identity{e.stranger}
|
||||
return dkc, o
|
||||
}},
|
||||
{name: "two INNER_ACCESS_AGE stanzas for one recipient", want: datekeys.ErrPolicyStructureMismatch, step: 13, network: true,
|
||||
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
|
||||
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
|
||||
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
|
||||
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
again, _ := e.stranger.Recipient().Wrap(fk)
|
||||
return append(s, again[0])
|
||||
}})
|
||||
o.Identities = []age.Identity{e.stranger}
|
||||
return dkc, o
|
||||
}},
|
||||
}
|
||||
|
||||
func TestMutationCorpus(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
n := 0
|
||||
for _, m := range mutations {
|
||||
if m.spec {
|
||||
n++
|
||||
}
|
||||
t.Run(m.name, func(t *testing.T) {
|
||||
dkc, o := m.make(t, e)
|
||||
counter := &countingSource{inner: o.Source}
|
||||
o.Source = counter
|
||||
opened, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), o)
|
||||
if err == nil {
|
||||
t.Fatal("mutation accepted")
|
||||
}
|
||||
if !errors.Is(err, m.want) {
|
||||
t.Fatalf("got %v, want %v", err, m.want)
|
||||
}
|
||||
if !m.network && counter.calls != 0 {
|
||||
t.Fatalf("an invalid capsule caused %d release requests", counter.calls)
|
||||
}
|
||||
if m.step != 0 {
|
||||
checks := checksOf(opened, dkc, o)
|
||||
last := checks[len(checks)-1]
|
||||
if last.OK || last.Step != m.step || last.Error != m.want.Code() {
|
||||
t.Fatalf("failed at %+v, want step %d", last, m.step)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if n != 20 {
|
||||
t.Fatalf("spec §64 lists 20 mutations, the corpus has %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// checksOf returns the checks recorded for a failed Open; failures inside the
|
||||
// inspection return no Opened, so the inspection is repeated for them.
|
||||
func checksOf(opened *capsule.Opened, dkc []byte, o capsule.OpenOptions) []capsule.CheckResult {
|
||||
if opened != nil {
|
||||
return opened.Inspection.Checks
|
||||
}
|
||||
in, _ := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: o.Registry, Extensions: o.Extensions})
|
||||
return in.Checks
|
||||
}
|
||||
|
||||
type countingSource struct {
|
||||
inner provider.ReleaseSource
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *countingSource) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
|
||||
c.calls++
|
||||
return c.inner.Fetch(ctx, p, cond)
|
||||
}
|
||||
|
||||
// Known critical extensions are accepted when the application declares them.
|
||||
func TestKnownCriticalExtensions(t *testing.T) {
|
||||
crit := []extension.Extension{{ID: "org.example.must-understand", Version: 1}}
|
||||
for _, b := range []testkit.Build{{HeaderCritical: crit}, {ControlCritical: crit}} {
|
||||
dkc, o := build(t, b)
|
||||
o.Extensions = extension.Set{"org.example.must-understand": {1}}
|
||||
var out bytes.Buffer
|
||||
if _, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o); err != nil || out.String() != "malicious creator" {
|
||||
t.Fatalf("known critical extension rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||
@ -0,0 +1,273 @@
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// OpenOptions configures Open.
|
||||
type OpenOptions struct {
|
||||
// Registry holds the locally pinned profiles. Required.
|
||||
Registry profile.Registry
|
||||
// Extensions lists the critical extensions the application implements.
|
||||
Extensions extension.Registry
|
||||
// Source fetches the release. Required. Its answer is always verified
|
||||
// locally.
|
||||
Source provider.ReleaseSource
|
||||
// Identities are the caller's own X25519 identities, for time_and_key
|
||||
// capsules encrypted to known recipients.
|
||||
Identities []age.Identity
|
||||
// AccessKey is a portable .dkk, for time_and_key capsules.
|
||||
AccessKey *accesskey.AccessKey
|
||||
// Now is the clock. Required: no package of this module reads the wall
|
||||
// clock on its own. Open does not ask Source for a round whose time has
|
||||
// not been reached.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Opened describes a capsule that Open decrypted completely.
|
||||
type Opened struct {
|
||||
Inspection *Inspection
|
||||
Release provider.Release
|
||||
// ControlCritical and ControlNoncritical are the extensions of the sealed
|
||||
// CONTROL_CBOR, only visible after opening.
|
||||
ControlCritical []extension.Extension
|
||||
ControlNoncritical []extension.Extension
|
||||
}
|
||||
|
||||
// Open runs the complete decryption flow of spec §63 and streams the
|
||||
// plaintext to dst.
|
||||
//
|
||||
// Everything verifiable locally is checked before a release is requested or a
|
||||
// secret is used (steps 1 to 8, the presence and capsule binding of access
|
||||
// credentials, and the .dkk capsule_digest when r is seekable). The stanza
|
||||
// rules are enforced again, as a MUST, by the identities that open each age
|
||||
// file (steps 11, 13 and 17).
|
||||
//
|
||||
// Open returns nil only after age has authenticated the whole payload
|
||||
// (step 18). On error, dst may hold a partial plaintext that MUST be
|
||||
// discarded: write to a temporary file and publish it only on success
|
||||
// (spec §56), as the datekeys CLI does.
|
||||
func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*Opened, error) {
|
||||
if opts.Source == nil {
|
||||
return nil, errors.New("capsule: OpenOptions.Source is required")
|
||||
}
|
||||
if opts.Now == nil {
|
||||
return nil, errors.New("capsule: OpenOptions.Now is required")
|
||||
}
|
||||
start, seekable := int64(0), false
|
||||
if s, ok := r.(io.Seeker); ok {
|
||||
if pos, err := s.Seek(0, io.SeekCurrent); err == nil {
|
||||
start, seekable = pos, true
|
||||
}
|
||||
}
|
||||
|
||||
// Steps 1 to 8.
|
||||
in, st, err := inspect(r, InspectOptions{Registry: opts.Registry, Extensions: opts.Extensions})
|
||||
out := &Opened{Inspection: in}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
h, p := in.Header, in.Profile
|
||||
|
||||
// Access credentials are checked before any network request.
|
||||
var ids []age.Identity
|
||||
if h.Policy == TimeAndKey {
|
||||
ids = append(ids, opts.Identities...)
|
||||
if k := opts.AccessKey; k != nil {
|
||||
if err := checkAccessKey(k, h, opts.Extensions); err != nil {
|
||||
return out, in.fail(9, "access credential", err)
|
||||
}
|
||||
if k.Verification != nil && seekable {
|
||||
payload, err := checkCapsuleDigest(r.(io.ReadSeeker), start, in.PayloadOffset, k.Verification.CapsuleDigest)
|
||||
if err != nil {
|
||||
return out, in.fail(9, "access credential", err)
|
||||
}
|
||||
st.payload = payload
|
||||
}
|
||||
id, err := k.Identity()
|
||||
if err != nil {
|
||||
return out, in.fail(9, "access credential", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return out, in.fail(9, "access credential", fmt.Errorf("capsule: time_and_key capsule and no identity or .dkk supplied: %w", datekeys.ErrAccessRequired))
|
||||
}
|
||||
in.pass(9, "access credential", fmt.Sprintf("%d identities to try", len(ids)))
|
||||
}
|
||||
|
||||
// Step 9: obtain the release, never before its round time.
|
||||
cond := provider.Condition{Round: h.DateKey.Round}
|
||||
if now := opts.Now(); now.Before(in.UnlockAt) {
|
||||
err := fmt.Errorf("capsule: round %d is published at %s, it is %s: %w", cond.Round,
|
||||
in.UnlockAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
|
||||
return out, in.fail(9, "release", err)
|
||||
}
|
||||
release, err := opts.Source.Fetch(ctx, p, cond)
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
err = fmt.Errorf("capsule: %v: %w", err, datekeys.ErrReleaseUnavailable)
|
||||
}
|
||||
return out, in.fail(9, "release", err)
|
||||
}
|
||||
in.pass(9, "release", fmt.Sprintf("round %d obtained", release.Round))
|
||||
|
||||
// Step 10: verify the release locally.
|
||||
if err := provider.Verify(p, cond, release); err != nil {
|
||||
return out, in.fail(10, "release verification", err)
|
||||
}
|
||||
out.Release = release
|
||||
in.pass(10, "release verification", "BLS signature valid under the pinned key")
|
||||
|
||||
// Step 11: open OUTER_TIME_AGE with the strict tlock identity.
|
||||
timeID, err := agewrap.NewTimeIdentity(p, cond.Round, release)
|
||||
if err != nil {
|
||||
return out, in.fail(11, "open sealed control", err)
|
||||
}
|
||||
inner, err := decryptAll(st.sealed, timeID)
|
||||
if err != nil {
|
||||
return out, in.fail(11, "open sealed control", err)
|
||||
}
|
||||
defer clear(inner)
|
||||
in.pass(11, "open sealed control", "one tlock stanza, header MAC valid")
|
||||
|
||||
// Step 12: the structure must match access_policy (spec §36).
|
||||
var controlBytes []byte
|
||||
switch h.Policy {
|
||||
case TimeOnly:
|
||||
if looksLikeAge(inner) {
|
||||
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_only capsule seals an age file: %w", datekeys.ErrPolicyStructureMismatch))
|
||||
}
|
||||
controlBytes = inner
|
||||
in.pass(12, "policy structure", "time_only: CONTROL_CBOR sealed directly")
|
||||
case TimeAndKey:
|
||||
stanzas, err := agewrap.Stanzas(bytes.NewReader(inner))
|
||||
if err != nil {
|
||||
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_and_key capsule does not seal an age file: %w", datekeys.ErrPolicyStructureMismatch))
|
||||
}
|
||||
if err := agewrap.CheckAccessStanzas(stanzas); err != nil {
|
||||
return out, in.fail(12, "policy structure", err)
|
||||
}
|
||||
in.pass(12, "policy structure", fmt.Sprintf("time_and_key: INNER_ACCESS_AGE with %d X25519 stanzas", len(stanzas)))
|
||||
|
||||
// Step 13: open INNER_ACCESS_AGE with the caller's identities.
|
||||
accessID, err := agewrap.NewAccessIdentity(ids...)
|
||||
if err != nil {
|
||||
return out, in.fail(13, "open access layer", err)
|
||||
}
|
||||
if controlBytes, err = decryptAll(inner, accessID); err != nil {
|
||||
return out, in.fail(13, "open access layer", err)
|
||||
}
|
||||
defer clear(controlBytes)
|
||||
in.pass(13, "open access layer", "identity matched exactly one stanza")
|
||||
}
|
||||
|
||||
// Step 14: parse the canonical CONTROL_CBOR.
|
||||
control, err := DecodeControl(controlBytes)
|
||||
if err != nil {
|
||||
return out, in.fail(14, "control", err)
|
||||
}
|
||||
defer clear(control.PayloadIdentity[:])
|
||||
if err := extension.CheckCritical(control.Critical, opts.Extensions); err != nil {
|
||||
return out, in.fail(14, "control", fmt.Errorf("capsule: CONTROL_CBOR: %w", err))
|
||||
}
|
||||
out.ControlCritical, out.ControlNoncritical = control.Critical, control.Noncritical
|
||||
in.pass(14, "control", "canonical CONTROL_CBOR")
|
||||
|
||||
// Step 15: verify header_binding over the exact stored bytes.
|
||||
binding := HeaderBinding(st.prelude, in.PublicHeader)
|
||||
if !hmac.Equal(binding[:], control.HeaderBinding[:]) {
|
||||
return out, in.fail(15, "header binding", fmt.Errorf("capsule: header_binding does not match PRELUDE || PUBLIC_HEADER: %w", datekeys.ErrHeaderBinding))
|
||||
}
|
||||
in.pass(15, "header binding", "matches")
|
||||
|
||||
// Steps 16 and 17: recover I_PAYLOAD and open PAYLOAD_AGE with it.
|
||||
payloadID, err := agewrap.NewPayloadIdentity(control.PayloadIdentity[:])
|
||||
if err != nil {
|
||||
return out, in.fail(16, "payload identity", err)
|
||||
}
|
||||
in.pass(16, "payload identity", "I_PAYLOAD recovered")
|
||||
pr, err := age.Decrypt(st.payload, payloadID)
|
||||
if err != nil {
|
||||
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
|
||||
}
|
||||
if _, err := io.Copy(dst, pr); err != nil {
|
||||
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
|
||||
}
|
||||
|
||||
// Step 18: age completed without error.
|
||||
in.pass(18, "commit", "payload authenticated completely")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkAccessKey validates a .dkk against the capsule before it is used.
|
||||
func checkAccessKey(k *accesskey.AccessKey, h *Header, reg extension.Registry) error {
|
||||
if k.CapsuleID != h.CapsuleID {
|
||||
return fmt.Errorf("capsule: the .dkk is for capsule %x, this is %x: %w", k.CapsuleID, h.CapsuleID, datekeys.ErrAccessInvalid)
|
||||
}
|
||||
if err := extension.CheckCritical(k.Critical, reg); err != nil {
|
||||
return fmt.Errorf("capsule: .dkk: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCapsuleDigest compares the .dkk capsule_digest with SHA-256 of the
|
||||
// .dkc (spec §43), then repositions r at the payload. The digest is a fast
|
||||
// failure for a wrong file, not a security property.
|
||||
func checkCapsuleDigest(r io.ReadSeeker, start, payloadOffset int64, want []byte) (io.Reader, error) {
|
||||
if _, err := r.Seek(start, io.SeekStart); err != nil {
|
||||
return nil, fmt.Errorf("capsule: %w", err)
|
||||
}
|
||||
sum := sha256.New()
|
||||
if _, err := io.Copy(sum, r); err != nil {
|
||||
return nil, fmt.Errorf("capsule: %w", err)
|
||||
}
|
||||
if !hmac.Equal(sum.Sum(nil), want) {
|
||||
return nil, fmt.Errorf("capsule: the .dkk capsule_digest does not match this .dkc: %w", datekeys.ErrAccessInvalid)
|
||||
}
|
||||
if _, err := r.Seek(start+payloadOffset, io.SeekStart); err != nil {
|
||||
return nil, fmt.Errorf("capsule: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// decryptAll opens a bounded, in-memory age file. The plaintext is never
|
||||
// longer than the ciphertext.
|
||||
func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(ciphertext), id)
|
||||
if err != nil {
|
||||
return nil, classify("age", err)
|
||||
}
|
||||
out, err := io.ReadAll(io.LimitReader(r, int64(len(ciphertext))))
|
||||
if err != nil {
|
||||
clear(out)
|
||||
return nil, classify("age", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// classify keeps the normative error an identity returned from Unwrap, and
|
||||
// maps every other age failure (malformed header, bad header MAC, STREAM
|
||||
// authentication, truncation, trailing data) to ErrIntegrity.
|
||||
func classify(what string, err error) error {
|
||||
if datekeys.Code(err) != "" {
|
||||
return fmt.Errorf("capsule: %s: %w", what, err)
|
||||
}
|
||||
return fmt.Errorf("capsule: %s: %v: %w", what, err, datekeys.ErrIntegrity)
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// checkNew fails if path already exists.
|
||||
func checkNew(path string) error {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return fmt.Errorf("%s already exists; outputs are never overwritten", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAtomic stages the output in a private temporary file next to path and
|
||||
// publishes it only after fn succeeded (spec §56). Publication creates path
|
||||
// without replacing an existing file: a hard link where the file system
|
||||
// supports it, otherwise an exclusive create and copy. On any failure no
|
||||
// partial output remains.
|
||||
func writeAtomic(path string, fn func(io.Writer) error) (err error) {
|
||||
if err := checkNew(path); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".datekeys-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := tmp.Name()
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(name)
|
||||
}()
|
||||
if err := fn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Link(name, path); err == nil {
|
||||
return nil
|
||||
} else if errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("%s already exists; outputs are never overwritten", path)
|
||||
}
|
||||
return copyExclusive(name, path)
|
||||
}
|
||||
|
||||
func copyExclusive(from, to string) error {
|
||||
src, err := os.Open(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(dst, src)
|
||||
syncErr := dst.Sync()
|
||||
closeErr := dst.Close()
|
||||
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
|
||||
os.Remove(to)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,391 @@
|
||||
// Command datekeys encrypts, inspects and opens DateKeyCap (.dkc) files.
|
||||
//
|
||||
// datekeys encrypt -at 2030-01-01T00:00:00Z -in secret.txt -out secret.dkc
|
||||
// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk key.dkk -in secret.txt -out secret.dkc
|
||||
// datekeys inspect -in secret.dkc
|
||||
// datekeys decrypt -in secret.dkc -out secret.txt [-dkk key.dkk] [-identity key.txt]
|
||||
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
|
||||
// datekeys profile hash
|
||||
//
|
||||
// Encryption never touches the network. Decryption fetches the release from
|
||||
// public drand relays and verifies it locally. Outputs are written to a
|
||||
// temporary file in the destination directory and published only when
|
||||
// complete; existing files are never overwritten.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider/drand"
|
||||
)
|
||||
|
||||
const usage = `usage:
|
||||
datekeys encrypt -at TIME -in FILE -out FILE.dkc [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk]
|
||||
datekeys decrypt -in FILE.dkc -out FILE [-dkk FILE.dkk] [-identity FILE]... [-relay URL]...
|
||||
datekeys inspect -in FILE.dkc [-json]
|
||||
datekeys datekey resolve -at TIME
|
||||
datekeys profile hash [-in PROFILE.cbor]
|
||||
|
||||
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.`
|
||||
|
||||
// errUsage reports a malformed command line; main prints the usage text.
|
||||
var errUsage = errors.New("invalid command line; run 'datekeys help'")
|
||||
|
||||
// longHorizon is the product policy threshold for the harvest-now,
|
||||
// decrypt-later warning (spec §53).
|
||||
const longHorizon = 365 * 24 * time.Hour
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr, time.Now); err != nil {
|
||||
if errors.Is(err, errUsage) {
|
||||
fmt.Fprintln(os.Stderr, usage)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "datekeys:", err)
|
||||
if code := datekeys.Code(err); code != "" {
|
||||
fmt.Fprintln(os.Stderr, "datekeys: error code", code)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type multi []string
|
||||
|
||||
func (m *multi) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multi) Set(v string) error { *m = append(*m, v); return nil }
|
||||
|
||||
// run is the CLI; the clock is injected for tests (only the CLI reads the
|
||||
// wall clock).
|
||||
func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
|
||||
if len(args) == 0 {
|
||||
return errUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "encrypt":
|
||||
return encrypt(args[1:], stderr, now)
|
||||
case "decrypt":
|
||||
return decrypt(args[1:], stderr, now)
|
||||
case "inspect":
|
||||
return inspect(args[1:], stdout)
|
||||
case "datekey":
|
||||
if len(args) < 2 || args[1] != "resolve" {
|
||||
return errUsage
|
||||
}
|
||||
return resolve(args[2:], stdout)
|
||||
case "profile":
|
||||
if len(args) < 2 || args[1] != "hash" {
|
||||
return errUsage
|
||||
}
|
||||
return profileHash(args[2:], stdout)
|
||||
case "-h", "-help", "--help", "help":
|
||||
fmt.Fprintln(stdout, usage)
|
||||
return nil
|
||||
}
|
||||
return errUsage
|
||||
}
|
||||
|
||||
func newFlags(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
return fs
|
||||
}
|
||||
|
||||
func parse(fs *flag.FlagSet, args []string) error {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fmt.Errorf("%s: %w", fs.Name(), err)
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("%s: unexpected arguments %q", fs.Name(), fs.Args())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid -at %q: RFC 3339 with a time zone is required", s)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
|
||||
fs := newFlags("encrypt")
|
||||
at := fs.String("at", "", "unlock time, RFC 3339")
|
||||
in := fs.String("in", "", "plaintext file")
|
||||
out := fs.String("out", "", "new .dkc file; never overwritten")
|
||||
policy := fs.String("policy", "time_only", "time_only or time_and_key")
|
||||
dkk := fs.String("dkk", "", "time_and_key: new .dkk file for a portable access key")
|
||||
var recipients multi
|
||||
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := parseTime(*at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pol, err := capsule.ParsePolicy(*policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *in == "" || *out == "" {
|
||||
return errors.New("encrypt: -in and -out are required")
|
||||
}
|
||||
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Now: now}
|
||||
for _, r := range recipients {
|
||||
x, err := age.ParseX25519Recipient(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
opts.Recipients = append(opts.Recipients, x)
|
||||
}
|
||||
if *dkk != "" {
|
||||
if err := checkNew(*dkk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
src, err := os.Open(*in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
var res *capsule.Result
|
||||
err = writeAtomic(*out, func(w io.Writer) error {
|
||||
res, err = capsule.Encrypt(w, src, opts)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.PortableKey != nil {
|
||||
defer res.PortableKey.Wipe()
|
||||
if err := writeAtomic(*dkk, func(w io.Writer) error { return accesskey.Encode(w, res.PortableKey) }); err != nil {
|
||||
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n",
|
||||
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID)
|
||||
if res.PortableKey != nil {
|
||||
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
|
||||
}
|
||||
if res.UnlockAt.Sub(now()) > longHorizon {
|
||||
fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+
|
||||
" and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decrypt(args []string, stderr io.Writer, now func() time.Time) error {
|
||||
fs := newFlags("decrypt")
|
||||
in := fs.String("in", "", ".dkc file")
|
||||
out := fs.String("out", "", "new plaintext file; never overwritten")
|
||||
dkk := fs.String("dkk", "", "portable access key (.dkk)")
|
||||
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
|
||||
var identities, relays multi
|
||||
fs.Var(&identities, "identity", "age identity file with X25519 keys (repeatable)")
|
||||
fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *in == "" || *out == "" {
|
||||
return errors.New("decrypt: -in and -out are required")
|
||||
}
|
||||
reg, err := profile.Default()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now}
|
||||
for _, path := range identities {
|
||||
ids, err := readIdentities(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Identities = append(opts.Identities, ids...)
|
||||
}
|
||||
if *dkk != "" {
|
||||
f, err := os.Open(*dkk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := accesskey.Decode(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Wipe()
|
||||
opts.AccessKey = k
|
||||
}
|
||||
src, err := os.Open(*in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
var opened *capsule.Opened
|
||||
err = writeAtomic(*out, func(w io.Writer) error {
|
||||
opened, err = capsule.Open(ctx, w, src, opts)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
|
||||
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
func readIdentities(path string) ([]age.Identity, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
ids, err := age.ParseIdentities(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type inspectView struct {
|
||||
File string `json:"file"`
|
||||
CapsuleID string `json:"capsule_id,omitempty"`
|
||||
DateKey string `json:"datekey,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Round uint64 `json:"round,omitempty"`
|
||||
UnlockAt string `json:"unlock_at,omitempty"`
|
||||
AccessPolicy string `json:"access_policy,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Checks []capsule.CheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
// inspect runs steps 1 to 8 only: it never requests a release and never uses
|
||||
// a secret.
|
||||
func inspect(args []string, stdout io.Writer) error {
|
||||
fs := newFlags("inspect")
|
||||
in := fs.String("in", "", ".dkc file")
|
||||
asJSON := fs.Bool("json", false, "JSON output")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *in == "" {
|
||||
return errors.New("inspect: -in is required")
|
||||
}
|
||||
reg, err := profile.Default()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(*in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
result, inspectErr := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
|
||||
v := inspectView{File: *in, Valid: inspectErr == nil, Error: datekeys.Code(inspectErr), Checks: result.Checks}
|
||||
if h := result.Header; h != nil {
|
||||
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
|
||||
}
|
||||
if !result.UnlockAt.IsZero() {
|
||||
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
|
||||
}
|
||||
if *asJSON {
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "%s\n", v.File)
|
||||
for _, c := range v.Checks {
|
||||
mark := "ok "
|
||||
if !c.OK {
|
||||
mark = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(stdout, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
|
||||
}
|
||||
if v.Valid {
|
||||
fmt.Fprintf(stdout, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
|
||||
}
|
||||
}
|
||||
return inspectErr
|
||||
}
|
||||
|
||||
type resolveView struct {
|
||||
DateKey string `json:"datekey"`
|
||||
Profile string `json:"profile"`
|
||||
Round uint64 `json:"round"`
|
||||
Requested string `json:"requested"`
|
||||
UnlockAt string `json:"unlock_at"`
|
||||
}
|
||||
|
||||
func resolve(args []string, stdout io.Writer) error {
|
||||
fs := newFlags("datekey resolve")
|
||||
at := fs.String("at", "", "instant, RFC 3339")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := parseTime(*at)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := profile.Quicknet()
|
||||
d, err := datekey.Resolve(p, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(stdout).Encode(resolveView{
|
||||
DateKey: d.Compact(), Profile: d.ProfileID, Round: d.Round,
|
||||
Requested: t.Format(time.RFC3339Nano), UnlockAt: d.UnlockAt(p).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func profileHash(args []string, stdout io.Writer) error {
|
||||
fs := newFlags("profile hash")
|
||||
in := fs.String("in", "", "Deterministic CBOR profile file; default: the pinned Quicknet profile")
|
||||
if err := parse(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
p := profile.Quicknet()
|
||||
if *in != "" {
|
||||
b, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p, err = profile.Decode(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b, err := p.CanonicalCBOR()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := p.Hash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pinned := *in == "" || hex.EncodeToString(h[:]) == profile.QuicknetProfileHash
|
||||
return json.NewEncoder(stdout).Encode(map[string]any{
|
||||
"profile_id": p.ID,
|
||||
"profile_hash": hex.EncodeToString(h[:]),
|
||||
"canonical_cbor": hex.EncodeToString(b),
|
||||
"pinned": pinned,
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
const fixtures = "../../testdata/fixtures"
|
||||
|
||||
// relay serves the known Quicknet releases like a drand HTTP relay.
|
||||
func relay(t *testing.T) string {
|
||||
t.Helper()
|
||||
p := profile.Quicknet()
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, round := range testkit.Rounds {
|
||||
if r.URL.Path == fmt.Sprintf("/v2/chains/%s/rounds/%d", p.ChainHashHex(), round) {
|
||||
fmt.Fprintf(w, `{"round":%d,"signature":"%s"}`, round, hex.EncodeToString(testkit.Release(round).Signature))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s.URL
|
||||
}
|
||||
|
||||
func cli(t *testing.T, now time.Time, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
err := run(args, &out, &errOut, func() time.Time { return now })
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
var later = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestOutputNotPublishedOnFailureOrOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "output")
|
||||
err := writeAtomic(out, func(w io.Writer) error {
|
||||
_, _ = w.Write([]byte("partial plaintext"))
|
||||
return errors.New("invalid authentication tag")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if _, err := os.Stat(out); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("published partial output")
|
||||
}
|
||||
if err := os.WriteFile(out, []byte("keep me"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeAtomic(out, func(io.Writer) error { t.Fatal("should not run"); return nil }); err == nil {
|
||||
t.Fatal("overwrote output")
|
||||
}
|
||||
if b, _ := os.ReadFile(out); string(b) != "keep me" {
|
||||
t.Fatal("output changed")
|
||||
}
|
||||
if files, _ := filepath.Glob(filepath.Join(dir, ".datekeys-*")); len(files) != 0 {
|
||||
t.Fatal("temporary file left behind")
|
||||
}
|
||||
if err := copyExclusive(out, out); err == nil {
|
||||
t.Fatal("exclusive copy replaced a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptFixtures(t *testing.T) {
|
||||
url := relay(t)
|
||||
for _, tc := range []struct{ name, dkk string }{
|
||||
{"time_only", ""},
|
||||
{"time_only_extensions", ""},
|
||||
{"empty_payload", ""},
|
||||
{"time_and_key_portable", "time_and_key_portable.dkk"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "plain")
|
||||
args := []string{"decrypt", "-in", filepath.Join(fixtures, tc.name+".dkc"), "-out", out, "-relay", url}
|
||||
if tc.dkk != "" {
|
||||
args = append(args, "-dkk", filepath.Join(fixtures, tc.dkk))
|
||||
}
|
||||
if _, stderr, err := cli(t, later, args...); err != nil {
|
||||
t.Fatalf("%v\n%s", err, stderr)
|
||||
}
|
||||
got, _ := os.ReadFile(out)
|
||||
want, _ := os.ReadFile(filepath.Join(fixtures, tc.name+".plaintext"))
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("plaintext differs")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithIdentityFile(t *testing.T) {
|
||||
var f testkit.DKCFixture
|
||||
if err := testkit.ReadJSON(filepath.Join(fixtures, "time_and_key_recipients.json"), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
key := filepath.Join(dir, "key.txt")
|
||||
os.WriteFile(key, []byte("# test identity\n"+f.Identities[1]+"\n"), 0o600)
|
||||
out := filepath.Join(dir, "plain")
|
||||
if _, stderr, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, f.File), "-out", out, "-identity", key, "-relay", relay(t)); err != nil {
|
||||
t.Fatalf("%v\n%s", err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptFailuresLeaveNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
|
||||
bad := filepath.Join(dir, "bad.dkc")
|
||||
b[len(b)-1] ^= 1
|
||||
os.WriteFile(bad, b, 0o600)
|
||||
out := filepath.Join(dir, "plain")
|
||||
_, _, err := cli(t, later, "decrypt", "-in", bad, "-out", out, "-relay", relay(t))
|
||||
if !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Fatalf("left files behind: %v", entries)
|
||||
}
|
||||
// time_and_key without credentials fails before contacting any relay.
|
||||
_, _, err = cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "time_and_key_portable.dkc"), "-out", out, "-relay", "http://127.0.0.1:1")
|
||||
if !errors.Is(err, datekeys.ErrAccessRequired) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "secret.txt")
|
||||
os.WriteFile(in, []byte("round trip through the CLI"), 0o600)
|
||||
p := profile.Quicknet()
|
||||
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
|
||||
genesis := time.Unix(p.GenesisTime, 0)
|
||||
x, _ := age.GenerateX25519Identity()
|
||||
key := filepath.Join(dir, "x.txt")
|
||||
os.WriteFile(key, []byte(x.String()+"\n"), 0o600)
|
||||
|
||||
dkc, dkk := filepath.Join(dir, "s.dkc"), filepath.Join(dir, "s.dkk")
|
||||
_, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc,
|
||||
"-policy", "time_and_key", "-recipient", x.Recipient().String(), "-dkk", dkk)
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "round 1000") || strings.Contains(stderr, "not post-quantum") {
|
||||
t.Fatalf("unexpected report:\n%s", stderr)
|
||||
}
|
||||
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc); err == nil {
|
||||
t.Fatal("overwrote an existing capsule")
|
||||
}
|
||||
|
||||
stdout, _, err := cli(t, later, "inspect", "-in", dkc, "-json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v inspectView
|
||||
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" {
|
||||
t.Fatalf("inspect: %+v %v", v, err)
|
||||
}
|
||||
for i, extra := range [][]string{{"-dkk", dkk}, {"-identity", key}} {
|
||||
out := filepath.Join(dir, fmt.Sprintf("out%d", i))
|
||||
args := append([]string{"decrypt", "-in", dkc, "-out", out, "-relay", relay(t)}, extra...)
|
||||
if _, stderr, err := cli(t, later, args...); err != nil {
|
||||
t.Fatalf("%v\n%s", err, stderr)
|
||||
}
|
||||
if b, _ := os.ReadFile(out); string(b) != "round trip through the CLI" {
|
||||
t.Fatal("plaintext differs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectReportsFailures(t *testing.T) {
|
||||
b, _ := os.ReadFile(filepath.Join(fixtures, "time_only.dkc"))
|
||||
b = bytes.Replace(b, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1)
|
||||
path := filepath.Join(t.TempDir(), "bad.dkc")
|
||||
os.WriteFile(path, b, 0o600)
|
||||
stdout, _, err := cli(t, later, "inspect", "-in", path)
|
||||
if !errors.Is(err, datekeys.ErrRoundMismatch) || !strings.Contains(stdout, "[FAIL] step 8") {
|
||||
t.Fatalf("%v\n%s", err, stdout)
|
||||
}
|
||||
stdout, _, err = cli(t, later, "inspect", "-in", filepath.Join(fixtures, "time_only.dkc"))
|
||||
if err != nil || !strings.Contains(stdout, "valid before unlock") {
|
||||
t.Fatalf("%v\n%s", err, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndProfile(t *testing.T) {
|
||||
stdout, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01T00:00:00Z")
|
||||
if err != nil || !strings.Contains(stdout, `"round":66884212`) || !strings.Contains(stdout, `"unlock_at":"2030-01-01T00:00:00Z"`) {
|
||||
t.Fatalf("%v %s", err, stdout)
|
||||
}
|
||||
if _, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01 00:00"); err == nil {
|
||||
t.Fatal("accepted a time without zone")
|
||||
}
|
||||
stdout, _, err = cli(t, later, "profile", "hash")
|
||||
if err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) || !strings.Contains(stdout, `"pinned":true`) {
|
||||
t.Fatalf("%v %s", err, stdout)
|
||||
}
|
||||
b, _ := profile.Quicknet().CanonicalCBOR()
|
||||
path := filepath.Join(t.TempDir(), "q.cbor")
|
||||
os.WriteFile(path, b, 0o600)
|
||||
if stdout, _, err = cli(t, later, "profile", "hash", "-in", path); err != nil || !strings.Contains(stdout, profile.QuicknetProfileHash) {
|
||||
t.Fatalf("%v %s", err, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsage(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"nope"}, {"datekey"}, {"profile", "x"}, {"encrypt", "-bogus"}, {"inspect", "extra"}} {
|
||||
if _, _, err := cli(t, later, args...); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spec §53: long horizons get the harvest-now, decrypt-later warning.
|
||||
func TestLongHorizonWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in")
|
||||
os.WriteFile(in, []byte("x"), 0o600)
|
||||
for i, tc := range []struct {
|
||||
after time.Duration
|
||||
warn bool
|
||||
}{{time.Hour, false}, {2 * 365 * 24 * time.Hour, true}} {
|
||||
out := filepath.Join(dir, fmt.Sprintf("%d.dkc", i))
|
||||
_, stderr, err := cli(t, later, "encrypt", "-at", later.Add(tc.after).Format(time.RFC3339), "-in", in, "-out", out)
|
||||
if err != nil || strings.Contains(stderr, "not post-quantum") != tc.warn {
|
||||
t.Fatalf("%s: %v: %s", tc.after, err, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
// Copyright (c) 2017 Takatoshi Nakagawa
|
||||
// Copyright (c) 2019 The age Authors
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
// Package bech32 is a modified version of the reference implementation of BIP173.
|
||||
//
|
||||
// Provenance: copied verbatim from filippo.io/age v1.3.2, internal/bech32,
|
||||
// under the license above, so that raw X25519 keys stored in .dkk files
|
||||
// (spec §38) can be converted to and from the Bech32 forms accepted by the
|
||||
// public age API without diverging from age. This is an encoding, not
|
||||
// cryptography. Do not modify; resynchronise with upstream instead.
|
||||
package bech32
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
var generator = []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
||||
|
||||
func polymod(values []byte) uint32 {
|
||||
chk := uint32(1)
|
||||
for _, v := range values {
|
||||
top := chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5
|
||||
chk = chk ^ uint32(v)
|
||||
for i := range 5 {
|
||||
bit := top >> i & 1
|
||||
if bit == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
func hrpExpand(hrp string) []byte {
|
||||
h := []byte(strings.ToLower(hrp))
|
||||
var ret []byte
|
||||
for _, c := range h {
|
||||
ret = append(ret, c>>5)
|
||||
}
|
||||
ret = append(ret, 0)
|
||||
for _, c := range h {
|
||||
ret = append(ret, c&31)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func verifyChecksum(hrp string, data []byte) bool {
|
||||
return polymod(append(hrpExpand(hrp), data...)) == 1
|
||||
}
|
||||
|
||||
func createChecksum(hrp string, data []byte) []byte {
|
||||
values := append(hrpExpand(hrp), data...)
|
||||
values = append(values, []byte{0, 0, 0, 0, 0, 0}...)
|
||||
mod := polymod(values) ^ 1
|
||||
ret := make([]byte, 6)
|
||||
for p := range ret {
|
||||
shift := 5 * (5 - p)
|
||||
ret[p] = byte(mod>>shift) & 31
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func convertBits(data []byte, frombits, tobits byte, pad bool) ([]byte, error) {
|
||||
var ret []byte
|
||||
acc := uint32(0)
|
||||
bits := byte(0)
|
||||
maxv := byte(1<<tobits - 1)
|
||||
for idx, value := range data {
|
||||
if value>>frombits != 0 {
|
||||
return nil, fmt.Errorf("invalid data range: data[%d]=%d (frombits=%d)", idx, value, frombits)
|
||||
}
|
||||
acc = acc<<frombits | uint32(value)
|
||||
bits += frombits
|
||||
for bits >= tobits {
|
||||
bits -= tobits
|
||||
ret = append(ret, byte(acc>>bits)&maxv)
|
||||
}
|
||||
}
|
||||
if pad {
|
||||
if bits > 0 {
|
||||
ret = append(ret, byte(acc<<(tobits-bits))&maxv)
|
||||
}
|
||||
} else if bits >= frombits {
|
||||
return nil, fmt.Errorf("illegal zero padding")
|
||||
} else if byte(acc<<(tobits-bits))&maxv != 0 {
|
||||
return nil, fmt.Errorf("non-zero padding")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// Encode encodes the HRP and a bytes slice to Bech32. If the HRP is uppercase,
|
||||
// the output will be uppercase.
|
||||
func Encode(hrp string, data []byte) (string, error) {
|
||||
values, err := convertBits(data, 8, 5, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(hrp) < 1 {
|
||||
return "", fmt.Errorf("invalid HRP: %q", hrp)
|
||||
}
|
||||
for p, c := range hrp {
|
||||
if c < 33 || c > 126 {
|
||||
return "", fmt.Errorf("invalid HRP character: hrp[%d]=%d", p, c)
|
||||
}
|
||||
}
|
||||
if strings.ToUpper(hrp) != hrp && strings.ToLower(hrp) != hrp {
|
||||
return "", fmt.Errorf("mixed case HRP: %q", hrp)
|
||||
}
|
||||
lower := strings.ToLower(hrp) == hrp
|
||||
hrp = strings.ToLower(hrp)
|
||||
var ret strings.Builder
|
||||
ret.WriteString(hrp)
|
||||
ret.WriteString("1")
|
||||
for _, p := range values {
|
||||
ret.WriteByte(charset[p])
|
||||
}
|
||||
for _, p := range createChecksum(hrp, values) {
|
||||
ret.WriteByte(charset[p])
|
||||
}
|
||||
if lower {
|
||||
return ret.String(), nil
|
||||
}
|
||||
return strings.ToUpper(ret.String()), nil
|
||||
}
|
||||
|
||||
// Decode decodes a Bech32 string. If the string is uppercase, the HRP will be uppercase.
|
||||
func Decode(s string) (hrp string, data []byte, err error) {
|
||||
if strings.ToLower(s) != s && strings.ToUpper(s) != s {
|
||||
return "", nil, fmt.Errorf("mixed case")
|
||||
}
|
||||
pos := strings.LastIndex(s, "1")
|
||||
if pos < 1 || pos+7 > len(s) {
|
||||
return "", nil, fmt.Errorf("separator '1' at invalid position: pos=%d, len=%d", pos, len(s))
|
||||
}
|
||||
hrp = s[:pos]
|
||||
for p, c := range hrp {
|
||||
if c < 33 || c > 126 {
|
||||
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
|
||||
}
|
||||
}
|
||||
for p, c := range s[pos+1:] {
|
||||
// Fold ASCII explicitly. Unicode case folding can turn a non-ASCII
|
||||
// rune into a shorter valid charset member.
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
d := strings.IndexRune(charset, c)
|
||||
if d == -1 {
|
||||
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
|
||||
}
|
||||
data = append(data, byte(d))
|
||||
}
|
||||
if len(data) < 6 {
|
||||
return "", nil, fmt.Errorf("data part too short")
|
||||
}
|
||||
if !verifyChecksum(hrp, data) {
|
||||
return "", nil, fmt.Errorf("invalid checksum")
|
||||
}
|
||||
data, err = convertBits(data[:len(data)-6], 5, 8, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return hrp, data, nil
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2013-2017 The btcsuite developers
|
||||
// Copyright (c) 2016-2017 The Lightning Network Developers
|
||||
// Copyright (c) 2019 The age Authors
|
||||
//
|
||||
// Permission to use, copy, modify, and distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package bech32_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/datekeys/datekeys-go/codec/bech32"
|
||||
)
|
||||
|
||||
func TestBech32(t *testing.T) {
|
||||
tests := []struct {
|
||||
str string
|
||||
valid bool
|
||||
}{
|
||||
{"A12UEL5L", true}, // empty
|
||||
{"a12uel5l", true},
|
||||
{"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", true},
|
||||
{"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", true},
|
||||
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", true},
|
||||
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", true},
|
||||
|
||||
// invalid checksum
|
||||
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", false},
|
||||
// invalid character (space) in hrp
|
||||
{"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", false},
|
||||
{"split1cheo2y9e2w", false}, // invalid character (o) in data part
|
||||
{"split1a2y9w", false}, // too short data part
|
||||
{"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp
|
||||
// invalid character (DEL) in hrp
|
||||
{"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false},
|
||||
|
||||
// long vectors that we do accept despite the spec, see Issue 453
|
||||
{"long10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qfcsvr0", true},
|
||||
{"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", true},
|
||||
|
||||
// BIP 173 invalid vectors.
|
||||
{"pzry9x0s0muk", false},
|
||||
{"1pzry9x0s0muk", false},
|
||||
{"x1b4n0q5v", false},
|
||||
{"li1dgmt3", false},
|
||||
{"de1lg7wt\xff", false},
|
||||
{"A1G7SGD8", false},
|
||||
{"10a06t8", false},
|
||||
{"1qzzfhee", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
str := test.str
|
||||
hrp, decoded, err := bech32.Decode(str)
|
||||
if !test.valid {
|
||||
// Invalid string decoding should result in error.
|
||||
if err == nil {
|
||||
t.Errorf("expected decoding to fail for invalid string %v", test.str)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Valid string decoding should result in no error.
|
||||
if err != nil {
|
||||
t.Errorf("expected string to be valid bech32: %v", err)
|
||||
}
|
||||
|
||||
// Check that it encodes to the same string.
|
||||
encoded, err := bech32.Encode(hrp, decoded)
|
||||
if err != nil {
|
||||
t.Errorf("encoding failed: %v", err)
|
||||
}
|
||||
if encoded != str {
|
||||
t.Errorf("expected data to encode to %v, but got %v", str, encoded)
|
||||
}
|
||||
|
||||
// Flip a bit in the string an make sure it is caught.
|
||||
pos := strings.LastIndexAny(str, "1")
|
||||
flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:]
|
||||
if _, _, err = bech32.Decode(flipped); err == nil {
|
||||
t.Error("expected decoding to fail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeShortDataPart(t *testing.T) {
|
||||
kelvin := string(rune(0x212A))
|
||||
for _, s := range []string{
|
||||
"AA3100AC" + kelvin,
|
||||
"BK1" + kelvin + "0JFM",
|
||||
"AQM1KZCML",
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Decode(%+q) panicked: %v", s, r)
|
||||
}
|
||||
}()
|
||||
if _, _, err := bech32.Decode(s); err == nil {
|
||||
t.Errorf("Decode(%+q) = nil error, want error", s)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
// Package codec implements the Deterministic CBOR rules of spec §58 and §58.1.
|
||||
//
|
||||
// Encoding uses RFC 8949 §4.2.1 Core Deterministic Encoding. Decoding is
|
||||
// strict (no indefinite lengths, no tags, no duplicate keys, bounded depth and
|
||||
// sizes, valid UTF-8, no unknown struct fields) and is always followed by a
|
||||
// re-encoding that must reproduce the input byte for byte. Any difference is
|
||||
// ErrNonCanonicalCBOR. The same principle as dk1_ canonicality (spec §19):
|
||||
// canonicality does not depend on a library promising to reject every
|
||||
// non-canonical form.
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
)
|
||||
|
||||
// Decoding limits. Structural sizes are additionally bounded by the framing
|
||||
// limits of spec §57 before any CBOR is decoded.
|
||||
const (
|
||||
MaxNestedLevels = 16
|
||||
MaxArrayElements = 65536
|
||||
MaxMapPairs = 65536
|
||||
)
|
||||
|
||||
var (
|
||||
encMode = must(cbor.CoreDetEncOptions().EncMode())
|
||||
decMode = must(decOptions(true).DecMode())
|
||||
peekMode = must(decOptions(false).DecMode())
|
||||
)
|
||||
|
||||
// decOptions returns the strict decoding options. Peek mode ignores unknown
|
||||
// map keys; the canonical mode reports them.
|
||||
func decOptions(strict bool) cbor.DecOptions {
|
||||
o := cbor.DecOptions{
|
||||
DupMapKey: cbor.DupMapKeyEnforcedAPF,
|
||||
IndefLength: cbor.IndefLengthForbidden,
|
||||
TagsMd: cbor.TagsForbidden,
|
||||
MaxNestedLevels: MaxNestedLevels,
|
||||
MaxArrayElements: MaxArrayElements,
|
||||
MaxMapPairs: MaxMapPairs,
|
||||
UTF8: cbor.UTF8RejectInvalid,
|
||||
MapKeyByteString: cbor.MapKeyByteStringAllowed,
|
||||
}
|
||||
if strict {
|
||||
o.ExtraReturnErrors = cbor.ExtraDecErrorUnknownField
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// must accepts only the static options above, which cannot be invalid.
|
||||
func must[T any](m T, err error) T {
|
||||
if err != nil {
|
||||
panic("codec: invalid static options: " + err.Error())
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Marshal returns the core deterministic CBOR encoding of v.
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
b, err := encMode.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codec: encode: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Unmarshal decodes exactly one CBOR data item from data into v, which must be
|
||||
// a pointer, and then requires that re-encoding v reproduces data exactly.
|
||||
// Every failure wraps datekeys.ErrNonCanonicalCBOR.
|
||||
//
|
||||
// Fields of type cbor.RawMessage are copied verbatim and are NOT covered by the
|
||||
// re-encoding check; callers must validate them with Valid.
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
if err := decMode.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
if re, err := encMode.Marshal(v); err != nil || !bytes.Equal(re, data) {
|
||||
return fmt.Errorf("codec: input is not the deterministic encoding of its value: %w", datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Peek decodes selected fields of a CBOR map, ignoring every other key and
|
||||
// without the canonicality check. It exists only to read a type tag and a
|
||||
// schema version before strict decoding, so that an unknown major version is
|
||||
// reported as such (spec §70). Its result must never be used as the decoded
|
||||
// object.
|
||||
func Peek(data []byte, v any) error {
|
||||
if err := peekMode.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSchema reads key 0 (type tag) and key 1 (schema version) of a CBOR map
|
||||
// and requires the expected values. A different type tag is
|
||||
// ErrNonCanonicalCBOR; a different version is ErrUnsupportedVersion.
|
||||
func CheckSchema(data []byte, typeTag string, version uint64) error {
|
||||
var h struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
}
|
||||
if err := Peek(data, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.Type != typeTag {
|
||||
return fmt.Errorf("codec: type %q, want %q: %w", h.Type, typeTag, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
if h.Version != version {
|
||||
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, h.Version, version, datekeys.ErrUnsupportedVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Valid reports whether data is exactly one well-formed CBOR data item in core
|
||||
// deterministic encoding. It is used for opaque values the protocol does not
|
||||
// interpret, such as extension data (spec §54). Tags, the simple value
|
||||
// undefined and map keys that are arrays or maps are rejected.
|
||||
func Valid(data []byte) error {
|
||||
var v any
|
||||
return Unmarshal(data, &v)
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package codec_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
)
|
||||
|
||||
type sample struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
N uint64 `cbor:"1,keyasint"`
|
||||
Bytes []byte `cbor:"2,keyasint"`
|
||||
List []uint64 `cbor:"10,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
func mustHex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMarshalIsCoreDeterministic(t *testing.T) {
|
||||
b, err := codec.Marshal(sample{Type: "x", N: 23, Bytes: []byte{1}, List: []uint64{1, 500}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// {0: "x", 1: 23, 2: h'01', 10: [1, 500]} with keys sorted and shortest integers.
|
||||
if got, want := hex.EncodeToString(b), "a400617801170241010a82011901f4"; got != want {
|
||||
t.Fatalf("got %s, want %s", got, want)
|
||||
}
|
||||
var s sample
|
||||
if err := codec.Unmarshal(b, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalRejectsNonCanonical(t *testing.T) {
|
||||
for _, tc := range []struct{ name, hex string }{
|
||||
{"integer not in shortest form", "a300617801181702410" + "1"},
|
||||
{"keys out of order", "a301170061780241" + "01"},
|
||||
{"duplicate key", "a4006178006179011702" + "4101"},
|
||||
{"indefinite-length map", "bf00617801170241" + "01ff"},
|
||||
{"indefinite-length byte string", "a3006178011702" + "5f4101ff"},
|
||||
{"tag", "a3006178011702" + "c24101"},
|
||||
{"unknown key", "a4006178011702410103" + "00"},
|
||||
{"missing key", "a2006178011" + "7"},
|
||||
{"trailing byte", "a30061780117024101" + "00"},
|
||||
{"invalid UTF-8", "a30061ff0117024101"},
|
||||
{"empty optional array present", "a400617801170241010a" + "80"},
|
||||
{"wrong type", "a300617801617a024101"},
|
||||
{"not a map", "83006178" + "01"},
|
||||
{"empty input", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var s sample
|
||||
err := codec.Unmarshal(mustHex(t, tc.hex), &s)
|
||||
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("got %v, want ErrNonCanonicalCBOR", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
for _, h := range []string{
|
||||
"00", "17", "1818", "20", "3bffffffffffffffff", "40", "60", "80", "a0", "f4", "f5", "f6",
|
||||
"f97e00", "f93c00", "fa47c35000", "a2016161026162", "a1416101", "8201820203",
|
||||
} {
|
||||
if err := codec.Valid(mustHex(t, h)); err != nil {
|
||||
t.Errorf("%s rejected: %v", h, err)
|
||||
}
|
||||
}
|
||||
for _, h := range []string{
|
||||
"1817", // 23 encoded in two bytes
|
||||
"f7", // undefined
|
||||
"fb3ff0000000000000", // 1.0 as float64 instead of float16
|
||||
"fa7fc00000", // NaN not in the canonical f97e00 form
|
||||
"a2026162016161", // keys out of order
|
||||
"c101", // tag
|
||||
"9f01ff", // indefinite-length array
|
||||
"a1810101", // array as map key
|
||||
"0000", // two items
|
||||
strings.Repeat("81", codec.MaxNestedLevels+4) + "00", // nesting beyond the limit
|
||||
} {
|
||||
if err := codec.Valid(mustHex(t, h)); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s accepted or wrong error: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSchema(t *testing.T) {
|
||||
b, _ := codec.Marshal(sample{Type: "datekeycap", N: 1, Bytes: []byte{}})
|
||||
if err := codec.CheckSchema(b, "datekeycap", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := codec.CheckSchema(b, "datekeys-control", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("type confusion: %v", err)
|
||||
}
|
||||
if err := codec.CheckSchema(b, "datekeycap", 2); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
|
||||
t.Fatalf("version: %v", err)
|
||||
}
|
||||
// A future version with unknown keys still reports the version.
|
||||
future, _ := codec.Marshal(map[uint64]any{0: "datekeycap", 1: uint64(2), 99: "new"})
|
||||
if err := codec.CheckSchema(future, "datekeycap", 1); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
|
||||
t.Fatalf("future version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripProperty(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(1, 2))
|
||||
for range 2000 {
|
||||
s := sample{Type: string(rune('a' + r.IntN(26))), N: r.Uint64() >> r.IntN(64), Bytes: make([]byte, r.IntN(40))}
|
||||
for range r.IntN(4) {
|
||||
s.List = append(s.List, r.Uint64()>>r.IntN(64))
|
||||
}
|
||||
b, err := codec.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got sample
|
||||
if err := codec.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("%x: %v", b, err)
|
||||
}
|
||||
b2, _ := codec.Marshal(got)
|
||||
if string(b) != string(b2) {
|
||||
t.Fatal("encoding is not stable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsCarryTheNormativeCode(t *testing.T) {
|
||||
if _, err := codec.Marshal(make(chan int)); err == nil {
|
||||
t.Fatal("encoded a channel")
|
||||
}
|
||||
for _, in := range [][]byte{nil, {0xff}, {0x83, 0x01}, mustHex(t, "a10061")} {
|
||||
if err := codec.CheckSchema(in, "datekeycap", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("%x: %v", in, err)
|
||||
}
|
||||
var v struct {
|
||||
A uint64 `cbor:"0,keyasint"`
|
||||
}
|
||||
if err := codec.Peek(in, &v); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("peek %x: %v", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzValid(f *testing.F) {
|
||||
for _, h := range []string{"a400617801170241010a82011901f4", "f97e00", "a2016161026162", "9f01ff"} {
|
||||
b, _ := hex.DecodeString(h)
|
||||
f.Add(b)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, b []byte) {
|
||||
if codec.Valid(b) != nil {
|
||||
return
|
||||
}
|
||||
var v any
|
||||
if err := codec.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("Valid accepted what Unmarshal rejects: %v", err)
|
||||
}
|
||||
re, err := codec.Marshal(v)
|
||||
if err != nil || string(re) != string(b) {
|
||||
t.Fatalf("accepted a non-canonical item %x", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
// Package datekey implements DateKeys (spec §14-§19): the local resolution of
|
||||
// an instant to a provider condition and the canonical dk1_ representation.
|
||||
//
|
||||
// A DateKey is public. It is not a symmetric key, not a private key, not a
|
||||
// .dkk and not a secret (spec §14).
|
||||
package datekey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// Prefix and JSON version of the V1 representation (spec §18).
|
||||
const (
|
||||
Prefix = "dk1_"
|
||||
Version = 1
|
||||
)
|
||||
|
||||
// MaxRound is the largest round accepted in a dk1_ string, 2^53-1, so that
|
||||
// every implementation, including JSON parsers that use IEEE 754 doubles,
|
||||
// reads the same integer. Profiles impose a lower bound through
|
||||
// profile.Profile.MaxRound.
|
||||
const MaxRound = 1<<53 - 1
|
||||
|
||||
// MaxEncodedLen bounds the input accepted by Parse, checked before decoding.
|
||||
const MaxEncodedLen = 256
|
||||
|
||||
// DateKey is the public descriptor of a time condition: a profile and, for
|
||||
// Quicknet, a round (spec §9, §14).
|
||||
type DateKey struct {
|
||||
ProfileID string
|
||||
Round uint64
|
||||
}
|
||||
|
||||
// Resolve returns the DateKey of the first round whose round time is at or
|
||||
// after at (spec §15). The comparison uses the full precision of at: an
|
||||
// instant one nanosecond after a round boundary resolves to the next round.
|
||||
// Rounding backwards never happens.
|
||||
//
|
||||
// Resolve needs no network. It accepts past instants, which is useful for
|
||||
// lookups; callers creating new capsules must require a future instant.
|
||||
func Resolve(p *profile.Profile, at time.Time) (DateKey, error) {
|
||||
period := int64(p.Period / time.Second)
|
||||
if period <= 0 || p.Period%time.Second != 0 {
|
||||
return DateKey{}, fmt.Errorf("datekey: profile %s has no whole-second period: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
secs := at.Unix()
|
||||
if secs < p.GenesisTime {
|
||||
return DateKey{}, fmt.Errorf("datekey: %s is before the genesis of %s: %w",
|
||||
at.UTC().Format(time.RFC3339Nano), p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
if secs > profile.MaxUnixTime {
|
||||
return DateKey{}, fmt.Errorf("datekey: %s is after 9999-12-31T23:59:59Z: %w",
|
||||
at.UTC().Format(time.RFC3339Nano), datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
delta := secs - p.GenesisTime
|
||||
// candidate = floor((timestamp - genesis_time) / period) + 1
|
||||
candidate := uint64(delta/period) + 1
|
||||
// if round_time(candidate) < requested_unlock_at: candidate++
|
||||
// round_time(candidate) is a whole second <= secs, so it is earlier than at
|
||||
// unless it equals secs and at has no fractional part.
|
||||
if delta%period != 0 || at.Nanosecond() != 0 {
|
||||
candidate++
|
||||
}
|
||||
d := DateKey{ProfileID: p.ID, Round: candidate}
|
||||
if err := d.Validate(p); err != nil {
|
||||
return DateKey{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// RoundTime returns round_time(r) = genesis_time + (r - 1) * period (spec §15).
|
||||
func RoundTime(p *profile.Profile, round uint64) (time.Time, error) {
|
||||
if round == 0 || round > p.MaxRound() {
|
||||
return time.Time{}, fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
period := int64(p.Period / time.Second)
|
||||
return time.Unix(p.GenesisTime+int64(round-1)*period, 0).UTC(), nil
|
||||
}
|
||||
|
||||
// Validate checks that d belongs to p and that its round is in p's range.
|
||||
func (d DateKey) Validate(p *profile.Profile) error {
|
||||
if d.ProfileID != p.ID {
|
||||
return fmt.Errorf("datekey: profile %q, expected %q: %w", d.ProfileID, p.ID, datekeys.ErrProfileMismatch)
|
||||
}
|
||||
if d.Round == 0 || d.Round > p.MaxRound() || d.Round > MaxRound {
|
||||
return fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", d.Round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockAt returns the effective unlock time of d under p, or the zero time if
|
||||
// d is not valid for p.
|
||||
func (d DateKey) UnlockAt(p *profile.Profile) time.Time {
|
||||
if d.Validate(p) != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
t, _ := RoundTime(p, d.Round)
|
||||
return t
|
||||
}
|
||||
|
||||
// CanonicalJSON returns the canonical JSON payload of spec §18, for example
|
||||
// {"version":1,"network":"datekeys:quicknet:v1","round":66884212}, or nil if
|
||||
// d is not syntactically valid.
|
||||
func (d DateKey) CanonicalJSON() []byte {
|
||||
if !d.valid() {
|
||||
return nil
|
||||
}
|
||||
// ProfileID is restricted to [a-z0-9:._-], so no JSON escaping is needed.
|
||||
return fmt.Appendf(nil, `{"version":%d,"network":"%s","round":%d}`, Version, d.ProfileID, d.Round)
|
||||
}
|
||||
|
||||
// Compact returns the canonical dk1_ string (spec §18), or "" if d is not
|
||||
// syntactically valid.
|
||||
func (d DateKey) Compact() string {
|
||||
j := d.CanonicalJSON()
|
||||
if j == nil {
|
||||
return ""
|
||||
}
|
||||
return Prefix + base64.RawURLEncoding.EncodeToString(j)
|
||||
}
|
||||
|
||||
// String returns Compact.
|
||||
func (d DateKey) String() string { return d.Compact() }
|
||||
|
||||
func (d DateKey) valid() bool {
|
||||
return profile.ValidID(d.ProfileID) && d.Round >= 1 && d.Round <= MaxRound
|
||||
}
|
||||
|
||||
// Parse accepts only the unique canonical dk1_ string of a DateKey (spec §19):
|
||||
// it decodes Base64URL, parses the JSON, validates the fields, re-emits the
|
||||
// canonical JSON and dk1_ string and compares them byte for byte with s.
|
||||
//
|
||||
// Input that cannot be decoded or holds invalid fields fails with
|
||||
// ErrDateKeyInvalid; a valid DateKey in any other encoding fails with
|
||||
// ErrDateKeyNonCanonical. Parse does not check that the profile is known;
|
||||
// callers look it up in their profile.Registry.
|
||||
func Parse(s string) (DateKey, error) {
|
||||
if len(s) > MaxEncodedLen {
|
||||
return DateKey{}, fmt.Errorf("datekey: input longer than %d bytes: %w", MaxEncodedLen, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
payload, ok := strings.CutPrefix(s, Prefix)
|
||||
if !ok {
|
||||
return DateKey{}, fmt.Errorf("datekey: missing %q prefix: %w", Prefix, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
raw, err := decodeBase64(payload)
|
||||
if err != nil {
|
||||
return DateKey{}, fmt.Errorf("datekey: payload is not Base64URL: %w", datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
d, err := parseJSON(raw)
|
||||
if err != nil {
|
||||
return DateKey{}, err
|
||||
}
|
||||
if d.Compact() != s {
|
||||
return DateKey{}, fmt.Errorf("datekey: not the canonical encoding %s: %w", d.Compact(), datekeys.ErrDateKeyNonCanonical)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// decodeBase64 decodes unpadded Base64URL (spec §18). Padded and standard
|
||||
// alphabet variants are decoded too, so that they are reported as
|
||||
// non-canonical rather than invalid; the final comparison rejects them.
|
||||
func decodeBase64(s string) ([]byte, error) {
|
||||
var firstErr error
|
||||
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} {
|
||||
b, err := enc.DecodeString(s)
|
||||
if err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
func parseJSON(raw []byte) (DateKey, error) {
|
||||
invalid := func(format string, a ...any) error {
|
||||
return fmt.Errorf("datekey: "+format+": %w", append(a, datekeys.ErrDateKeyInvalid)...)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var obj map[string]any
|
||||
if err := dec.Decode(&obj); err != nil || obj == nil {
|
||||
return DateKey{}, invalid("payload is not a JSON object")
|
||||
}
|
||||
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
|
||||
return DateKey{}, invalid("trailing data after the JSON object")
|
||||
}
|
||||
if len(obj) != 3 {
|
||||
return DateKey{}, invalid("expected exactly the fields version, network and round")
|
||||
}
|
||||
version, ok := jsonUint(obj["version"])
|
||||
if !ok || version != Version {
|
||||
return DateKey{}, invalid("unsupported version %v", obj["version"])
|
||||
}
|
||||
network, ok := obj["network"].(string)
|
||||
if !ok || !profile.ValidID(network) {
|
||||
return DateKey{}, invalid("invalid network %v", obj["network"])
|
||||
}
|
||||
round, ok := jsonUint(obj["round"])
|
||||
if !ok || round == 0 || round > MaxRound {
|
||||
return DateKey{}, invalid("invalid round %v", obj["round"])
|
||||
}
|
||||
return DateKey{ProfileID: network, Round: round}, nil
|
||||
}
|
||||
|
||||
// jsonUint returns the value of a JSON number if it is a non-negative integer
|
||||
// that fits in uint64, whatever its spelling: 1000, 1000.0, 1e3 and 10E2 all
|
||||
// yield 1000. Non-canonical spellings are rejected later by the byte
|
||||
// comparison, as spec §19 prescribes.
|
||||
func jsonUint(v any) (uint64, bool) {
|
||||
n, ok := v.(json.Number)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
lit := string(n)
|
||||
neg := strings.HasPrefix(lit, "-")
|
||||
lit = strings.TrimPrefix(lit, "-")
|
||||
mantissa, exp, hasExp := strings.Cut(strings.ToLower(lit), "e")
|
||||
intPart, frac, _ := strings.Cut(mantissa, ".")
|
||||
digits := strings.TrimLeft(intPart+frac, "0")
|
||||
if digits == "" {
|
||||
return 0, true // zero, including -0, 0.0 and 0e99999
|
||||
}
|
||||
e := int64(0)
|
||||
if hasExp {
|
||||
var err error
|
||||
if e, err = strconv.ParseInt(exp, 10, 16); err != nil {
|
||||
return 0, false // |exponent| >= 32768 with non-zero digits
|
||||
}
|
||||
}
|
||||
if neg {
|
||||
return 0, false
|
||||
}
|
||||
e -= int64(len(frac))
|
||||
// digits * 10^e, keeping only integral values.
|
||||
digits = strings.TrimLeft(digits, "0")
|
||||
for e < 0 && strings.HasSuffix(digits, "0") {
|
||||
digits = digits[:len(digits)-1]
|
||||
e++
|
||||
}
|
||||
if e < 0 || int64(len(digits))+e > 20 {
|
||||
return 0, false
|
||||
}
|
||||
u, err := strconv.ParseUint(digits+strings.Repeat("0", int(e)), 10, 64)
|
||||
return u, err == nil
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
package datekey_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
func TestNormativeRoundVector(t *testing.T) {
|
||||
// Spec §16, stated literally.
|
||||
p := profile.Quicknet()
|
||||
d, err := datekey.Resolve(p, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil || d.Round != 66884212 {
|
||||
t.Fatalf("2030-01-01 resolved to %+v, %v", d, err)
|
||||
}
|
||||
if got := d.UnlockAt(p).Format(time.RFC3339); got != "2030-01-01T00:00:00Z" {
|
||||
t.Fatalf("round time %s", got)
|
||||
}
|
||||
rt, err := datekey.RoundTime(p, 66432123)
|
||||
if err != nil || rt.Format(time.RFC3339) != "2029-12-16T07:15:33Z" {
|
||||
t.Fatalf("round 66432123 at %s, %v", rt, err)
|
||||
}
|
||||
if got := d.Compact(); got != "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9" {
|
||||
t.Fatalf("dk1_ %s", got)
|
||||
}
|
||||
if got := string(d.CanonicalJSON()); got != `{"version":1,"network":"datekeys:quicknet:v1","round":66884212}` {
|
||||
t.Fatalf("canonical JSON %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldenRoundVectors(t *testing.T) {
|
||||
var golden testkit.RoundVectorFile
|
||||
if err := testkit.ReadJSON("../testdata/vectors/quicknet_rounds.json", &golden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := testkit.RoundVectors(); !reflect.DeepEqual(got, golden) {
|
||||
t.Fatalf("round vectors changed:\n got %+v\nwant %+v", got, golden)
|
||||
}
|
||||
p := profile.Quicknet()
|
||||
for _, v := range golden.Vectors {
|
||||
at, err := time.Parse(time.RFC3339Nano, v.Requested)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := datekey.Resolve(p, at)
|
||||
if v.Error != "" {
|
||||
if datekeys.Code(err) != v.Error {
|
||||
t.Errorf("%s: got %v, want %s", v.Name, err, v.Error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || d.Round != v.Round || d.UnlockAt(p).Format(time.RFC3339Nano) != v.Effective {
|
||||
t.Errorf("%s: got %+v %v", v.Name, d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldenDK1Vectors(t *testing.T) {
|
||||
var golden testkit.DK1VectorFile
|
||||
if err := testkit.ReadJSON("../testdata/vectors/dk1.json", &golden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := testkit.DK1Vectors(); !reflect.DeepEqual(got, golden) {
|
||||
t.Fatalf("dk1_ vectors changed")
|
||||
}
|
||||
for _, v := range golden.Vectors {
|
||||
if v.DK1 != "" {
|
||||
d, err := datekey.Parse(v.DK1)
|
||||
if err != nil || d.ProfileID != v.Network || d.Round != v.Round {
|
||||
t.Errorf("%s: %+v %v", v.Name, d, err)
|
||||
}
|
||||
if string(d.CanonicalJSON()) != v.CanonicalJSON || base64.RawURLEncoding.EncodeToString(d.CanonicalJSON()) != v.Base64URL {
|
||||
t.Errorf("%s: intermediate encodings differ", v.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v.Error == "accepted" {
|
||||
t.Errorf("%s: a rejected-encoding vector is accepted", v.Name)
|
||||
}
|
||||
if _, err := datekey.Parse(v.Input); datekeys.Code(err) != v.Error {
|
||||
t.Errorf("%s: got %v, want %s", v.Name, err, v.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kept from the prototype: resolution never picks a round that opens early.
|
||||
func TestRoundNeverOpensEarly(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
g := time.Unix(p.GenesisTime, 0).UTC()
|
||||
for _, tc := range []struct {
|
||||
offset time.Duration
|
||||
want uint64
|
||||
}{
|
||||
{0, 1}, {time.Nanosecond, 2}, {time.Second, 2}, {3 * time.Second, 2}, {3*time.Second + time.Nanosecond, 3}, {2997 * time.Second, 1000},
|
||||
} {
|
||||
requested := g.Add(tc.offset)
|
||||
d, err := datekey.Resolve(p, requested)
|
||||
if err != nil || d.Round != tc.want {
|
||||
t.Fatalf("offset %s: %+v, %v", tc.offset, d, err)
|
||||
}
|
||||
if u := d.UnlockAt(p); u.Before(requested) || u.Sub(requested) >= p.Period {
|
||||
t.Fatal("unsafe rounding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProperty(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
r := rand.New(rand.NewPCG(3, 4))
|
||||
for range 20000 {
|
||||
secs := p.GenesisTime + r.Int64N(profile.MaxUnixTime-p.GenesisTime-3)
|
||||
at := time.Unix(secs, r.Int64N(int64(time.Second)))
|
||||
d, err := datekey.Resolve(p, at)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", at, err)
|
||||
}
|
||||
u := d.UnlockAt(p)
|
||||
// The first round whose time is >= at: never earlier, and the previous
|
||||
// round is strictly earlier.
|
||||
if u.Before(at) {
|
||||
t.Fatalf("%s resolves to round %d at %s, before the request", at, d.Round, u)
|
||||
}
|
||||
if d.Round > 1 {
|
||||
prev, _ := datekey.RoundTime(p, d.Round-1)
|
||||
if !prev.Before(at) {
|
||||
t.Fatalf("%s: round %d at %s would already satisfy the request", at, d.Round-1, prev)
|
||||
}
|
||||
}
|
||||
parsed, err := datekey.Parse(d.Compact())
|
||||
if err != nil || parsed != d {
|
||||
t.Fatalf("Parse(Compact(d)) != d for %+v: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimezoneIndependence(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
a, _ := time.Parse(time.RFC3339Nano, "2026-10-22T19:00:00.001+02:00")
|
||||
b, _ := time.Parse(time.RFC3339Nano, "2026-10-22T17:00:00.001Z")
|
||||
da, _ := datekey.Resolve(p, a)
|
||||
db, _ := datekey.Resolve(p, b)
|
||||
if da != db {
|
||||
t.Fatal("timezone changed the DateKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
if err := (datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 5}).Validate(p); !errors.Is(err, datekeys.ErrProfileMismatch) {
|
||||
t.Fatalf("other profile: %v", err)
|
||||
}
|
||||
for _, r := range []uint64{0, p.MaxRound() + 1} {
|
||||
if err := (datekey.DateKey{ProfileID: p.ID, Round: r}).Validate(p); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Fatalf("round %d: %v", r, err)
|
||||
}
|
||||
}
|
||||
if !(datekey.DateKey{ProfileID: p.ID, Round: 0}).UnlockAt(p).IsZero() {
|
||||
t.Fatal("invalid DateKey has an unlock time")
|
||||
}
|
||||
if (datekey.DateKey{ProfileID: `bad"id`, Round: 1}).Compact() != "" {
|
||||
t.Fatal("invalid DateKey has a dk1_ form")
|
||||
}
|
||||
if _, err := datekey.Resolve(p, time.Time{}); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Fatalf("zero time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberSpellings(t *testing.T) {
|
||||
enc := func(round string) string {
|
||||
return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"network":"datekeys:quicknet:v1","round":`+round+`}`))
|
||||
}
|
||||
for _, s := range []string{"1000.0", "1e3", "1E3", "10e2", "1000e0", "100000e-2", "0.1e4"} {
|
||||
if _, err := datekey.Parse(enc(s)); !errors.Is(err, datekeys.ErrDateKeyNonCanonical) {
|
||||
t.Errorf("%s: %v, want non-canonical", s, err)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"1.5", "-1000", "1e-3", "1e400", "18446744073709551616", "0", "-0", "0e5"} {
|
||||
if _, err := datekey.Parse(enc(s)); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Errorf("%s: %v, want invalid", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
d := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
|
||||
f.Add(d.Compact())
|
||||
f.Add("dk1_invalid")
|
||||
f.Add(datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(`{"version":1,"network":"a","round":1e3}`)))
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
d, err := datekey.Parse(s)
|
||||
if err != nil {
|
||||
if c := datekeys.Code(err); c != "ERR_DATEKEY_INVALID" && c != "ERR_DATEKEY_NON_CANONICAL" {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if d.Compact() != s {
|
||||
t.Fatal("accepted a non-canonical DateKey")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
// Package datekeys is the reference Go implementation of the DateKeys Protocol
|
||||
// Specification v0.8.1 (spec/DateKeys_Protocol_Specification_v0.8.1.md).
|
||||
//
|
||||
// The protocol objects live in subpackages:
|
||||
//
|
||||
// - datekey: DateKey resolution and the canonical dk1_ form (spec §14-§19).
|
||||
// - profile: Provider Profiles and the pinned Quicknet profile (spec §10-§13).
|
||||
// - provider, provider/drand: release sources and local BLS verification (spec §45-§52).
|
||||
// - capsule: the DateKeyCap .dkc container (spec §20-§39, §61-§63).
|
||||
// - accesskey: the DateKeys Access Key .dkk credential (spec §40-§44).
|
||||
// - extension: the generic extension mechanism (spec §54).
|
||||
//
|
||||
// This package holds the normative error catalogue of spec §69. Every protocol
|
||||
// failure returned by this module wraps exactly one of these sentinels, so
|
||||
// callers can match them with [errors.Is] and extract the code with [Code].
|
||||
package datekeys
|
||||
|
||||
import "errors"
|
||||
|
||||
// Error is a normative DateKeys error (spec §69). Values are compared by
|
||||
// identity; use [errors.Is] against the exported sentinels.
|
||||
type Error struct {
|
||||
code string
|
||||
}
|
||||
|
||||
// Error returns the normative code, for example "ERR_INVALID_MAGIC".
|
||||
func (e *Error) Error() string { return e.code }
|
||||
|
||||
// Code returns the normative code, for example "ERR_INVALID_MAGIC".
|
||||
func (e *Error) Code() string { return e.code }
|
||||
|
||||
// Normative errors, spec §69.
|
||||
var (
|
||||
// ErrInvalidMagic: the object does not start with DKC1 or DKK1 (spec §22, §40).
|
||||
ErrInvalidMagic = &Error{"ERR_INVALID_MAGIC"}
|
||||
// ErrUnsupportedVersion: an unknown framing or schema version (spec §22, §70).
|
||||
ErrUnsupportedVersion = &Error{"ERR_UNSUPPORTED_VERSION"}
|
||||
// ErrInvalidFlags: FLAGS or RESERVED are not zero (spec §22, §40).
|
||||
ErrInvalidFlags = &Error{"ERR_INVALID_FLAGS"}
|
||||
// ErrNonCanonicalCBOR: the bytes are not the unique deterministic CBOR
|
||||
// encoding of a valid instance of the normative schema (spec §58, §58.1).
|
||||
ErrNonCanonicalCBOR = &Error{"ERR_NON_CANONICAL_CBOR"}
|
||||
// ErrUnknownProfile: the DateKey names a profile that is not pinned locally (spec §13).
|
||||
ErrUnknownProfile = &Error{"ERR_UNKNOWN_PROFILE"}
|
||||
// ErrProfileMismatch: a chain hash or profile does not match the pinned profile (spec §35, §63).
|
||||
ErrProfileMismatch = &Error{"ERR_PROFILE_MISMATCH"}
|
||||
// ErrDateKeyInvalid: a DateKey that cannot be decoded or validated (spec §18, §19).
|
||||
ErrDateKeyInvalid = &Error{"ERR_DATEKEY_INVALID"}
|
||||
// ErrDateKeyNonCanonical: a valid DateKey in a non-canonical encoding (spec §19).
|
||||
ErrDateKeyNonCanonical = &Error{"ERR_DATEKEY_NON_CANONICAL"}
|
||||
// ErrRoundMismatch: a round that differs from the locally resolved one (spec §17, §63).
|
||||
ErrRoundMismatch = &Error{"ERR_ROUND_MISMATCH"}
|
||||
// ErrReleaseUnavailable: the release is not published yet or no source delivered it (spec §45-§50).
|
||||
ErrReleaseUnavailable = &Error{"ERR_RELEASE_UNAVAILABLE"}
|
||||
// ErrReleaseInvalid: a release that fails local verification (spec §51).
|
||||
ErrReleaseInvalid = &Error{"ERR_RELEASE_INVALID"}
|
||||
// ErrAccessRequired: the policy requires an access credential and none was supplied (spec §33).
|
||||
ErrAccessRequired = &Error{"ERR_ACCESS_REQUIRED"}
|
||||
// ErrAccessInvalid: the supplied credentials do not open this capsule (spec §33, §38).
|
||||
ErrAccessInvalid = &Error{"ERR_ACCESS_INVALID"}
|
||||
// ErrPolicyStructureMismatch: the cryptographic structure does not match the
|
||||
// declared access policy or the stanza rules of V1 (spec §25, §29, §32, §33, §36).
|
||||
ErrPolicyStructureMismatch = &Error{"ERR_POLICY_STRUCTURE_MISMATCH"}
|
||||
// ErrHeaderBinding: header_binding does not match PRELUDE || PUBLIC_HEADER (spec §26).
|
||||
ErrHeaderBinding = &Error{"ERR_HEADER_BINDING"}
|
||||
// ErrIntegrity: truncation, corruption or failed authentication of framing or age data (spec §4, §55).
|
||||
ErrIntegrity = &Error{"ERR_INTEGRITY"}
|
||||
// ErrExtensionCriticalUnknown: a critical extension this implementation does not know (spec §54).
|
||||
ErrExtensionCriticalUnknown = &Error{"ERR_EXTENSION_CRITICAL_UNKNOWN"}
|
||||
)
|
||||
|
||||
// All returns every normative error in the order of spec §69.
|
||||
func All() []*Error {
|
||||
return []*Error{
|
||||
ErrInvalidMagic, ErrUnsupportedVersion, ErrInvalidFlags, ErrNonCanonicalCBOR,
|
||||
ErrUnknownProfile, ErrProfileMismatch, ErrDateKeyInvalid, ErrDateKeyNonCanonical,
|
||||
ErrRoundMismatch, ErrReleaseUnavailable, ErrReleaseInvalid, ErrAccessRequired,
|
||||
ErrAccessInvalid, ErrPolicyStructureMismatch, ErrHeaderBinding, ErrIntegrity,
|
||||
ErrExtensionCriticalUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
// Code returns the normative code of the first DateKeys error in err's tree,
|
||||
// or "" if err does not wrap one.
|
||||
func Code(err error) string {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e.code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package datekeys_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
)
|
||||
|
||||
// The catalogue matches spec §69 exactly, in order.
|
||||
func TestCatalogueMatchesSpec(t *testing.T) {
|
||||
spec, err := os.ReadFile("spec/DateKeys_Protocol_Specification_v0.8.1.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(spec)
|
||||
start := strings.Index(s, "## 69. Errores normativos")
|
||||
end := strings.Index(s, "## 70.")
|
||||
if start < 0 || end < start {
|
||||
t.Fatal("section 69 not found")
|
||||
}
|
||||
var want []string
|
||||
for _, line := range strings.Split(s[start:end], "\n") {
|
||||
if line = strings.TrimSpace(line); strings.HasPrefix(line, "ERR_") {
|
||||
want = append(want, line)
|
||||
}
|
||||
}
|
||||
var got []string
|
||||
for _, e := range datekeys.All() {
|
||||
got = append(got, e.Code())
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("catalogue\n got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCode(t *testing.T) {
|
||||
err := fmt.Errorf("capsule: step 8: %w", fmt.Errorf("agewrap: %w", datekeys.ErrRoundMismatch))
|
||||
if datekeys.Code(err) != "ERR_ROUND_MISMATCH" || !errors.Is(err, datekeys.ErrRoundMismatch) || errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Fatal("wrapping")
|
||||
}
|
||||
joined := fmt.Errorf("drand: %w: %w", datekeys.ErrReleaseUnavailable, errors.Join(errors.New("x"), datekeys.ErrReleaseInvalid))
|
||||
if datekeys.Code(joined) != "ERR_RELEASE_UNAVAILABLE" || !errors.Is(joined, datekeys.ErrReleaseInvalid) {
|
||||
t.Fatal("joined errors")
|
||||
}
|
||||
if datekeys.Code(errors.New("plain")) != "" || datekeys.Code(nil) != "" {
|
||||
t.Fatal("non-DateKeys errors have no code")
|
||||
}
|
||||
if datekeys.ErrIntegrity.Error() != "ERR_INTEGRITY" {
|
||||
t.Fatal("message")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
// Package extension implements the single generic extension mechanism shared
|
||||
// by PUBLIC_HEADER, CONTROL_CBOR and .dkk (spec §31, §44, §54, §72).
|
||||
//
|
||||
// The base protocol does not interpret extension data. It enforces the
|
||||
// structural rules only: valid UTF-8 identifiers, no identifier repeated
|
||||
// within an object (V1 registers no schema that allows multiplicity), no
|
||||
// identifier in both the critical and the noncritical array, canonical order
|
||||
// by the UTF-8 bytes of extension_id and then by version, rejection of unknown
|
||||
// critical extensions, and omission of empty arrays (spec §58.1).
|
||||
package extension
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
)
|
||||
|
||||
// MaxIDLen bounds extension_id. It is an implementation limit (spec §74).
|
||||
const MaxIDLen = 256
|
||||
|
||||
// Extension is one entry of an extension array.
|
||||
type Extension struct {
|
||||
ID string // key 0, extension_id
|
||||
Version uint64 // key 1, extension_version
|
||||
// Data is the Deterministic CBOR encoding of the data item (key 2), or
|
||||
// nil when the extension carries no data and the key is omitted.
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// New builds an extension whose data is the deterministic encoding of value.
|
||||
func New(id string, version uint64, value any) (Extension, error) {
|
||||
b, err := codec.Marshal(value)
|
||||
if err != nil {
|
||||
return Extension{}, err
|
||||
}
|
||||
return Extension{ID: id, Version: version, Data: b}, nil
|
||||
}
|
||||
|
||||
// Wire is the CBOR map of one extension (spec §54).
|
||||
type Wire struct {
|
||||
ID string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
Data cbor.RawMessage `cbor:"2,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// Registry tells which critical extensions the application implements. A nil
|
||||
// Registry knows none, which is the state of the base protocol V1.
|
||||
type Registry interface {
|
||||
Known(id string, version uint64) bool
|
||||
}
|
||||
|
||||
// Set is a simple Registry.
|
||||
type Set map[string][]uint64
|
||||
|
||||
// Known reports whether (id, version) is in the set.
|
||||
func (s Set) Known(id string, version uint64) bool { return slices.Contains(s[id], version) }
|
||||
|
||||
func compare(a, b Extension) int {
|
||||
if c := bytes.Compare([]byte(a.ID), []byte(b.ID)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Version, b.Version)
|
||||
}
|
||||
|
||||
func validate(e Extension) error {
|
||||
if e.ID == "" || len(e.ID) > MaxIDLen || !utf8.ValidString(e.ID) {
|
||||
return fmt.Errorf("extension: invalid extension_id %q: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
if e.Data != nil {
|
||||
if err := codec.Valid(e.Data); err != nil {
|
||||
return fmt.Errorf("extension %s: data: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode validates one extension array and returns its canonical wire form,
|
||||
// sorted by extension_id bytes and then version. An empty input yields nil,
|
||||
// so that the array key is omitted (spec §58.1).
|
||||
func Encode(exts []Extension) ([]Wire, error) {
|
||||
if len(exts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sorted := slices.Clone(exts)
|
||||
slices.SortFunc(sorted, compare)
|
||||
out := make([]Wire, 0, len(sorted))
|
||||
for i, e := range sorted {
|
||||
if err := validate(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 && sorted[i-1].ID == e.ID {
|
||||
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
out = append(out, Wire{ID: e.ID, Version: e.Version, Data: bytes.Clone(e.Data)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Decode validates one decoded extension array: canonical order, no repeated
|
||||
// identifier and canonical data.
|
||||
func Decode(ws []Wire) ([]Extension, error) {
|
||||
if len(ws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]Extension, 0, len(ws))
|
||||
for i, w := range ws {
|
||||
e := Extension{ID: w.ID, Version: w.Version}
|
||||
if w.Data != nil {
|
||||
e.Data = bytes.Clone(w.Data)
|
||||
}
|
||||
if err := validate(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
prev := out[i-1]
|
||||
if prev.ID == e.ID {
|
||||
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
if compare(prev, e) > 0 {
|
||||
return nil, fmt.Errorf("extension %s: array is not in canonical order: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckDisjoint applies the cross-array rule of one object: an extension_id
|
||||
// must not appear in both critical_extensions and noncritical_extensions
|
||||
// (spec §31, §54).
|
||||
func CheckDisjoint(critical, noncritical []Extension) error {
|
||||
for _, c := range critical {
|
||||
for _, n := range noncritical {
|
||||
if c.ID == n.ID {
|
||||
return fmt.Errorf("extension %s: both critical and noncritical: %w", c.ID, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCritical rejects every critical extension unknown to reg (spec §54,
|
||||
// §70). Unknown noncritical extensions may be ignored and are not checked.
|
||||
func CheckCritical(critical []Extension, reg Registry) error {
|
||||
for _, c := range critical {
|
||||
if reg == nil || !reg.Known(c.ID, c.Version) {
|
||||
return fmt.Errorf("extension %s v%d: %w", c.ID, c.Version, datekeys.ErrExtensionCriticalUnknown)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package extension_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
)
|
||||
|
||||
func ext(t *testing.T, id string, v uint64, data any) extension.Extension {
|
||||
t.Helper()
|
||||
if data == nil {
|
||||
return extension.Extension{ID: id, Version: v}
|
||||
}
|
||||
e, err := extension.New(id, v, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestEncodeSortsCanonically(t *testing.T) {
|
||||
in := []extension.Extension{ext(t, "org.b", 1, nil), ext(t, "org.a", 2, "x"), ext(t, "Z", 9, nil), ext(t, "org.aa", 1, 7)}
|
||||
w, err := extension.Encode(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
for _, e := range w {
|
||||
order = append(order, e.ID)
|
||||
}
|
||||
// Bytewise UTF-8 order: uppercase before lowercase, prefixes first.
|
||||
if got := []string{"Z", "org.a", "org.aa", "org.b"}; !equal(order, got) {
|
||||
t.Fatalf("order %v, want %v", order, got)
|
||||
}
|
||||
if w, _ := extension.Encode(nil); w != nil {
|
||||
t.Fatal("empty array must encode to nil so that the key is omitted")
|
||||
}
|
||||
back, err := extension.Decode(w)
|
||||
if err != nil || len(back) != 4 || back[1].ID != "org.a" || string(back[1].Data) != "\x61\x78" {
|
||||
t.Fatalf("decode: %+v %v", back, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRejects(t *testing.T) {
|
||||
for name, in := range map[string][]extension.Extension{
|
||||
"same id twice": {ext(t, "org.a", 1, nil), ext(t, "org.a", 2, nil)},
|
||||
"empty id": {ext(t, "", 1, nil)},
|
||||
"invalid UTF-8 id": {{ID: "org.\xff", Version: 1}},
|
||||
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0x18, 0x01}}},
|
||||
"data with two items": {{ID: "org.a", Version: 1, Data: []byte{0x01, 0x02}}},
|
||||
"data with a tag": {{ID: "org.a", Version: 1, Data: []byte{0xc1, 0x01}}},
|
||||
} {
|
||||
if _, err := extension.Encode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejects(t *testing.T) {
|
||||
for name, in := range map[string][]extension.Wire{
|
||||
"out of order": {{ID: "org.b", Version: 1}, {ID: "org.a", Version: 1}},
|
||||
"versions out of order": {{ID: "org.a", Version: 2}, {ID: "org.a", Version: 1}},
|
||||
"repeated id": {{ID: "org.a", Version: 1}, {ID: "org.a", Version: 2}},
|
||||
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0xf9, 0x3c, 0x00, 0x00}}},
|
||||
} {
|
||||
if _, err := extension.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossArrayRules(t *testing.T) {
|
||||
crit := []extension.Extension{ext(t, "org.a", 1, nil)}
|
||||
non := []extension.Extension{ext(t, "org.a", 2, nil)}
|
||||
if err := extension.CheckDisjoint(crit, non); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("id in both arrays: %v", err)
|
||||
}
|
||||
if err := extension.CheckCritical(crit, nil); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
|
||||
t.Fatalf("unknown critical accepted by the base protocol: %v", err)
|
||||
}
|
||||
if err := extension.CheckCritical(crit, extension.Set{"org.a": {2}}); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
|
||||
t.Fatalf("other version accepted: %v", err)
|
||||
}
|
||||
if err := extension.CheckCritical(crit, extension.Set{"org.a": {1}}); err != nil {
|
||||
t.Fatalf("known critical rejected: %v", err)
|
||||
}
|
||||
if err := extension.CheckCritical(nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
module github.com/datekeys/datekeys-go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.2
|
||||
github.com/drand/drand/v2 v2.1.7
|
||||
github.com/drand/kyber v1.3.2
|
||||
github.com/drand/tlock v1.2.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.4
|
||||
golang.org/x/crypto v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/drand/kyber-bls12381 v0.3.4 // indirect
|
||||
github.com/kilic/bls12-381 v0.1.0 // indirect
|
||||
github.com/nikkolasg/hexjson v0.1.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.dedis.ch/fixbuf v1.0.3 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
|
||||
google.golang.org/grpc v1.84.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@ -0,0 +1,141 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/ardanlabs/darwin/v2 v2.0.0 h1:XCisQMgQ5EG+ZvSEcADEo+pyfIMKyWAGnn5o2TgriYE=
|
||||
github.com/ardanlabs/darwin/v2 v2.0.0/go.mod h1:MubZ2e9DAYGaym0mClSOi183NYahrrfKxvSy1HMhoes=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/drand/drand/v2 v2.1.7 h1:VpNrMI7dSFDEayQ8uGCouJ+8SekPqy3G9SdcwAzUkiY=
|
||||
github.com/drand/drand/v2 v2.1.7/go.mod h1:wuZkwJ47Mbn6mhXgi0doTJQ8urCZB0hDMZ6gVUTRXsk=
|
||||
github.com/drand/go-clients v0.2.0 h1:2agHJkF2OOjd9Eij/YedQnDc9mW0rywV/9xUHbf2XoQ=
|
||||
github.com/drand/go-clients v0.2.0/go.mod h1:4m2qC/O8lx2Aj6DEIrEZ4kUzAUV6BIjmiSouW6lpYfI=
|
||||
github.com/drand/kyber v1.3.2 h1:Cf3NNcb5bV3eODopr3XVHzImjDK40GiObhFUFG93Zeo=
|
||||
github.com/drand/kyber v1.3.2/go.mod h1:ciDFWoC7ajb89niGJnS4C1Xeo4lSJMmbi+km5w8juAI=
|
||||
github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAebZQJxI=
|
||||
github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk=
|
||||
github.com/drand/tlock v1.2.0 h1:YmbH2PXsq6UeUXljq+GMZcDicUlVnLIW9QbLqYoDp6g=
|
||||
github.com/drand/tlock v1.2.0/go.mod h1:HFjdoX5v8rp4uOFaIPI8nDdWRKdvDnNgj+kQwQOOxoQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4=
|
||||
github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nikkolasg/hexjson v0.1.0 h1:Cgi1MSZVQFoJKYeRpBNEcdF3LB+Zo4fYKsDz7h8uJYQ=
|
||||
github.com/nikkolasg/hexjson v0.1.0/go.mod h1:fbGbWFZ0FmJMFbpCMtJpwb0tudVxSSZ+Es2TsCg57cA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.dedis.ch/fixbuf v1.0.3 h1:hGcV9Cd/znUxlusJ64eAlExS+5cJDIyTyEG+otu5wQs=
|
||||
go.dedis.ch/fixbuf v1.0.3/go.mod h1:yzJMt34Wa5xD37V5RTdmp38cz3QhMagdGoem9anUalw=
|
||||
go.dedis.ch/protobuf v1.0.11 h1:FTYVIEzY/bfl37lu3pR4lIj+F9Vp1jE8oh91VmxKgLo=
|
||||
go.dedis.ch/protobuf v1.0.11/go.mod h1:97QR256dnkimeNdfmURz0wAMNVbd1VmLXhG1CrTYrJ4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
|
||||
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@ -0,0 +1,150 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
)
|
||||
|
||||
// CaptureRecipient forwards to Recipient and records the file key that age
|
||||
// asks it to wrap. Knowing the file key lets a test rewrite the age header
|
||||
// and recompute a valid MAC, which models a malicious creator (spec §27).
|
||||
type CaptureRecipient struct {
|
||||
Recipient age.Recipient
|
||||
FileKey []byte
|
||||
}
|
||||
|
||||
// Wrap implements age.Recipient.
|
||||
func (c *CaptureRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
c.FileKey = bytes.Clone(fileKey)
|
||||
return c.Recipient.Wrap(fileKey)
|
||||
}
|
||||
|
||||
// WrapWithLabels forwards labels when the wrapped recipient has them.
|
||||
func (c *CaptureRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
|
||||
c.FileKey = bytes.Clone(fileKey)
|
||||
if l, ok := c.Recipient.(age.RecipientWithLabels); ok {
|
||||
return l.WrapWithLabels(fileKey)
|
||||
}
|
||||
s, err := c.Recipient.Wrap(fileKey)
|
||||
return s, nil, err
|
||||
}
|
||||
|
||||
// Encrypt returns a complete age file for plaintext and the file key age
|
||||
// generated for it.
|
||||
func Encrypt(plaintext []byte, recipients ...age.Recipient) (file, fileKey []byte, err error) {
|
||||
if len(recipients) == 0 {
|
||||
return nil, nil, errors.New("testkit: no recipients")
|
||||
}
|
||||
capture := &CaptureRecipient{Recipient: recipients[0]}
|
||||
all := append([]age.Recipient{capture}, recipients[1:]...)
|
||||
var buf bytes.Buffer
|
||||
w, err := age.Encrypt(&buf, all...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return buf.Bytes(), capture.FileKey, nil
|
||||
}
|
||||
|
||||
// HeaderLen returns the length of the age header at the start of file.
|
||||
func HeaderLen(file []byte) (int, error) {
|
||||
hdr, err := age.ExtractHeader(bytes.NewReader(file))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !bytes.HasPrefix(file, hdr) {
|
||||
return 0, errors.New("testkit: header is not in canonical form")
|
||||
}
|
||||
return len(hdr), nil
|
||||
}
|
||||
|
||||
// RewriteAge replaces the recipient stanzas of an age file with
|
||||
// edit(stanzas) and recomputes the header MAC with fileKey, keeping the nonce
|
||||
// and the STREAM payload. The result is an age file that age itself accepts
|
||||
// whenever some identity yields fileKey: only structural checks can reject it.
|
||||
func RewriteAge(file, fileKey []byte, edit func([]*age.Stanza) []*age.Stanza) ([]byte, error) {
|
||||
stanzas, err := agewrap.Stanzas(bytes.NewReader(file))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := HeaderLen(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdr, err := MarshalHeader(edit(stanzas), fileKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(hdr, file[n:]...), nil
|
||||
}
|
||||
|
||||
// MarshalHeader serialises an age v1 header as specified by C2SP age.md: the
|
||||
// intro line, each stanza, and the footer with
|
||||
// HMAC-SHA-256(HKDF-SHA-256(file key, "", "header"), header up to "---").
|
||||
func MarshalHeader(stanzas []*age.Stanza, fileKey []byte) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("age-encryption.org/v1\n")
|
||||
for _, s := range stanzas {
|
||||
if !validArg(s.Type) {
|
||||
return nil, fmt.Errorf("testkit: invalid stanza type %q", s.Type)
|
||||
}
|
||||
b.WriteString("-> " + s.Type)
|
||||
for _, a := range s.Args {
|
||||
if !validArg(a) {
|
||||
return nil, fmt.Errorf("testkit: invalid stanza argument %q", a)
|
||||
}
|
||||
b.WriteString(" " + a)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
body := base64.RawStdEncoding.EncodeToString(s.Body)
|
||||
for len(body) >= 64 {
|
||||
b.WriteString(body[:64] + "\n")
|
||||
body = body[64:]
|
||||
}
|
||||
b.WriteString(body + "\n") // the final line is always short, possibly empty
|
||||
}
|
||||
b.WriteString("---")
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.New(sha256.New, fileKey, nil, []byte("header")), key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(b.Bytes())
|
||||
b.WriteString(" " + base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) + "\n")
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func validArg(s string) bool {
|
||||
return s != "" && !strings.ContainsFunc(s, func(r rune) bool { return r < 33 || r > 126 })
|
||||
}
|
||||
|
||||
// X25519Stanza returns a well-formed X25519 stanza wrapping fileKey for a
|
||||
// fresh identity, and that identity. It models an extra decryption path that
|
||||
// a malicious creator could add.
|
||||
func X25519Stanza(fileKey []byte) (*age.Stanza, *age.X25519Identity, error) {
|
||||
id, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
s, err := id.Recipient().Wrap(fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return s[0], id, nil
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// Build assembles a capsule step by step like capsule.Encrypt, but lets a test
|
||||
// declare one policy and build another structure, and edit the stanzas of each
|
||||
// age file while keeping every MAC valid. Every other value (header_binding,
|
||||
// lengths) stays consistent, as a malicious creator would make it.
|
||||
type Build struct {
|
||||
Profile *profile.Profile // default Quicknet
|
||||
Round uint64 // default 1000
|
||||
Declared capsule.Policy // access_policy written in PUBLIC_HEADER
|
||||
Structure capsule.Policy // construction actually used
|
||||
// AccessRecipients of INNER_ACCESS_AGE when Structure is time_and_key.
|
||||
AccessRecipients []age.Recipient
|
||||
Plaintext []byte
|
||||
DateKeyString string // overrides the canonical dk1_ string in PUBLIC_HEADER
|
||||
|
||||
HeaderCritical, HeaderNoncritical []extension.Extension
|
||||
ControlCritical, ControlNoncritical []extension.Extension
|
||||
|
||||
// Stanza edits, applied with the corresponding file key.
|
||||
EditOuter func(fileKey []byte, s []*age.Stanza) []*age.Stanza
|
||||
EditInner func(fileKey []byte, s []*age.Stanza) []*age.Stanza
|
||||
EditPayload func(fileKey []byte, s []*age.Stanza) []*age.Stanza
|
||||
}
|
||||
|
||||
// Built is a capsule produced by Build and its secrets.
|
||||
type Built struct {
|
||||
DKC []byte
|
||||
Prelude [capsule.PreludeSize]byte
|
||||
PublicHeader []byte
|
||||
Sealed []byte
|
||||
Payload []byte
|
||||
Control []byte
|
||||
PayloadIdentity []byte
|
||||
CapsuleID [capsule.CapsuleIDSize]byte
|
||||
}
|
||||
|
||||
// Make builds the capsule.
|
||||
func (b Build) Make() (*Built, error) {
|
||||
p := b.Profile
|
||||
if p == nil {
|
||||
p = profile.Quicknet()
|
||||
}
|
||||
round := b.Round
|
||||
if round == 0 {
|
||||
round = 1000
|
||||
}
|
||||
out := &Built{}
|
||||
if _, err := rand.Read(out.CapsuleID[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header, err := b.header(p, round, out.CapsuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.PublicHeader = header
|
||||
|
||||
payloadID, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.PayloadIdentity, err = agewrap.RawX25519Identity(payloadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, fk, err := Encrypt(b.Plaintext, payloadID.Recipient())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.EditPayload != nil {
|
||||
if payload, err = RewriteAge(payload, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditPayload(fk, s) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out.Payload = payload
|
||||
|
||||
timeRecipient, err := agewrap.NewTimeRecipient(p, round)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seal := func(control []byte) ([]byte, error) {
|
||||
plaintext := control
|
||||
if b.Structure == capsule.TimeAndKey {
|
||||
if len(b.AccessRecipients) == 0 {
|
||||
return nil, errors.New("testkit: time_and_key structure needs AccessRecipients")
|
||||
}
|
||||
inner, fk, err := Encrypt(control, b.AccessRecipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.EditInner != nil {
|
||||
if inner, err = RewriteAge(inner, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditInner(fk, s) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
plaintext = inner
|
||||
}
|
||||
outer, fk, err := Encrypt(plaintext, timeRecipient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.EditOuter != nil {
|
||||
return RewriteAge(outer, fk, func(s []*age.Stanza) []*age.Stanza { return b.EditOuter(fk, s) })
|
||||
}
|
||||
return outer, nil
|
||||
}
|
||||
|
||||
ctrl := &capsule.Control{Critical: b.ControlCritical, Noncritical: b.ControlNoncritical}
|
||||
draft, err := capsule.EncodeControl(ctrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
draftSealed, err := seal(draft)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Prelude = capsule.Prelude{PublicHeaderLen: uint32(len(header)), SealedControlLen: uint32(len(draftSealed))}.Bytes()
|
||||
ctrl.HeaderBinding = capsule.HeaderBinding(out.Prelude, header)
|
||||
copy(ctrl.PayloadIdentity[:], out.PayloadIdentity)
|
||||
if out.Control, err = capsule.EncodeControl(ctrl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Sealed, err = seal(out.Control); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out.Sealed) != len(draftSealed) {
|
||||
return nil, fmt.Errorf("testkit: SEALED_CONTROL length changed from %d to %d", len(draftSealed), len(out.Sealed))
|
||||
}
|
||||
out.DKC = Join(out.Prelude[:], out.PublicHeader, out.Sealed, out.Payload)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b Build) header(p *profile.Profile, round uint64, id [capsule.CapsuleIDSize]byte) ([]byte, error) {
|
||||
h := &capsule.Header{
|
||||
CapsuleID: id,
|
||||
DateKey: datekey.DateKey{ProfileID: p.ID, Round: round},
|
||||
Policy: b.Declared,
|
||||
Critical: b.HeaderCritical,
|
||||
Noncritical: b.HeaderNoncritical,
|
||||
}
|
||||
if b.DateKeyString == "" {
|
||||
return capsule.EncodeHeader(h)
|
||||
}
|
||||
return RawHeader(id, b.DateKeyString, uint64(b.Declared))
|
||||
}
|
||||
|
||||
// RawHeader encodes a PUBLIC_HEADER with an arbitrary DateKey string and
|
||||
// policy value, bypassing the validation of capsule.EncodeHeader.
|
||||
func RawHeader(id [capsule.CapsuleIDSize]byte, dk string, policy uint64) ([]byte, error) {
|
||||
type wire struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
CapsuleID []byte `cbor:"2,keyasint"`
|
||||
DateKey string `cbor:"3,keyasint"`
|
||||
Policy uint64 `cbor:"4,keyasint"`
|
||||
}
|
||||
return codec.Marshal(wire{capsule.HeaderTypeTag, capsule.HeaderVersion, id[:], dk, policy})
|
||||
}
|
||||
|
||||
// Parts is a .dkc split into its four sections.
|
||||
type Parts struct {
|
||||
Prelude, Header, Sealed, Payload []byte
|
||||
}
|
||||
|
||||
// Split splits a .dkc using the lengths in its prelude.
|
||||
func Split(dkc []byte) (Parts, error) {
|
||||
if len(dkc) < capsule.PreludeSize {
|
||||
return Parts{}, errors.New("testkit: short capsule")
|
||||
}
|
||||
hl := int(binary.BigEndian.Uint32(dkc[8:12]))
|
||||
sl := int(binary.BigEndian.Uint32(dkc[12:16]))
|
||||
if len(dkc) < capsule.PreludeSize+hl+sl {
|
||||
return Parts{}, errors.New("testkit: capsule shorter than its prelude says")
|
||||
}
|
||||
h := dkc[capsule.PreludeSize : capsule.PreludeSize+hl]
|
||||
s := dkc[capsule.PreludeSize+hl : capsule.PreludeSize+hl+sl]
|
||||
return Parts{Prelude: dkc[:capsule.PreludeSize], Header: h, Sealed: s, Payload: dkc[capsule.PreludeSize+hl+sl:]}, nil
|
||||
}
|
||||
|
||||
// Join concatenates sections into a new slice.
|
||||
func Join(parts ...[]byte) []byte {
|
||||
var out []byte
|
||||
for _, p := range parts {
|
||||
out = append(out, p...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Reframe joins sections with a prelude whose lengths match them, keeping
|
||||
// the version, flags and reserved bytes of prelude.
|
||||
func Reframe(prelude, header, sealed, payload []byte) []byte {
|
||||
pre := append([]byte(nil), prelude[:capsule.PreludeSize]...)
|
||||
binary.BigEndian.PutUint32(pre[8:12], uint32(len(header)))
|
||||
binary.BigEndian.PutUint32(pre[12:16], uint32(len(sealed)))
|
||||
return Join(pre, header, sealed, payload)
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// FixtureStanza is the visible part of an age stanza in a fixture.
|
||||
type FixtureStanza struct {
|
||||
Type string `json:"type"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
// FixtureRelease is the release a fixture opens with.
|
||||
type FixtureRelease struct {
|
||||
Round uint64 `json:"round"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// FixtureStage is the expected result of one step of spec §63.
|
||||
type FixtureStage struct {
|
||||
Step int `json:"step"`
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DKCFixture holds the expected values of an official .dkc fixture (spec §67).
|
||||
type DKCFixture struct {
|
||||
Description string `json:"description"`
|
||||
Spec string `json:"spec"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Release FixtureRelease `json:"release"`
|
||||
Prelude string `json:"prelude"`
|
||||
PublicHeader string `json:"public_header"`
|
||||
DateKey string `json:"datekey"`
|
||||
CapsuleID string `json:"capsule_id"`
|
||||
AccessPolicy string `json:"access_policy"`
|
||||
Structure string `json:"structure"`
|
||||
UnlockAt string `json:"unlock_at"`
|
||||
HeaderBinding string `json:"header_binding"`
|
||||
OuterStanzas []FixtureStanza `json:"outer_stanzas"`
|
||||
PayloadStanzas []FixtureStanza `json:"payload_stanzas"`
|
||||
InnerStanzas []FixtureStanza `json:"inner_stanzas,omitempty"`
|
||||
AccessKeyFile string `json:"access_key_file,omitempty"`
|
||||
Identities []string `json:"identities,omitempty"`
|
||||
ControlCBOR string `json:"control_cbor"`
|
||||
PayloadIdentity string `json:"payload_identity"`
|
||||
PlaintextFile string `json:"plaintext_file"`
|
||||
PlaintextSHA256 string `json:"plaintext_sha256"`
|
||||
HeaderExtensions []FixtureExt `json:"header_extensions,omitempty"`
|
||||
ControlExt []FixtureExt `json:"control_extensions,omitempty"`
|
||||
Stages []FixtureStage `json:"stages"`
|
||||
}
|
||||
|
||||
// FixtureExt is an extension in a fixture.
|
||||
type FixtureExt struct {
|
||||
Critical bool `json:"critical"`
|
||||
ID string `json:"id"`
|
||||
Version uint64 `json:"version"`
|
||||
Data string `json:"data,omitempty"` // hex of the CBOR data item
|
||||
}
|
||||
|
||||
// DKKFixture holds the expected values of an official .dkk fixture (spec §68).
|
||||
type DKKFixture struct {
|
||||
Description string `json:"description"`
|
||||
Spec string `json:"spec"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CredentialID string `json:"credential_id"`
|
||||
CapsuleID string `json:"capsule_id"`
|
||||
AccessType string `json:"access_type"`
|
||||
Material string `json:"access_material"`
|
||||
CapsuleDigest string `json:"capsule_digest,omitempty"`
|
||||
Extensions []FixtureExt `json:"extensions,omitempty"`
|
||||
Capsule string `json:"capsule"`
|
||||
ExpectedResult string `json:"expected_result"`
|
||||
Stages []FixtureStage `json:"stages,omitempty"`
|
||||
}
|
||||
|
||||
// ReadJSON decodes a JSON file.
|
||||
func ReadJSON(path string, v any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// WriteJSON writes v as indented JSON with a trailing newline.
|
||||
func WriteJSON(path string, v any) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0o644)
|
||||
}
|
||||
@ -0,0 +1,306 @@
|
||||
// Command genfixtures generates the official DateKeys vectors and fixtures
|
||||
// (spec §65-§68) into testdata/.
|
||||
//
|
||||
// Fixtures are generated once, over rounds that are already published, and
|
||||
// then committed: age randomness cannot be injected through its public API,
|
||||
// so they are decryption and validation fixtures, not byte-reproducible
|
||||
// encryption outputs (spec §67). Existing fixtures are never overwritten
|
||||
// unless -force is given; vectors are always regenerated, and the tests fail
|
||||
// if the implementation stops reproducing the committed ones.
|
||||
//
|
||||
// go run ./internal/testkit/genfixtures -out testdata
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"github.com/datekeys/datekeys-go/accesskey"
|
||||
"github.com/datekeys/datekeys-go/agewrap"
|
||||
"github.com/datekeys/datekeys-go/capsule"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/extension"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "testdata", "output directory")
|
||||
force := flag.Bool("force", false, "overwrite existing fixtures")
|
||||
flag.Parse()
|
||||
if err := vectors(filepath.Join(*out, "vectors")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := fixtures(filepath.Join(*out, "fixtures"), *force); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func vectors(dir string) error {
|
||||
pv, err := testkit.QuicknetProfileVector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := testkit.WriteJSON(filepath.Join(dir, "profile_quicknet.json"), pv); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := testkit.WriteJSON(filepath.Join(dir, "quicknet_rounds.json"), testkit.RoundVectors()); err != nil {
|
||||
return err
|
||||
}
|
||||
return testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors())
|
||||
}
|
||||
|
||||
type spec struct {
|
||||
name, description string
|
||||
round uint64
|
||||
policy capsule.Policy
|
||||
recipients int
|
||||
portable bool
|
||||
plaintext []byte
|
||||
headerExt []extension.Extension
|
||||
controlExt []extension.Extension
|
||||
}
|
||||
|
||||
func fixtures(dir string, force bool) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
|
||||
hExt, err := extension.New("org.example.label", 1, "public label")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cExt, err := extension.New("org.example.note", 2, map[string]any{"sealed": true, "n": 7})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []spec{
|
||||
{name: "time_only", description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
|
||||
{name: "time_only_extensions", description: "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}},
|
||||
{name: "time_and_key_portable", description: "time_and_key capsule whose only recipient is a portable .dkk", round: 1000, policy: capsule.TimeAndKey, portable: true, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")},
|
||||
{name: "time_and_key_recipients", description: "time_and_key capsule for two known X25519 recipients and a portable .dkk", round: 1001, policy: capsule.TimeAndKey, recipients: 2, portable: true, plaintext: []byte("DateKeys fixture for several recipients.\n")},
|
||||
{name: "empty_payload", description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}},
|
||||
}
|
||||
for _, s := range specs {
|
||||
path := filepath.Join(dir, s.name+".dkc")
|
||||
if _, err := os.Stat(path); err == nil && !force {
|
||||
log.Printf("keeping existing %s", path)
|
||||
continue
|
||||
}
|
||||
if err := generate(dir, s); err != nil {
|
||||
return fmt.Errorf("%s: %w", s.name, err)
|
||||
}
|
||||
log.Printf("generated %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generate(dir string, s spec) error {
|
||||
p := profile.Quicknet()
|
||||
reg := testkit.Registry()
|
||||
unlock, err := datekey.RoundTime(p, s.round)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := capsule.EncryptOptions{
|
||||
Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable,
|
||||
Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()),
|
||||
}
|
||||
var ids []*age.X25519Identity
|
||||
for range s.recipients {
|
||||
id, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
opts.Recipients = append(opts.Recipients, id.Recipient())
|
||||
}
|
||||
var dkc bytes.Buffer
|
||||
res, err := capsule.Encrypt(&dkc, bytes.NewReader(s.plaintext), opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release := testkit.Release(s.round)
|
||||
|
||||
// Recover every intermediate value by opening the fixture step by step.
|
||||
parts, err := testkit.Split(dkc.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeID, err := agewrap.NewTimeIdentity(p, s.round, release)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inner, err := decrypt(parts.Sealed, timeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
control := inner
|
||||
var innerStanzas []testkit.FixtureStanza
|
||||
var identities []string
|
||||
var dkkFile string
|
||||
var dkkBytes []byte
|
||||
if s.policy == capsule.TimeAndKey {
|
||||
st, err := agewrap.Stanzas(bytes.NewReader(inner))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
innerStanzas = stanzas(st)
|
||||
var tryIDs []age.Identity
|
||||
for _, id := range ids {
|
||||
identities = append(identities, id.String())
|
||||
tryIDs = append(tryIDs, id)
|
||||
}
|
||||
if res.PortableKey != nil {
|
||||
var kb bytes.Buffer
|
||||
if err := accesskey.Encode(&kb, res.PortableKey); err != nil {
|
||||
return err
|
||||
}
|
||||
dkkBytes = kb.Bytes()
|
||||
dkkFile = s.name + ".dkk"
|
||||
kid, err := res.PortableKey.Identity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tryIDs = append(tryIDs, kid)
|
||||
}
|
||||
accessID, err := agewrap.NewAccessIdentity(tryIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if control, err = decrypt(inner, accessID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctrl, err := capsule.DecodeControl(control)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outer, err := agewrap.Stanzas(bytes.NewReader(parts.Sealed))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := agewrap.Stanzas(bytes.NewReader(parts.Payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The fixture must open through the public API with the embedded release.
|
||||
oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)}
|
||||
for _, id := range ids {
|
||||
oo.Identities = append(oo.Identities, id)
|
||||
}
|
||||
if s.policy == capsule.TimeAndKey && len(ids) == 0 {
|
||||
oo.AccessKey = res.PortableKey
|
||||
}
|
||||
var plain bytes.Buffer
|
||||
opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), oo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fixture does not open: %w", err)
|
||||
}
|
||||
if !bytes.Equal(plain.Bytes(), s.plaintext) {
|
||||
return fmt.Errorf("fixture plaintext mismatch")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(dkc.Bytes())
|
||||
psum := sha256.Sum256(s.plaintext)
|
||||
f := testkit.DKCFixture{
|
||||
Description: s.description,
|
||||
Spec: testkit.SpecVersion,
|
||||
File: s.name + ".dkc",
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Release: testkit.FixtureRelease{Round: release.Round, Signature: hex.EncodeToString(release.Signature)},
|
||||
Prelude: hex.EncodeToString(parts.Prelude),
|
||||
PublicHeader: hex.EncodeToString(parts.Header),
|
||||
DateKey: res.DateKey.Compact(),
|
||||
CapsuleID: hex.EncodeToString(res.CapsuleID[:]),
|
||||
AccessPolicy: s.policy.String(),
|
||||
Structure: s.policy.String(),
|
||||
UnlockAt: unlock.Format(time.RFC3339),
|
||||
HeaderBinding: hex.EncodeToString(ctrl.HeaderBinding[:]),
|
||||
OuterStanzas: stanzas(outer),
|
||||
PayloadStanzas: stanzas(payload),
|
||||
InnerStanzas: innerStanzas,
|
||||
AccessKeyFile: dkkFile,
|
||||
Identities: identities,
|
||||
ControlCBOR: hex.EncodeToString(control),
|
||||
PayloadIdentity: hex.EncodeToString(ctrl.PayloadIdentity[:]),
|
||||
PlaintextFile: s.name + ".plaintext",
|
||||
PlaintextSHA256: hex.EncodeToString(psum[:]),
|
||||
HeaderExtensions: exts(false, s.headerExt),
|
||||
ControlExt: exts(false, s.controlExt),
|
||||
}
|
||||
for _, c := range opened.Inspection.Checks {
|
||||
f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, f.File), dkc.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, f.PlaintextFile), s.plaintext, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := testkit.WriteJSON(filepath.Join(dir, s.name+".json"), f); err != nil {
|
||||
return err
|
||||
}
|
||||
if dkkBytes == nil {
|
||||
return nil
|
||||
}
|
||||
k := res.PortableKey
|
||||
ksum := sha256.Sum256(dkkBytes)
|
||||
kf := testkit.DKKFixture{
|
||||
Description: "portable X25519 .dkk of " + f.File,
|
||||
Spec: testkit.SpecVersion,
|
||||
File: dkkFile,
|
||||
SHA256: hex.EncodeToString(ksum[:]),
|
||||
CredentialID: hex.EncodeToString(k.CredentialID[:]),
|
||||
CapsuleID: hex.EncodeToString(k.CapsuleID[:]),
|
||||
AccessType: k.Type,
|
||||
Material: hex.EncodeToString(k.Material),
|
||||
CapsuleDigest: hex.EncodeToString(k.Verification.CapsuleDigest),
|
||||
Capsule: f.File,
|
||||
ExpectedResult: "opens INNER_ACCESS_AGE of " + f.File + " and yields its CONTROL_CBOR",
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, dkkFile), dkkBytes, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return testkit.WriteJSON(filepath.Join(dir, s.name+".dkk.json"), kf)
|
||||
}
|
||||
|
||||
func decrypt(file []byte, id age.Identity) ([]byte, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(file), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if _, err := b.ReadFrom(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func stanzas(in []*age.Stanza) []testkit.FixtureStanza {
|
||||
out := make([]testkit.FixtureStanza, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func exts(critical bool, in []extension.Extension) []testkit.FixtureExt {
|
||||
var out []testkit.FixtureExt
|
||||
for _, e := range in {
|
||||
out = append(out, testkit.FixtureExt{Critical: critical, ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
// Package testkit builds DateKeys test material: known Quicknet releases,
|
||||
// offline release sources, capsules with arbitrary structural defects, and age
|
||||
// files with edited headers whose MAC is still valid.
|
||||
//
|
||||
// It is internal and exists for tests and fixture generation only.
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// Published Quicknet signatures. They are public data obtained from drand
|
||||
// relays; tests never trust them blindly but verify them with provider.Verify
|
||||
// against the pinned public key.
|
||||
var signatures = map[uint64]string{
|
||||
1000: "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
|
||||
1001: "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41",
|
||||
2000: "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e",
|
||||
}
|
||||
|
||||
// Rounds with a known release, in increasing order.
|
||||
var Rounds = []uint64{1000, 1001, 2000}
|
||||
|
||||
// Release returns the published release of round; it panics for rounds
|
||||
// without a known signature.
|
||||
func Release(round uint64) provider.Release {
|
||||
s, ok := signatures[round]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("testkit: no known release for round %d", round))
|
||||
}
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return provider.Release{Round: round, Signature: b}
|
||||
}
|
||||
|
||||
// Source serves the given releases by round and reports every other round as
|
||||
// unavailable. It counts the requests it receives.
|
||||
type Source struct {
|
||||
Releases map[uint64]provider.Release
|
||||
Calls int
|
||||
}
|
||||
|
||||
// NewSource returns a Source with the given releases.
|
||||
func NewSource(releases ...provider.Release) *Source {
|
||||
s := &Source{Releases: map[uint64]provider.Release{}}
|
||||
for _, r := range releases {
|
||||
s.Releases[r.Round] = r
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Fetch implements provider.ReleaseSource.
|
||||
func (s *Source) Fetch(_ context.Context, _ *profile.Profile, c provider.Condition) (provider.Release, error) {
|
||||
s.Calls++
|
||||
r, ok := s.Releases[c.Round]
|
||||
if !ok {
|
||||
return provider.Release{}, fmt.Errorf("testkit: round %d: %w", c.Round, datekeys.ErrReleaseUnavailable)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Fixed returns a clock stopped at t.
|
||||
func Fixed(t time.Time) func() time.Time { return func() time.Time { return t } }
|
||||
|
||||
// Genesis returns the Quicknet genesis instant, a convenient "now" for
|
||||
// encrypting to rounds that are already published.
|
||||
func Genesis() time.Time { return time.Unix(profile.QuicknetGenesisTime, 0).UTC() }
|
||||
|
||||
// Registry returns the default registry or panics.
|
||||
func Registry() profile.Registry {
|
||||
r, err := profile.Default()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/datekey"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// SpecVersion is the specification the vectors and fixtures implement.
|
||||
const SpecVersion = "0.8.1"
|
||||
|
||||
// RoundVector is one Quicknet resolution vector (spec §65).
|
||||
type RoundVector struct {
|
||||
Name string `json:"name"`
|
||||
Requested string `json:"requested"`
|
||||
Round uint64 `json:"round,omitempty"`
|
||||
Effective string `json:"effective,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RoundVectorFile is testdata/vectors/quicknet_rounds.json.
|
||||
type RoundVectorFile struct {
|
||||
Spec string `json:"spec"`
|
||||
Profile string `json:"profile"`
|
||||
Description string `json:"description"`
|
||||
Vectors []RoundVector `json:"vectors"`
|
||||
}
|
||||
|
||||
// DK1Vector is one dk1_ vector (spec §66). Valid vectors carry the logical
|
||||
// object and every intermediate encoding; invalid ones carry the input and the
|
||||
// expected error.
|
||||
type DK1Vector struct {
|
||||
Name string `json:"name"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Round uint64 `json:"round,omitempty"`
|
||||
CanonicalJSON string `json:"canonical_json,omitempty"`
|
||||
Base64URL string `json:"base64url,omitempty"`
|
||||
Input string `json:"input,omitempty"`
|
||||
DK1 string `json:"dk1,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DK1VectorFile is testdata/vectors/dk1.json.
|
||||
type DK1VectorFile struct {
|
||||
Spec string `json:"spec"`
|
||||
Description string `json:"description"`
|
||||
Vectors []DK1Vector `json:"vectors"`
|
||||
}
|
||||
|
||||
// ProfileVector is testdata/vectors/profile_quicknet.json.
|
||||
type ProfileVector struct {
|
||||
Spec string `json:"spec"`
|
||||
Description string `json:"description"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
Provider string `json:"provider"`
|
||||
Network string `json:"network"`
|
||||
ChainHash string `json:"chain_hash"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PeriodSeconds uint64 `json:"period_seconds"`
|
||||
GenesisTime int64 `json:"genesis_time"`
|
||||
GenesisSeed string `json:"genesis_seed"`
|
||||
Scheme string `json:"scheme"`
|
||||
CanonicalCBOR string `json:"canonical_cbor"`
|
||||
ProfileHash string `json:"profile_hash"`
|
||||
}
|
||||
|
||||
// RoundVectors computes the Quicknet resolution vectors with the implementation.
|
||||
func RoundVectors() RoundVectorFile {
|
||||
p := profile.Quicknet()
|
||||
g := time.Unix(p.GenesisTime, 0).UTC()
|
||||
r1000, _ := datekey.RoundTime(p, 1000)
|
||||
cases := []struct {
|
||||
name string
|
||||
at time.Time
|
||||
}{
|
||||
{"genesis exactly: round 1", g},
|
||||
{"genesis + 1ns: next round", g.Add(time.Nanosecond)},
|
||||
{"genesis + 1s", g.Add(time.Second)},
|
||||
{"genesis + one period: round 2", g.Add(3 * time.Second)},
|
||||
{"genesis + one period + 1ns: round 3", g.Add(3*time.Second + time.Nanosecond)},
|
||||
{"genesis - 1s: before the profile", g.Add(-time.Second)},
|
||||
{"round 1000 boundary exactly", r1000},
|
||||
{"one second before the round 1000 boundary", r1000.Add(-time.Second)},
|
||||
{"one second after the round 1000 boundary", r1000.Add(time.Second)},
|
||||
{"1ns after the round 1000 boundary", r1000.Add(time.Nanosecond)},
|
||||
{"half a second after the round 1000 boundary", r1000.Add(500 * time.Millisecond)},
|
||||
{"normative vector 2030-01-01 (spec §16)", time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{"1ns after 2030-01-01", time.Date(2030, 1, 1, 0, 0, 0, 1, time.UTC)},
|
||||
{"normative vector round 66432123 (spec §16)", time.Date(2029, 12, 16, 7, 15, 33, 0, time.UTC)},
|
||||
{"offset timezone equals UTC instant", time.Date(2026, 10, 22, 19, 0, 0, 1_000_000, time.FixedZone("", 2*3600))},
|
||||
{"last representable round time", time.Date(9999, 12, 31, 23, 59, 57, 0, time.UTC)},
|
||||
{"after the last representable round", time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)},
|
||||
}
|
||||
f := RoundVectorFile{
|
||||
Spec: SpecVersion,
|
||||
Profile: p.ID,
|
||||
Description: "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
|
||||
}
|
||||
for _, c := range cases {
|
||||
v := RoundVector{Name: c.name, Requested: c.at.Format(time.RFC3339Nano)}
|
||||
d, err := datekey.Resolve(p, c.at)
|
||||
if err != nil {
|
||||
v.Error = datekeys.Code(err)
|
||||
} else {
|
||||
v.Round = d.Round
|
||||
v.Effective = d.UnlockAt(p).Format(time.RFC3339Nano)
|
||||
}
|
||||
f.Vectors = append(f.Vectors, v)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// DK1Vectors computes the dk1_ vectors with the implementation.
|
||||
func DK1Vectors() DK1VectorFile {
|
||||
f := DK1VectorFile{
|
||||
Spec: SpecVersion,
|
||||
Description: "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
|
||||
}
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
round uint64
|
||||
}{
|
||||
{"round 1", 1},
|
||||
{"round 1000", 1000},
|
||||
{"normative 2030-01-01 round", 66884212},
|
||||
{"last Quicknet round", profile.Quicknet().MaxRound()},
|
||||
} {
|
||||
d := datekey.DateKey{ProfileID: profile.QuicknetID, Round: v.round}
|
||||
j := d.CanonicalJSON()
|
||||
f.Vectors = append(f.Vectors, DK1Vector{
|
||||
Name: v.name,
|
||||
Network: d.ProfileID,
|
||||
Round: d.Round,
|
||||
CanonicalJSON: string(j),
|
||||
Base64URL: base64.RawURLEncoding.EncodeToString(j),
|
||||
DK1: d.Compact(),
|
||||
})
|
||||
}
|
||||
enc := func(s string) string { return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||
canon := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 66884212}
|
||||
// The canonical JSON of round 1000 is 59 bytes: its Base64 form needs padding
|
||||
// and has unused bits, unlike the 63-byte JSON of round 66884212.
|
||||
r1000 := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
|
||||
bad := []struct{ name, input string }{
|
||||
{"whitespace in JSON", enc(`{"version": 1, "network": "datekeys:quicknet:v1", "round": 66884212}`)},
|
||||
{"keys reordered", enc(`{"network":"datekeys:quicknet:v1","version":1,"round":66884212}`)},
|
||||
{"trailing whitespace", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}` + "\n")},
|
||||
{"exponent notation", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":6.6884212e7}`)},
|
||||
{"fraction notation", enc(`{"version":1.0,"network":"datekeys:quicknet:v1","round":66884212}`)},
|
||||
{"escaped character", enc(strings.Replace(string(canon.CanonicalJSON()), "datekeys:", "datekeys\\"+"u003a", 1))},
|
||||
{"duplicate key", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":1,"round":66884212}`)},
|
||||
{"padded Base64URL", datekey.Prefix + base64.URLEncoding.EncodeToString(r1000.CanonicalJSON())},
|
||||
{"non-zero trailing bits", mangleLastChar(r1000.Compact())},
|
||||
{"extra field", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212,"public_key":"00"}`)},
|
||||
{"missing field", enc(`{"version":1,"network":"datekeys:quicknet:v1"}`)},
|
||||
{"version 2", enc(`{"version":2,"network":"datekeys:quicknet:v1","round":66884212}`)},
|
||||
{"round 0", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":0}`)},
|
||||
{"negative round", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":-1}`)},
|
||||
{"fractional round", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":1.5}`)},
|
||||
{"round above 2^53-1", enc(fmt.Sprintf(`{"version":1,"network":"datekeys:quicknet:v1","round":%d}`, uint64(datekey.MaxRound)+1))},
|
||||
{"round as string", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":"66884212"}`)},
|
||||
{"uppercase network", enc(`{"version":1,"network":"DATEKEYS:QUICKNET:V1","round":66884212}`)},
|
||||
{"trailing data", enc(`{"version":1,"network":"datekeys:quicknet:v1","round":66884212}x`)},
|
||||
{"byte order mark", enc("\ufeff" + `{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`)},
|
||||
{"not Base64", datekey.Prefix + "!!!"},
|
||||
{"missing prefix", canon.Compact()[len(datekey.Prefix):]},
|
||||
{"uppercase prefix", "DK1_" + canon.Compact()[len(datekey.Prefix):]},
|
||||
}
|
||||
for _, b := range bad {
|
||||
_, err := datekey.Parse(b.input)
|
||||
code := datekeys.Code(err)
|
||||
if err == nil {
|
||||
code = "accepted"
|
||||
}
|
||||
f.Vectors = append(f.Vectors, DK1Vector{Name: b.name, Input: b.input, Error: code})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// mangleLastChar changes the last Base64 character to one that decodes to the
|
||||
// same bytes but has non-zero unused bits.
|
||||
func mangleLastChar(s string) string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
last := s[len(s)-1]
|
||||
for i := 0; i < len(alphabet); i++ {
|
||||
c := alphabet[i]
|
||||
if c == last {
|
||||
continue
|
||||
}
|
||||
cand := s[:len(s)-1] + string(c)
|
||||
a, errA := base64.RawURLEncoding.DecodeString(s[len(datekey.Prefix):])
|
||||
b, errB := base64.RawURLEncoding.DecodeString(cand[len(datekey.Prefix):])
|
||||
if errA == nil && errB == nil && string(a) == string(b) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// QuicknetProfileVector computes the profile vector with the implementation.
|
||||
func QuicknetProfileVector() (ProfileVector, error) {
|
||||
p := profile.Quicknet()
|
||||
b, err := p.CanonicalCBOR()
|
||||
if err != nil {
|
||||
return ProfileVector{}, err
|
||||
}
|
||||
h, err := p.Hash()
|
||||
if err != nil {
|
||||
return ProfileVector{}, err
|
||||
}
|
||||
return ProfileVector{
|
||||
Spec: SpecVersion,
|
||||
Description: "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
|
||||
ProfileID: p.ID,
|
||||
Provider: p.Provider,
|
||||
Network: p.Network,
|
||||
ChainHash: p.ChainHashHex(),
|
||||
PublicKey: hex.EncodeToString(p.PublicKey),
|
||||
PeriodSeconds: uint64(p.Period / time.Second),
|
||||
GenesisTime: p.GenesisTime,
|
||||
GenesisSeed: hex.EncodeToString(p.GenesisSeed[:]),
|
||||
Scheme: p.Scheme,
|
||||
CanonicalCBOR: hex.EncodeToString(b),
|
||||
ProfileHash: hex.EncodeToString(h[:]),
|
||||
}, nil
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
// Package profile implements Provider Profiles (spec §10-§13): their
|
||||
// Deterministic CBOR encoding, profile_hash, validation and the locally
|
||||
// pinned registry that forms the client's root of trust.
|
||||
package profile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/drand/drand/v2/common/chain"
|
||||
"github.com/drand/drand/v2/crypto"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
)
|
||||
|
||||
// Schema constants of the Provider Profile CBOR map (spec §11).
|
||||
const (
|
||||
TypeTag = "datekeys-provider-profile"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
// ProviderDrand is the only provider implemented by this module (spec §12).
|
||||
const ProviderDrand = "drand"
|
||||
|
||||
// MaxUnixTime is 9999-12-31T23:59:59Z. Round times beyond it are rejected so
|
||||
// that every effective time stays representable in RFC 3339 and every round
|
||||
// computation stays within int64.
|
||||
const MaxUnixTime int64 = 253402300799
|
||||
|
||||
// Field limits enforced by Validate. They are implementation limits; spec §74
|
||||
// leaves the definitive field limits open.
|
||||
const (
|
||||
maxIDLen = 128
|
||||
maxNameLen = 64
|
||||
maxPublicKeyLen = 1024
|
||||
maxPeriod = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Profile is an immutable Provider Profile (spec §10). Treat values as
|
||||
// read-only; registries hand out copies.
|
||||
type Profile struct {
|
||||
ID string // key 2, profile_id, for example "datekeys:quicknet:v1"
|
||||
Provider string // key 3, for example "drand"
|
||||
Network string // key 4, provider network identifier, for example "quicknet"
|
||||
ChainHash [32]byte // key 5
|
||||
PublicKey []byte // key 6, provider group public key
|
||||
Period time.Duration // key 7, encoded as whole seconds
|
||||
GenesisTime int64 // key 8, Unix seconds
|
||||
Scheme string // key 9, for example "bls-unchained-g1-rfc9380"
|
||||
GenesisSeed [32]byte // key 10
|
||||
}
|
||||
|
||||
// wire is the CBOR map of spec §11. Every key is required.
|
||||
type wire struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
ID string `cbor:"2,keyasint"`
|
||||
Provider string `cbor:"3,keyasint"`
|
||||
Network string `cbor:"4,keyasint"`
|
||||
ChainHash []byte `cbor:"5,keyasint"`
|
||||
PublicKey []byte `cbor:"6,keyasint"`
|
||||
Period uint64 `cbor:"7,keyasint"`
|
||||
GenesisTime int64 `cbor:"8,keyasint"`
|
||||
Scheme string `cbor:"9,keyasint"`
|
||||
GenesisSeed []byte `cbor:"10,keyasint"`
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of p.
|
||||
func (p *Profile) Clone() *Profile {
|
||||
c := *p
|
||||
c.PublicKey = bytes.Clone(p.PublicKey)
|
||||
return &c
|
||||
}
|
||||
|
||||
// CanonicalCBOR returns the exact Deterministic CBOR bytes of spec §11.
|
||||
func (p *Profile) CanonicalCBOR() ([]byte, error) {
|
||||
if p.Period <= 0 || p.Period%time.Second != 0 {
|
||||
return nil, fmt.Errorf("profile: period %s is not a positive whole number of seconds", p.Period)
|
||||
}
|
||||
return codec.Marshal(wire{
|
||||
Type: TypeTag,
|
||||
Version: SchemaVersion,
|
||||
ID: p.ID,
|
||||
Provider: p.Provider,
|
||||
Network: p.Network,
|
||||
ChainHash: p.ChainHash[:],
|
||||
PublicKey: p.PublicKey,
|
||||
Period: uint64(p.Period / time.Second),
|
||||
GenesisTime: p.GenesisTime,
|
||||
Scheme: p.Scheme,
|
||||
GenesisSeed: p.GenesisSeed[:],
|
||||
})
|
||||
}
|
||||
|
||||
// Hash returns profile_hash = SHA-256(exact_deterministic_cbor_bytes) (spec §11).
|
||||
//
|
||||
// A profile_hash declared by a remote party has no security value; security
|
||||
// comes from the profile pinned locally (spec §11, §13).
|
||||
func (p *Profile) Hash() ([32]byte, error) {
|
||||
b, err := p.CanonicalCBOR()
|
||||
if err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
return sha256.Sum256(b), nil
|
||||
}
|
||||
|
||||
// Decode parses the Deterministic CBOR encoding of a Provider Profile and
|
||||
// validates it. It does not make the profile trusted: only a Registry built by
|
||||
// the caller does (spec §13).
|
||||
func Decode(b []byte) (*Profile, error) {
|
||||
if err := codec.CheckSchema(b, TypeTag, SchemaVersion); err != nil {
|
||||
return nil, fmt.Errorf("profile: %w", err)
|
||||
}
|
||||
var w wire
|
||||
if err := codec.Unmarshal(b, &w); err != nil {
|
||||
return nil, fmt.Errorf("profile: %w", err)
|
||||
}
|
||||
if len(w.ChainHash) != 32 || len(w.GenesisSeed) != 32 {
|
||||
return nil, fmt.Errorf("profile: chain hash and genesis seed must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
if w.Period == 0 || w.Period > uint64(maxPeriod/time.Second) {
|
||||
return nil, fmt.Errorf("profile: period %d s out of range: %w", w.Period, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
p := &Profile{
|
||||
ID: w.ID,
|
||||
Provider: w.Provider,
|
||||
Network: w.Network,
|
||||
PublicKey: w.PublicKey,
|
||||
Period: time.Duration(w.Period) * time.Second,
|
||||
GenesisTime: w.GenesisTime,
|
||||
Scheme: w.Scheme,
|
||||
}
|
||||
copy(p.ChainHash[:], w.ChainHash)
|
||||
copy(p.GenesisSeed[:], w.GenesisSeed)
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Validate checks the syntax of every field and, for drand profiles, that the
|
||||
// scheme is supported, that the public key is a valid group element and that
|
||||
// the chain hash is the drand chain-info hash of the other parameters. The
|
||||
// last check is the self-verification kept from the prototype: a profile whose
|
||||
// parameters do not produce its own chain hash is rejected.
|
||||
func (p *Profile) Validate() error {
|
||||
if !ValidID(p.ID) {
|
||||
return fmt.Errorf("profile: invalid profile_id %q: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if !validName(p.Provider) || !validName(p.Network) || !validName(p.Scheme) {
|
||||
return fmt.Errorf("profile %s: invalid provider, network or scheme name: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if len(p.PublicKey) == 0 || len(p.PublicKey) > maxPublicKeyLen {
|
||||
return fmt.Errorf("profile %s: invalid public key length %d: %w", p.ID, len(p.PublicKey), datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if p.Period <= 0 || p.Period > maxPeriod || p.Period%time.Second != 0 {
|
||||
return fmt.Errorf("profile %s: invalid period %s: %w", p.ID, p.Period, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if p.GenesisTime <= 0 || p.GenesisTime >= MaxUnixTime {
|
||||
return fmt.Errorf("profile %s: invalid genesis time %d: %w", p.ID, p.GenesisTime, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if p.Provider != ProviderDrand {
|
||||
return fmt.Errorf("profile %s: unsupported provider %q: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
return p.validateDrand()
|
||||
}
|
||||
|
||||
func (p *Profile) validateDrand() error {
|
||||
scheme, err := p.DrandScheme()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch scheme.Name {
|
||||
case crypto.SigsOnG1ID, crypto.UnchainedSchemeID, crypto.ShortSigSchemeID:
|
||||
default:
|
||||
return fmt.Errorf("profile %s: scheme %q is not supported by tlock: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
key := scheme.KeyGroup.Point()
|
||||
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
|
||||
return fmt.Errorf("profile %s: public key is not a %s group element: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if key.Equal(key.Null()) {
|
||||
return fmt.Errorf("profile %s: public key is the identity element: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
info := chain.Info{
|
||||
PublicKey: key,
|
||||
ID: p.Network,
|
||||
Period: p.Period,
|
||||
Scheme: p.Scheme,
|
||||
GenesisTime: p.GenesisTime,
|
||||
GenesisSeed: p.GenesisSeed[:],
|
||||
}
|
||||
if !bytes.Equal(info.Hash(), p.ChainHash[:]) {
|
||||
return fmt.Errorf("profile %s: parameters hash to chain %s, not the pinned %s: %w",
|
||||
p.ID, info.HashString(), hex.EncodeToString(p.ChainHash[:]), datekeys.ErrProfileMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DrandScheme returns a fresh drand scheme object for p. Fresh objects avoid
|
||||
// sharing mutable kyber state between callers.
|
||||
func (p *Profile) DrandScheme() (*crypto.Scheme, error) {
|
||||
if p.Provider != ProviderDrand {
|
||||
return nil, fmt.Errorf("profile %s: provider %q is not drand: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
scheme, err := crypto.SchemeFromName(p.Scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
return scheme, nil
|
||||
}
|
||||
|
||||
// ChainHashHex returns the lowercase hexadecimal chain hash, the form used in
|
||||
// tlock stanzas and drand relay URLs.
|
||||
func (p *Profile) ChainHashHex() string { return hex.EncodeToString(p.ChainHash[:]) }
|
||||
|
||||
// MaxRound is the last round whose round time is not after MaxUnixTime.
|
||||
func (p *Profile) MaxRound() uint64 {
|
||||
period := int64(p.Period / time.Second)
|
||||
if period <= 0 || p.GenesisTime >= MaxUnixTime {
|
||||
return 0
|
||||
}
|
||||
return uint64((MaxUnixTime-p.GenesisTime)/period) + 1
|
||||
}
|
||||
|
||||
// ValidID reports whether s is a syntactically valid profile_id: 1 to 128
|
||||
// characters from [a-z0-9:._-], starting with a letter or digit. The restricted
|
||||
// alphabet keeps the dk1_ JSON form free of escapes (spec §18, §19).
|
||||
func ValidID(s string) bool {
|
||||
if len(s) == 0 || len(s) > maxIDLen || !alnum(s[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !alnum(c) && c != ':' && c != '.' && c != '_' && c != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validName(s string) bool {
|
||||
if len(s) == 0 || len(s) > maxNameLen || !alnum(s[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !alnum(c) && c != '.' && c != '_' && c != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func alnum(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') }
|
||||
@ -0,0 +1,186 @@
|
||||
package profile_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/codec"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
func TestQuicknetMatchesGoldenVector(t *testing.T) {
|
||||
var golden testkit.ProfileVector
|
||||
if err := testkit.ReadJSON("../testdata/vectors/profile_quicknet.json", &golden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := testkit.QuicknetProfileVector()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != golden {
|
||||
t.Fatalf("Quicknet profile vector changed:\n got %+v\nwant %+v", got, golden)
|
||||
}
|
||||
if golden.ProfileHash != profile.QuicknetProfileHash {
|
||||
t.Fatalf("pinned hash %s differs from golden %s", profile.QuicknetProfileHash, golden.ProfileHash)
|
||||
}
|
||||
// Spec §12 values, restated independently of the constants.
|
||||
p := profile.Quicknet()
|
||||
if p.ID != "datekeys:quicknet:v1" || p.Provider != "drand" || p.Network != "quicknet" ||
|
||||
p.Period != 3*time.Second || p.GenesisTime != 1692803367 || p.Scheme != "bls-unchained-g1-rfc9380" ||
|
||||
p.ChainHashHex() != "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" ||
|
||||
hex.EncodeToString(p.GenesisSeed[:]) != "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e" {
|
||||
t.Fatal("Quicknet parameters differ from spec §12")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuicknetCBORLayout(t *testing.T) {
|
||||
b, err := profile.Quicknet().CanonicalCBOR()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Map of 11 entries whose keys 0..10 appear in order (spec §11).
|
||||
if b[0] != 0xab {
|
||||
t.Fatalf("map header %#x", b[0])
|
||||
}
|
||||
var m map[uint64]any
|
||||
if err := codec.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[uint64]any{0: "datekeys-provider-profile", 1: uint64(1), 2: "datekeys:quicknet:v1", 3: "drand", 4: "quicknet", 7: uint64(3), 8: uint64(1692803367), 9: "bls-unchained-g1-rfc9380"}
|
||||
for k, v := range want {
|
||||
if m[k] != v {
|
||||
t.Errorf("key %d = %v, want %v", k, m[k], v)
|
||||
}
|
||||
}
|
||||
for _, k := range []uint64{5, 6, 10} {
|
||||
if _, ok := m[k].([]byte); !ok {
|
||||
t.Errorf("key %d is not a byte string", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRoundTrip(t *testing.T) {
|
||||
b, _ := profile.Quicknet().CanonicalCBOR()
|
||||
p, err := profile.Decode(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2, _ := p.CanonicalCBOR()
|
||||
if !bytes.Equal(b, b2) {
|
||||
t.Fatal("Decode/CanonicalCBOR is not the identity")
|
||||
}
|
||||
// The same values with a different key order or integer width are rejected.
|
||||
period, genesis := []byte{0x07, 0x03}, []byte{0x08, 0x1a, 0x64, 0xe6, 0x21, 0x27}
|
||||
pair := append(append([]byte(nil), period...), genesis...)
|
||||
if !bytes.Contains(b, pair) {
|
||||
t.Fatal("unexpected layout")
|
||||
}
|
||||
swapped := bytes.Replace(b, pair, append(append([]byte(nil), genesis...), period...), 1)
|
||||
widened := bytes.Replace(b, period, []byte{0x07, 0x18, 0x03}, 1)
|
||||
for name, in := range map[string][]byte{"keys out of order": swapped, "integer not in shortest form": widened} {
|
||||
if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
future, _ := codec.Marshal(map[uint64]any{0: profile.TypeTag, 1: uint64(2)})
|
||||
if _, err := profile.Decode(future); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
|
||||
t.Fatalf("future schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsTamperedProfiles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(p *profile.Profile)
|
||||
want error
|
||||
}{
|
||||
{"chain hash changed", func(p *profile.Profile) { p.ChainHash[0] ^= 1 }, datekeys.ErrProfileMismatch},
|
||||
{"genesis seed changed", func(p *profile.Profile) { p.GenesisSeed[0] ^= 1 }, datekeys.ErrProfileMismatch},
|
||||
{"genesis time changed", func(p *profile.Profile) { p.GenesisTime++ }, datekeys.ErrProfileMismatch},
|
||||
{"period changed", func(p *profile.Profile) { p.Period = 30 * time.Second }, datekeys.ErrProfileMismatch},
|
||||
{"network id changed", func(p *profile.Profile) { p.Network = "default" }, datekeys.ErrProfileMismatch},
|
||||
{"public key not a point", func(p *profile.Profile) { p.PublicKey = bytes.Repeat([]byte{0xff}, 96) }, datekeys.ErrUnknownProfile},
|
||||
{"public key truncated", func(p *profile.Profile) { p.PublicKey = p.PublicKey[:95] }, datekeys.ErrUnknownProfile},
|
||||
{"unknown scheme", func(p *profile.Profile) { p.Scheme = "bls-unchained-g9" }, datekeys.ErrUnknownProfile},
|
||||
{"scheme without tlock support", func(p *profile.Profile) { p.Scheme = "pedersen-bls-chained" }, datekeys.ErrUnknownProfile},
|
||||
{"unknown provider", func(p *profile.Profile) { p.Provider = "roughtime" }, datekeys.ErrUnknownProfile},
|
||||
{"sub-second period", func(p *profile.Profile) { p.Period = 1500 * time.Millisecond }, datekeys.ErrUnknownProfile},
|
||||
{"uppercase profile id", func(p *profile.Profile) { p.ID = "DateKeys:quicknet:v1" }, datekeys.ErrUnknownProfile},
|
||||
{"profile id with quote", func(p *profile.Profile) { p.ID = `datekeys:"quicknet` }, datekeys.ErrUnknownProfile},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
tc.mutate(p)
|
||||
if err := p.Validate(); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
reg, err := profile.Default()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, ok := reg.Lookup(profile.QuicknetID)
|
||||
if !ok {
|
||||
t.Fatal("Quicknet not pinned")
|
||||
}
|
||||
// Lookup hands out copies: mutating one does not alter the registry.
|
||||
p.PublicKey[0] ^= 0xff
|
||||
p.ChainHash[0] ^= 0xff
|
||||
again, _ := reg.Lookup(profile.QuicknetID)
|
||||
if err := again.Validate(); err != nil {
|
||||
t.Fatalf("registry state was mutated through a lookup: %v", err)
|
||||
}
|
||||
if _, ok := reg.Lookup("datekeys:evmnet:v1"); ok {
|
||||
t.Fatal("unknown profile found")
|
||||
}
|
||||
|
||||
var wrong [32]byte
|
||||
if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: wrong}); !errors.Is(err, datekeys.ErrProfileMismatch) {
|
||||
t.Fatalf("wrong pinned hash accepted: %v", err)
|
||||
}
|
||||
h, _ := profile.Quicknet().Hash()
|
||||
if _, err := profile.NewRegistry(profile.Pin{Profile: profile.Quicknet(), Hash: h}, profile.Pin{Profile: profile.Quicknet(), Hash: h}); err == nil {
|
||||
t.Fatal("duplicate profile accepted")
|
||||
}
|
||||
bad := profile.Quicknet()
|
||||
bad.ChainHash[31] ^= 1
|
||||
bh, _ := bad.Hash()
|
||||
if _, err := profile.NewRegistry(profile.Pin{Profile: bad, Hash: bh}); !errors.Is(err, datekeys.ErrProfileMismatch) {
|
||||
t.Fatalf("self-inconsistent profile pinned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxRound(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
if got := p.MaxRound(); got != 83903165811 {
|
||||
t.Fatalf("MaxRound = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDecode(f *testing.F) {
|
||||
b, _ := profile.Quicknet().CanonicalCBOR()
|
||||
f.Add(b)
|
||||
f.Add(b[:100])
|
||||
f.Fuzz(func(t *testing.T, in []byte) {
|
||||
p, err := profile.Decode(in)
|
||||
if err != nil {
|
||||
if datekeys.Code(err) == "" {
|
||||
t.Fatalf("error without a normative code: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
out, err := p.CanonicalCBOR()
|
||||
if err != nil || !bytes.Equal(out, in) {
|
||||
t.Fatal("accepted a profile that does not re-encode to its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Quicknet Provider Profile V1 parameters, pinned in the binary (spec §12).
|
||||
// No relay, API or ciphertext can replace them (spec §13, §35).
|
||||
const (
|
||||
QuicknetID = "datekeys:quicknet:v1"
|
||||
QuicknetNetwork = "quicknet"
|
||||
QuicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
QuicknetPublicKey = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"
|
||||
QuicknetGenesisSeed = "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e"
|
||||
QuicknetGenesisTime = int64(1692803367)
|
||||
QuicknetPeriod = 3 * time.Second
|
||||
QuicknetScheme = "bls-unchained-g1-rfc9380"
|
||||
|
||||
// QuicknetProfileHash is profile_hash of the Quicknet profile: SHA-256 of
|
||||
// its exact Deterministic CBOR (spec §11). It is the first official vector,
|
||||
// frozen in testdata/vectors/profile_quicknet.json, and part of the root of
|
||||
// trust of spec §13: Default refuses to build if the compiled-in
|
||||
// parameters do not reproduce it.
|
||||
QuicknetProfileHash = "4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4"
|
||||
)
|
||||
|
||||
// Quicknet returns a fresh copy of the pinned Quicknet Provider Profile V1.
|
||||
// A function instead of a package variable keeps the root of trust free of
|
||||
// shared mutable state.
|
||||
func Quicknet() *Profile {
|
||||
p := &Profile{
|
||||
ID: QuicknetID,
|
||||
Provider: ProviderDrand,
|
||||
Network: QuicknetNetwork,
|
||||
PublicKey: mustHex(QuicknetPublicKey),
|
||||
Period: QuicknetPeriod,
|
||||
GenesisTime: QuicknetGenesisTime,
|
||||
Scheme: QuicknetScheme,
|
||||
}
|
||||
copy(p.ChainHash[:], mustHex(QuicknetChainHash))
|
||||
copy(p.GenesisSeed[:], mustHex(QuicknetGenesisSeed))
|
||||
return p
|
||||
}
|
||||
|
||||
// mustHex decodes compile-time constants only.
|
||||
func mustHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic("profile: invalid pinned constant: " + err.Error())
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
)
|
||||
|
||||
// Registry resolves a profile_id to a locally trusted Provider Profile. The
|
||||
// client MUST NOT accept a profile or key supplied by the endpoint that
|
||||
// delivers the release (spec §13); a Registry is built by the caller from
|
||||
// pinned data only.
|
||||
type Registry interface {
|
||||
// Lookup returns a copy of the pinned profile for id.
|
||||
Lookup(id string) (*Profile, bool)
|
||||
}
|
||||
|
||||
// Pin is a profile together with the profile_hash the caller expects it to
|
||||
// have (spec §13: the profile hash is known in advance).
|
||||
type Pin struct {
|
||||
Profile *Profile
|
||||
Hash [32]byte
|
||||
}
|
||||
|
||||
type pinned struct {
|
||||
profile *Profile
|
||||
hash [32]byte
|
||||
}
|
||||
|
||||
type registry struct {
|
||||
m map[string]pinned
|
||||
}
|
||||
|
||||
// NewRegistry validates every profile, checks it against its expected
|
||||
// profile_hash and returns an immutable registry holding private copies.
|
||||
func NewRegistry(pins ...Pin) (Registry, error) {
|
||||
r := ®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})
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
// Package drand fetches Quicknet releases directly from public drand relays
|
||||
// (spec §48, §49). HTTP is an untrusted transport: every response is verified
|
||||
// locally with provider.Verify against the pinned profile before it is
|
||||
// returned, and authenticity comes from the BLS signature, never from the
|
||||
// hostname (spec §48, §52).
|
||||
package drand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
// Limits of a single relay exchange.
|
||||
const (
|
||||
DefaultTimeout = 6 * time.Second
|
||||
maxResponseSize = 8 << 10
|
||||
)
|
||||
|
||||
// DefaultRelays returns the public drand relays used when none are given.
|
||||
func DefaultRelays() []string {
|
||||
return []string{"https://api.drand.sh", "https://api2.drand.sh", "https://api3.drand.sh"}
|
||||
}
|
||||
|
||||
// Client races independent relays and returns the first release that passes
|
||||
// local verification. It implements provider.ReleaseSource.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
relays []string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var _ provider.ReleaseSource = (*Client)(nil)
|
||||
|
||||
// New returns a client for the given relay base URLs, or DefaultRelays.
|
||||
// Redirects are not followed.
|
||||
func New(relays ...string) *Client {
|
||||
return NewWithHTTPClient(&http.Client{
|
||||
Timeout: DefaultTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}, relays...)
|
||||
}
|
||||
|
||||
// NewWithHTTPClient is New with a caller-supplied HTTP client.
|
||||
func NewWithHTTPClient(hc *http.Client, relays ...string) *Client {
|
||||
if len(relays) == 0 {
|
||||
relays = DefaultRelays()
|
||||
}
|
||||
return &Client{http: hc, relays: append([]string(nil), relays...), timeout: DefaultTimeout}
|
||||
}
|
||||
|
||||
// Fetch implements provider.ReleaseSource. Only a cryptographically valid
|
||||
// release for exactly the requested round wins the race.
|
||||
func (c *Client) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
|
||||
if p.Provider != profile.ProviderDrand {
|
||||
return provider.Release{}, fmt.Errorf("drand: profile %s is not a drand profile: %w", p.ID, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
if cond.Round == 0 || cond.Round > p.MaxRound() {
|
||||
return provider.Release{}, fmt.Errorf("drand: round %d outside the range of %s: %w", cond.Round, p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
type result struct {
|
||||
release provider.Release
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, len(c.relays))
|
||||
for _, relay := range c.relays {
|
||||
go func(relay string) {
|
||||
r, err := c.fetch(ctx, relay, p, cond)
|
||||
ch <- result{r, err}
|
||||
}(relay)
|
||||
}
|
||||
var failures []error
|
||||
for range c.relays {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return provider.Release{}, fmt.Errorf("drand: %w: %w", datekeys.ErrReleaseUnavailable, ctx.Err())
|
||||
case r := <-ch:
|
||||
if r.err == nil {
|
||||
return r.release, nil
|
||||
}
|
||||
failures = append(failures, r.err)
|
||||
}
|
||||
}
|
||||
return provider.Release{}, fmt.Errorf("drand: no relay returned a verified release for round %d: %w: %w",
|
||||
cond.Round, datekeys.ErrReleaseUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, relay string, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
|
||||
url := strings.TrimRight(relay, "/") + "/v2/chains/" + p.ChainHashHex() + "/rounds/" + strconv.FormatUint(cond.Round, 10)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return provider.Release{}, fmt.Errorf("%s: HTTP %d", relay, res.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(res.Body, maxResponseSize+1))
|
||||
if err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
||||
}
|
||||
if len(b) > maxResponseSize {
|
||||
return provider.Release{}, fmt.Errorf("%s: response larger than %d bytes: %w", relay, maxResponseSize, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
var wire struct {
|
||||
Round uint64 `json:"round"`
|
||||
Signature string `json:"signature"`
|
||||
Randomness string `json:"randomness"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &wire); err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: malformed response: %w", relay, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
sig, err := hex.DecodeString(wire.Signature)
|
||||
if err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: signature is not hex: %w", relay, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
release := provider.Release{Round: wire.Round, Signature: sig}
|
||||
if err := provider.Verify(p, cond, release); err != nil {
|
||||
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
||||
}
|
||||
// The v2 API omits randomness; if a relay supplies it, it must be
|
||||
// SHA-256 of the verified signature.
|
||||
if wire.Randomness != "" {
|
||||
sum := sha256.Sum256(sig)
|
||||
if !strings.EqualFold(wire.Randomness, hex.EncodeToString(sum[:])) {
|
||||
return provider.Release{}, fmt.Errorf("%s: randomness does not match the signature: %w", relay, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package drand_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
"github.com/datekeys/datekeys-go/provider/drand"
|
||||
)
|
||||
|
||||
var sig1000 = hex.EncodeToString(testkit.Release(1000).Signature)
|
||||
|
||||
func serve(t *testing.T, h http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
s := httptest.NewServer(h)
|
||||
t.Cleanup(s.Close)
|
||||
return s.URL
|
||||
}
|
||||
|
||||
func TestRaceWaitsForAValidSignature(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
bad := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, strings.Repeat("0", 96))
|
||||
})
|
||||
good := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v2/chains/"+p.ChainHashHex()+"/rounds/1000" {
|
||||
t.Errorf("request not pinned to the chain: %s", r.URL.Path)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, sig1000)
|
||||
})
|
||||
c := drand.NewWithHTTPClient(http.DefaultClient, bad, good)
|
||||
rel, err := c.Fetch(context.Background(), p, provider.Condition{Round: 1000})
|
||||
if err != nil || hex.EncodeToString(rel.Signature) != sig1000 || rel.Round != 1000 {
|
||||
t.Fatalf("race failed: %+v %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectMalformedRelayResponses(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, payload := range []string{
|
||||
`{"round":999,"signature":"` + sig1000 + `"}`,
|
||||
`{"round":1000,"signature":"` + sig1000 + `","randomness":"fake"}`,
|
||||
`{"round":1000,"signature":"fake"}`,
|
||||
`{"round":1000,"signature":"` + sig1000 + `"} {}`,
|
||||
`{"round":1000,"signature":"` + hex.EncodeToString(testkit.Release(1001).Signature) + `"}`,
|
||||
strings.Repeat("x", 9000),
|
||||
``,
|
||||
} {
|
||||
url := serve(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, payload) })
|
||||
c := drand.NewWithHTTPClient(http.DefaultClient, url)
|
||||
_, err := c.Fetch(context.Background(), p, provider.Condition{Round: 1000})
|
||||
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
|
||||
t.Errorf("accepted or misclassified %.40q: %v", payload, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomnessMustMatchWhenPresent(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
sum := "e1f1cb5a9ddd0e2ae4a4a8c5bf42e8e1e1ed8b5a9f1f7da1a3f1d2bb0f1b2c3d"
|
||||
url := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `{"round":1000,"signature":"%s","randomness":"%s"}`, sig1000, sum)
|
||||
})
|
||||
_, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(context.Background(), p, provider.Condition{Round: 1000})
|
||||
if !errors.Is(err, datekeys.ErrReleaseInvalid) {
|
||||
t.Fatalf("wrong randomness accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailabilityAndCancellation(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, status := range []int{404, 425, 503} {
|
||||
url := serve(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) })
|
||||
_, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(context.Background(), p, provider.Condition{Round: 1000})
|
||||
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
|
||||
t.Fatalf("HTTP %d: %v", status, err)
|
||||
}
|
||||
}
|
||||
url := serve(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(time.Second) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := drand.NewWithHTTPClient(http.DefaultClient, url).Fetch(ctx, p, provider.Condition{Round: 1000}); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
|
||||
t.Fatalf("ignored cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectsAreNotFollowed(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
target := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `{"round":1000,"signature":"%s"}`, sig1000)
|
||||
})
|
||||
redirect := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target+r.URL.Path, http.StatusFound)
|
||||
})
|
||||
if _, err := drand.New(redirect).Fetch(context.Background(), p, provider.Condition{Round: 1000}); err == nil {
|
||||
t.Fatal("followed a redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidRequests(t *testing.T) {
|
||||
c := drand.New("http://127.0.0.1:1")
|
||||
p := profile.Quicknet()
|
||||
if _, err := c.Fetch(context.Background(), p, provider.Condition{Round: 0}); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Fatalf("round 0: %v", err)
|
||||
}
|
||||
other := profile.Quicknet()
|
||||
other.Provider = "other"
|
||||
if _, err := c.Fetch(context.Background(), other, provider.Condition{Round: 1}); !errors.Is(err, datekeys.ErrUnknownProfile) {
|
||||
t.Fatalf("non-drand profile: %v", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
//go:build integration
|
||||
|
||||
package drand_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
"github.com/datekeys/datekeys-go/provider/drand"
|
||||
)
|
||||
|
||||
// Every default relay serves the same, locally verified release.
|
||||
func TestLiveRelays(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, relay := range drand.DefaultRelays() {
|
||||
for _, round := range testkit.Rounds {
|
||||
rel, err := drand.New(relay).Fetch(context.Background(), p, provider.Condition{Round: round})
|
||||
if err != nil {
|
||||
t.Fatalf("%s round %d: %v", relay, round, err)
|
||||
}
|
||||
if !bytes.Equal(rel.Signature, testkit.Release(round).Signature) {
|
||||
t.Fatalf("%s round %d: unexpected signature", relay, round)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
// Package provider defines conditions, releases and release sources (spec §9,
|
||||
// §45-§52) and the local verification every release must pass.
|
||||
//
|
||||
// A release is never trusted because of where it came from: the DateKeys API,
|
||||
// a cache or a relay are untrusted transports (spec §3, §48, §52). A remote
|
||||
// "verified: true" has no security value (spec §51).
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/drand/drand/v2/common"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
)
|
||||
|
||||
// Condition is the time condition of a Quicknet-style profile: a round.
|
||||
type Condition struct {
|
||||
Round uint64
|
||||
}
|
||||
|
||||
// Release is the material that satisfies a condition. For drand it is the
|
||||
// BLS signature of the round.
|
||||
type Release struct {
|
||||
Round uint64
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
// ReleaseSource fetches the release of a condition. Implementations need not
|
||||
// verify it; callers always do, with Verify.
|
||||
//
|
||||
// Errors should wrap datekeys.ErrReleaseUnavailable when the release cannot be
|
||||
// obtained, for example because the round is not published yet.
|
||||
type ReleaseSource interface {
|
||||
Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error)
|
||||
}
|
||||
|
||||
// ReleaseSourceFunc adapts a function to ReleaseSource.
|
||||
type ReleaseSourceFunc func(ctx context.Context, p *profile.Profile, c Condition) (Release, error)
|
||||
|
||||
// Fetch calls f.
|
||||
func (f ReleaseSourceFunc) Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error) {
|
||||
return f(ctx, p, c)
|
||||
}
|
||||
|
||||
// Verify checks a release locally against the pinned profile (spec §17, §51):
|
||||
// the expected round, the signature length of the scheme and a valid BLS
|
||||
// signature under the pinned public key. The chain hash is covered because
|
||||
// the pinned public key is bound to it by profile.Validate.
|
||||
func Verify(p *profile.Profile, c Condition, r Release) error {
|
||||
if c.Round == 0 || c.Round > p.MaxRound() {
|
||||
return fmt.Errorf("provider: round %d outside the range of %s: %w", c.Round, p.ID, datekeys.ErrDateKeyInvalid)
|
||||
}
|
||||
if r.Round != c.Round {
|
||||
return fmt.Errorf("provider: release for round %d, expected %d: %w", r.Round, c.Round, datekeys.ErrRoundMismatch)
|
||||
}
|
||||
scheme, err := p.DrandScheme()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if want := scheme.SigGroup.PointLen(); len(r.Signature) != want {
|
||||
return fmt.Errorf("provider: signature is %d bytes, %s uses %d: %w", len(r.Signature), scheme.Name, want, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
key := scheme.KeyGroup.Point()
|
||||
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
|
||||
return fmt.Errorf("provider: pinned public key of %s: %v: %w", p.ID, err, datekeys.ErrUnknownProfile)
|
||||
}
|
||||
beacon := &common.Beacon{Round: r.Round, Signature: r.Signature}
|
||||
if err := scheme.VerifyBeacon(beacon, key); err != nil {
|
||||
return fmt.Errorf("provider: BLS signature of round %d does not verify under %s: %w", r.Round, p.ID, datekeys.ErrReleaseInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
datekeys "github.com/datekeys/datekeys-go"
|
||||
"github.com/datekeys/datekeys-go/internal/testkit"
|
||||
"github.com/datekeys/datekeys-go/profile"
|
||||
"github.com/datekeys/datekeys-go/provider"
|
||||
)
|
||||
|
||||
func TestVerifyPublishedReleases(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
for _, round := range testkit.Rounds {
|
||||
if err := provider.Verify(p, provider.Condition{Round: round}, testkit.Release(round)); err != nil {
|
||||
t.Fatalf("round %d: %v", round, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
p := profile.Quicknet()
|
||||
r1000, r1001 := testkit.Release(1000), testkit.Release(1001)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cond uint64
|
||||
rel provider.Release
|
||||
want error
|
||||
}{
|
||||
// Spec §17: a valid signature of another round is not enough.
|
||||
{"valid release of another round", 1000, r1001, datekeys.ErrRoundMismatch},
|
||||
// A signature of round 1001 relabelled as round 1000.
|
||||
{"signature of another round relabelled", 1000, provider.Release{Round: 1000, Signature: r1001.Signature}, datekeys.ErrReleaseInvalid},
|
||||
{"all-zero signature", 1000, provider.Release{Round: 1000, Signature: make([]byte, 48)}, datekeys.ErrReleaseInvalid},
|
||||
{"flipped bit", 1000, provider.Release{Round: 1000, Signature: flip(r1000.Signature)}, datekeys.ErrReleaseInvalid},
|
||||
{"short signature", 1000, provider.Release{Round: 1000, Signature: r1000.Signature[:47]}, datekeys.ErrReleaseInvalid},
|
||||
{"G2-sized signature", 1000, provider.Release{Round: 1000, Signature: bytes.Repeat(r1000.Signature, 2)}, datekeys.ErrReleaseInvalid},
|
||||
{"round zero", 0, provider.Release{Round: 0, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid},
|
||||
{"round beyond the profile", p.MaxRound() + 1, provider.Release{Round: p.MaxRound() + 1, Signature: r1000.Signature}, datekeys.ErrDateKeyInvalid},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := provider.Verify(p, provider.Condition{Round: tc.cond}, tc.rel); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyUsesThePinnedKeyOnly(t *testing.T) {
|
||||
// A profile with another public key rejects the genuine signature.
|
||||
p := profile.Quicknet()
|
||||
p.PublicKey = append([]byte(nil), p.PublicKey...)
|
||||
p.PublicKey[len(p.PublicKey)-1] ^= 1
|
||||
err := provider.Verify(p, provider.Condition{Round: 1000}, testkit.Release(1000))
|
||||
if err == nil {
|
||||
t.Fatal("verified under a different key")
|
||||
}
|
||||
}
|
||||
|
||||
func flip(b []byte) []byte {
|
||||
c := append([]byte(nil), b...)
|
||||
c[10] ^= 0x01
|
||||
return c
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs every parser fuzz target for the given duration each (default 20s).
|
||||
# FUZZ_PARALLEL limits the number of fuzzing workers; each one keeps a
|
||||
# 100 MB shared-memory file in the temporary directory.
|
||||
# FUZZ_MINIMIZE is the time spent minimising each new input (default 0):
|
||||
# minimisation stalls FuzzInspect for minutes, so short runs skip it; a
|
||||
# failing input is still saved under testdata/fuzz as found.
|
||||
set -euo pipefail
|
||||
duration="${1:-20s}"
|
||||
parallel=(-fuzzminimizetime="${FUZZ_MINIMIZE:-0}")
|
||||
if [[ -n "${FUZZ_PARALLEL:-}" ]]; then
|
||||
parallel+=(-parallel "$FUZZ_PARALLEL")
|
||||
fi
|
||||
targets=(
|
||||
"./codec FuzzValid"
|
||||
"./profile FuzzDecode"
|
||||
"./datekey FuzzParse"
|
||||
"./agewrap FuzzStanzas"
|
||||
"./accesskey FuzzDecode"
|
||||
"./capsule FuzzParsePrelude"
|
||||
"./capsule FuzzDecodeHeader"
|
||||
"./capsule FuzzDecodeControl"
|
||||
"./capsule FuzzInspect"
|
||||
)
|
||||
for t in "${targets[@]}"; do
|
||||
read -r pkg name <<<"$t"
|
||||
echo "== $pkg $name ($duration)"
|
||||
go test -run='^$' -fuzz="^${name}\$" -fuzztime="$duration" "${parallel[@]}" "$pkg"
|
||||
done
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
# Specification
|
||||
|
||||
- `DateKeys_Protocol_Specification_v0.8.1.md`: frozen copy of the normative
|
||||
draft v0.8.1 (25 September 2026) implemented by this module. SHA-256:
|
||||
`8beee534efecf19dcdee765f7d198b3ce4da12c799ada525e64878dc263f6fad`.
|
||||
- `datekeys.cddl`: the CBOR schemas of the specification as implemented, with
|
||||
the encoding rules CDDL cannot express.
|
||||
|
||||
The specification is licensed under the Creative Commons Attribution 4.0
|
||||
International License (CC-BY-4.0): <https://creativecommons.org/licenses/by/4.0/>.
|
||||
The code of this repository is licensed separately under Apache-2.0.
|
||||
|
||||
Changes to the specification follow its §76: a normative change should answer a
|
||||
reproducible case found through the reference implementation, the CDDL, a
|
||||
fixture, a mutation test, an interoperability test, fuzzing, a second
|
||||
implementation or an external review.
|
||||
@ -0,0 +1,98 @@
|
||||
; DateKeys Protocol Specification v0.8.1 - CBOR schemas (RFC 8610 CDDL).
|
||||
;
|
||||
; Normative companion of spec/DateKeys_Protocol_Specification_v0.8.1.md, as
|
||||
; implemented by the reference implementation github.com/datekeys/datekeys-go.
|
||||
;
|
||||
; Encoding rules that CDDL cannot express (spec section 58, 58.1):
|
||||
; - Every structure is Deterministic CBOR (RFC 8949 section 4.2.1): map keys
|
||||
; sorted by their encoded bytes, shortest-form integers and lengths,
|
||||
; definite lengths only, no tags.
|
||||
; - A decoder re-encodes what it decoded and rejects any byte difference
|
||||
; (ERR_NON_CANONICAL_CBOR).
|
||||
; - A semantically absent optional field is omitted. Empty arrays, empty
|
||||
; maps, null and "" never stand for absence; the .size and non-empty
|
||||
; constraints below make those forms invalid.
|
||||
; - Maps are closed: keys not listed here are rejected. New semantics go in
|
||||
; extensions (spec section 54).
|
||||
; - Within one object an extension_id appears at most once and never in both
|
||||
; extension arrays; arrays are sorted by the UTF-8 bytes of extension_id and
|
||||
; then by extension_version.
|
||||
|
||||
; Spec section 11 and 12.
|
||||
provider-profile = {
|
||||
0 => "datekeys-provider-profile",
|
||||
1 => 1,
|
||||
2 => profile-id,
|
||||
3 => name, ; provider, "drand"
|
||||
4 => name, ; provider network identifier, "quicknet"
|
||||
5 => bstr .size 32, ; chain_hash
|
||||
6 => bstr, ; group public key
|
||||
7 => uint .ge 1, ; period in seconds
|
||||
8 => uint, ; genesis_time, Unix seconds
|
||||
9 => name, ; scheme, "bls-unchained-g1-rfc9380"
|
||||
10 => bstr .size 32, ; genesis_seed
|
||||
}
|
||||
|
||||
; Spec section 24. Stored as exact bytes after the 16-byte PRELUDE and covered
|
||||
; by header_binding = SHA-256(PRELUDE || PUBLIC_HEADER).
|
||||
public-header = {
|
||||
0 => "datekeycap",
|
||||
1 => 1,
|
||||
2 => capsule-id,
|
||||
3 => compact-datekey, ; the only source of the profile
|
||||
4 => access-policy,
|
||||
? 5 => extensions, ; critical_extensions
|
||||
? 6 => extensions, ; noncritical_extensions
|
||||
}
|
||||
|
||||
; Spec section 31. Sealed inside OUTER_TIME_AGE (time_only) or inside
|
||||
; INNER_ACCESS_AGE inside OUTER_TIME_AGE (time_and_key).
|
||||
control = {
|
||||
0 => "datekeys-control",
|
||||
1 => 1,
|
||||
2 => bstr .size 32, ; header_binding
|
||||
3 => bstr .size 32, ; payload_identity, raw X25519 identity I_PAYLOAD
|
||||
? 4 => extensions, ; critical_extensions
|
||||
? 5 => extensions, ; noncritical_extensions
|
||||
}
|
||||
|
||||
; Spec section 41. BODY_CBOR of a .dkk, after the 12-byte DKK1 prelude.
|
||||
access-key-body = {
|
||||
0 => "datekeys-access-key",
|
||||
1 => 1,
|
||||
2 => bstr .size 16, ; credential_id
|
||||
3 => capsule-id,
|
||||
4 => access-type,
|
||||
5 => access-material,
|
||||
? 6 => verification-metadata,
|
||||
? 7 => extensions, ; critical_extensions
|
||||
? 8 => extensions, ; noncritical_extensions
|
||||
}
|
||||
|
||||
; Spec section 43. Present only when it holds a digest: never an empty map.
|
||||
verification-metadata = {
|
||||
0 => bstr .size 32, ; capsule_digest = SHA-256(exact .dkc bytes)
|
||||
}
|
||||
|
||||
; Spec section 31 and 54.
|
||||
extensions = [+ extension]
|
||||
extension = {
|
||||
0 => extension-id,
|
||||
1 => uint, ; extension_version
|
||||
? 2 => any, ; data, not interpreted by the base protocol
|
||||
}
|
||||
|
||||
capsule-id = bstr .size 16
|
||||
access-policy = &(time_only: 0, time_and_key: 1)
|
||||
access-type = "x25519"
|
||||
access-material = bstr .size 32 ; for access-type "x25519"
|
||||
|
||||
; Implementation limits of the reference implementation (spec section 74
|
||||
; leaves definitive field limits open).
|
||||
profile-id = tstr .regexp "[a-z0-9][a-z0-9:._-]{0,127}"
|
||||
name = tstr .regexp "[a-z0-9][a-z0-9._-]{0,63}"
|
||||
extension-id = tstr .size (1..256)
|
||||
|
||||
; Spec section 18 and 19: "dk1_" + unpadded Base64URL of the canonical JSON
|
||||
; {"version":1,"network":<profile-id>,"round":<round>}, round in 1..2^53-1.
|
||||
compact-datekey = tstr .regexp "dk1_[A-Za-z0-9_-]+"
|
||||
Binary file not shown.
@ -0,0 +1,121 @@
|
||||
{
|
||||
"description": "time_only capsule with an empty payload",
|
||||
"spec": "0.8.1",
|
||||
"file": "empty_payload.dkc",
|
||||
"sha256": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",
|
||||
"release": {
|
||||
"round": 1001,
|
||||
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
|
||||
},
|
||||
"prelude": "444b43310100000000000079000001be",
|
||||
"public_header": "a5006a646174656b657963617001010250ab10174561a9a19a6d9dc9ab1ef59c66037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d58300400",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
|
||||
"capsule_id": "ab10174561a9a19a6d9dc9ab1ef59c66",
|
||||
"access_policy": "time_only",
|
||||
"structure": "time_only",
|
||||
"unlock_at": "2023-08-23T15:59:27Z",
|
||||
"header_binding": "33186a4f03d79eb8e28dbb82d2538ada45b68cd4f2ed6d17fa87b211e54f4d58",
|
||||
"outer_stanzas": [
|
||||
{
|
||||
"type": "tlock",
|
||||
"args": [
|
||||
"1001",
|
||||
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
]
|
||||
}
|
||||
],
|
||||
"payload_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"kT/xASH7NSOUvlk9XfIAh37JzSq6kWxPKVkSzflygjY"
|
||||
]
|
||||
}
|
||||
],
|
||||
"control_cbor": "a40070646174656b6579732d636f6e74726f6c010102582033186a4f03d79eb8e28dbb82d2538ada45b68cd4f2ed6d17fa87b211e54f4d58035820cd5ca142d51386860bf4ae4ae16740d498ef70ff534084acf5f4005b74f278c3",
|
||||
"payload_identity": "cd5ca142d51386860bf4ae4ae16740d498ef70ff534084acf5f4005b74f278c3",
|
||||
"plaintext_file": "empty_payload.plaintext",
|
||||
"plaintext_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"stages": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "release",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"name": "release verification",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"name": "open sealed control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"name": "policy structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"name": "control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"name": "header binding",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"name": "payload identity",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 18,
|
||||
"name": "commit",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,13 @@
|
||||
{
|
||||
"description": "portable X25519 .dkk of time_and_key_portable.dkc",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_and_key_portable.dkk",
|
||||
"sha256": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
|
||||
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",
|
||||
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
|
||||
"access_type": "x25519",
|
||||
"access_material": "3d5e4d51f21236ecff40d89c0adcf9aa325a24b91b46fd9d1afa482f54b2d2c6",
|
||||
"capsule_digest": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
|
||||
"capsule": "time_and_key_portable.dkc",
|
||||
"expected_result": "opens INNER_ACCESS_AGE of time_and_key_portable.dkc and yields its CONTROL_CBOR"
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
{
|
||||
"description": "time_and_key capsule whose only recipient is a portable .dkk",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_and_key_portable.dkc",
|
||||
"sha256": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
|
||||
"release": {
|
||||
"round": 1000,
|
||||
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
|
||||
},
|
||||
"prelude": "444b4331010000000000007900000286",
|
||||
"public_header": "a5006a646174656b657963617001010250448e134a13457c319cab7fceaf7ffa1f037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300401",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
|
||||
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
|
||||
"access_policy": "time_and_key",
|
||||
"structure": "time_and_key",
|
||||
"unlock_at": "2023-08-23T15:59:24Z",
|
||||
"header_binding": "841fe789895abdd0ffecb0eb7562f4c4b4dfd0d1f8ec6fd251adbe1a89d5ab5f",
|
||||
"outer_stanzas": [
|
||||
{
|
||||
"type": "tlock",
|
||||
"args": [
|
||||
"1000",
|
||||
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
]
|
||||
}
|
||||
],
|
||||
"payload_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"/g3xW+kK9u65qfWLzS5huoFB/JIc9EqG3QBOfuO9PVQ"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inner_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"FyePxd/IAO/2FpE3kQgiidbDsxRVuNM/eEI3SDbbamc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"access_key_file": "time_and_key_portable.dkk",
|
||||
"control_cbor": "a40070646174656b6579732d636f6e74726f6c0101025820841fe789895abdd0ffecb0eb7562f4c4b4dfd0d1f8ec6fd251adbe1a89d5ab5f0358202536e99b16373ca8d118695c600fd652541367b0198dbfaba9362a98f9fa70cb",
|
||||
"payload_identity": "2536e99b16373ca8d118695c600fd652541367b0198dbfaba9362a98f9fa70cb",
|
||||
"plaintext_file": "time_and_key_portable.plaintext",
|
||||
"plaintext_sha256": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
|
||||
"stages": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "access credential",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "release",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"name": "release verification",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"name": "open sealed control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"name": "policy structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 13,
|
||||
"name": "open access layer",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"name": "control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"name": "header binding",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"name": "payload identity",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 18,
|
||||
"name": "commit",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
DateKeys fixture opened with a portable .dkk.
|
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,13 @@
|
||||
{
|
||||
"description": "portable X25519 .dkk of time_and_key_recipients.dkc",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_and_key_recipients.dkk",
|
||||
"sha256": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
|
||||
"credential_id": "b89292aedf6d05d584cec9a871ce8735",
|
||||
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
|
||||
"access_type": "x25519",
|
||||
"access_material": "42d6d897097fd5af2772e058db17920afe1390e2856139bc2f800294564dd7ac",
|
||||
"capsule_digest": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
|
||||
"capsule": "time_and_key_recipients.dkc",
|
||||
"expected_result": "opens INNER_ACCESS_AGE of time_and_key_recipients.dkc and yields its CONTROL_CBOR"
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
{
|
||||
"description": "time_and_key capsule for two known X25519 recipients and a portable .dkk",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_and_key_recipients.dkc",
|
||||
"sha256": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
|
||||
"release": {
|
||||
"round": 1001,
|
||||
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
|
||||
},
|
||||
"prelude": "444b433101000000000000790000034a",
|
||||
"public_header": "a5006a646174656b657963617001010250c75dfc8e9c576d1369910664df93693a037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d58300401",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
|
||||
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
|
||||
"access_policy": "time_and_key",
|
||||
"structure": "time_and_key",
|
||||
"unlock_at": "2023-08-23T15:59:27Z",
|
||||
"header_binding": "5d789b4bde52beecbad4b80e9b0a8fec8b59b6a84263af6d022cd1eccfded0e2",
|
||||
"outer_stanzas": [
|
||||
{
|
||||
"type": "tlock",
|
||||
"args": [
|
||||
"1001",
|
||||
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
]
|
||||
}
|
||||
],
|
||||
"payload_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"ew9JpO0AGTGmWXpZCEHsSrN0Ey2kSjzphbokfjCMoF4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inner_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"sOnq+vJxTJVHlPwhfXd2LovGNqRMuyS3e7qJQm8at0E"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"T8NHQ9xo+IaBvEoqirkEgwdbFzOUt19moVhfvkebdUY"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"tIu+bu/q+z3ACTYdwpolbaSwA7PXv4QVjXbcrgOXAAM"
|
||||
]
|
||||
}
|
||||
],
|
||||
"access_key_file": "time_and_key_recipients.dkk",
|
||||
"identities": [
|
||||
"AGE-SECRET-KEY-1DPM6CQMQV3665FK762HTVC37XAY6X99MM4YLJ9J0J2DQD7K63GCSG5TMCK",
|
||||
"AGE-SECRET-KEY-15MEM79HAM2XQ79HN5QVCLECUDM4JQQKWEE9JWR3VV2FWK033AXPQQ2422Y"
|
||||
],
|
||||
"control_cbor": "a40070646174656b6579732d636f6e74726f6c01010258205d789b4bde52beecbad4b80e9b0a8fec8b59b6a84263af6d022cd1eccfded0e20358205d3f4172eca48e5d5b2570208cfe3298c4735f1d77ceaed958bcccb8eec18a12",
|
||||
"payload_identity": "5d3f4172eca48e5d5b2570208cfe3298c4735f1d77ceaed958bcccb8eec18a12",
|
||||
"plaintext_file": "time_and_key_recipients.plaintext",
|
||||
"plaintext_sha256": "0e9fd50e98a85953aa9cf07a11ee3c62bb3d7622f344f1c6ce744d1ed111659f",
|
||||
"stages": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "access credential",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "release",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"name": "release verification",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"name": "open sealed control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"name": "policy structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 13,
|
||||
"name": "open access layer",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"name": "control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"name": "header binding",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"name": "payload identity",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 18,
|
||||
"name": "commit",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
DateKeys fixture for several recipients.
|
||||
Binary file not shown.
@ -0,0 +1,121 @@
|
||||
{
|
||||
"description": "time_only capsule, two STREAM chunks, no extensions",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_only.dkc",
|
||||
"sha256": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",
|
||||
"release": {
|
||||
"round": 1000,
|
||||
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
|
||||
},
|
||||
"prelude": "444b43310100000000000079000001be",
|
||||
"public_header": "a5006a646174656b657963617001010250ad4d676812b134ff8a3de263f77018b4037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300400",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
|
||||
"capsule_id": "ad4d676812b134ff8a3de263f77018b4",
|
||||
"access_policy": "time_only",
|
||||
"structure": "time_only",
|
||||
"unlock_at": "2023-08-23T15:59:24Z",
|
||||
"header_binding": "8e1d05df55bc626a579759d54d436b512b7bfc71039d3c12c1875a5b475f2417",
|
||||
"outer_stanzas": [
|
||||
{
|
||||
"type": "tlock",
|
||||
"args": [
|
||||
"1000",
|
||||
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
]
|
||||
}
|
||||
],
|
||||
"payload_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"LvR2+5baviJPeIiyw96VfTW1GnzTw3DbWIPGuq9amEw"
|
||||
]
|
||||
}
|
||||
],
|
||||
"control_cbor": "a40070646174656b6579732d636f6e74726f6c01010258208e1d05df55bc626a579759d54d436b512b7bfc71039d3c12c1875a5b475f2417035820e63d28ff1a6d1975263db49ff80c3bf949737d20aeedcc9539e648ffd330082b",
|
||||
"payload_identity": "e63d28ff1a6d1975263db49ff80c3bf949737d20aeedcc9539e648ffd330082b",
|
||||
"plaintext_file": "time_only.plaintext",
|
||||
"plaintext_sha256": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
|
||||
"stages": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "release",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"name": "release verification",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"name": "open sealed control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"name": "policy structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"name": "control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"name": "header binding",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"name": "payload identity",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 18,
|
||||
"name": "commit",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -0,0 +1,137 @@
|
||||
{
|
||||
"description": "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension",
|
||||
"spec": "0.8.1",
|
||||
"file": "time_only_extensions.dkc",
|
||||
"sha256": "1e7effd58016d9447f9a2e7c9645c658604979c4e35af675d1dc6c4ae12ac444",
|
||||
"release": {
|
||||
"round": 2000,
|
||||
"signature": "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e"
|
||||
},
|
||||
"prelude": "444b4331010000000000009f000001e2",
|
||||
"public_header": "a6006a646174656b657963617001010250d2fd9af55dc0253132fb36b8dc0d016b037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d6a41774d483004000681a300716f72672e6578616d706c652e6c6162656c0101026c7075626c6963206c6162656c",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0",
|
||||
"capsule_id": "d2fd9af55dc0253132fb36b8dc0d016b",
|
||||
"access_policy": "time_only",
|
||||
"structure": "time_only",
|
||||
"unlock_at": "2023-08-23T16:49:24Z",
|
||||
"header_binding": "a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc154",
|
||||
"outer_stanzas": [
|
||||
{
|
||||
"type": "tlock",
|
||||
"args": [
|
||||
"2000",
|
||||
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
]
|
||||
}
|
||||
],
|
||||
"payload_stanzas": [
|
||||
{
|
||||
"type": "X25519",
|
||||
"args": [
|
||||
"pUUIuCTHAZ3+kpMwJQQ9dc3EJO1zPagucu+VdFovSy0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"control_cbor": "a50070646174656b6579732d636f6e74726f6c0101025820a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc1540358201b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb80581a300706f72672e6578616d706c652e6e6f7465010202a2616e07667365616c6564f5",
|
||||
"payload_identity": "1b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb8",
|
||||
"plaintext_file": "time_only_extensions.plaintext",
|
||||
"plaintext_sha256": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
|
||||
"header_extensions": [
|
||||
{
|
||||
"critical": false,
|
||||
"id": "org.example.label",
|
||||
"version": 1,
|
||||
"data": "6c7075626c6963206c6162656c"
|
||||
}
|
||||
],
|
||||
"control_extensions": [
|
||||
{
|
||||
"critical": false,
|
||||
"id": "org.example.note",
|
||||
"version": 2,
|
||||
"data": "a2616e07667365616c6564f5"
|
||||
}
|
||||
],
|
||||
"stages": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 9,
|
||||
"name": "release",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 10,
|
||||
"name": "release verification",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 11,
|
||||
"name": "open sealed control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 12,
|
||||
"name": "policy structure",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 14,
|
||||
"name": "control",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 15,
|
||||
"name": "header binding",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 16,
|
||||
"name": "payload identity",
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"step": 18,
|
||||
"name": "commit",
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
DateKeys fixture with extensions.
|
||||
@ -0,0 +1,153 @@
|
||||
{
|
||||
"spec": "0.8.1",
|
||||
"description": "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "round 1",
|
||||
"network": "datekeys:quicknet:v1",
|
||||
"round": 1,
|
||||
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":1}",
|
||||
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MX0",
|
||||
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MX0"
|
||||
},
|
||||
{
|
||||
"name": "round 1000",
|
||||
"network": "datekeys:quicknet:v1",
|
||||
"round": 1000,
|
||||
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":1000}",
|
||||
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
|
||||
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0"
|
||||
},
|
||||
{
|
||||
"name": "normative 2030-01-01 round",
|
||||
"network": "datekeys:quicknet:v1",
|
||||
"round": 66884212,
|
||||
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":66884212}",
|
||||
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9"
|
||||
},
|
||||
{
|
||||
"name": "last Quicknet round",
|
||||
"network": "datekeys:quicknet:v1",
|
||||
"round": 83903165811,
|
||||
"canonical_json": "{\"version\":1,\"network\":\"datekeys:quicknet:v1\",\"round\":83903165811}",
|
||||
"base64url": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6ODM5MDMxNjU4MTF9",
|
||||
"dk1": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6ODM5MDMxNjU4MTF9"
|
||||
},
|
||||
{
|
||||
"name": "whitespace in JSON",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjogMSwgIm5ldHdvcmsiOiAiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCAicm91bmQiOiA2Njg4NDIxMn0",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "keys reordered",
|
||||
"input": "dk1_eyJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJ2ZXJzaW9uIjoxLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "trailing whitespace",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9Cg",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "exponent notation",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6Ni42ODg0MjEyZTd9",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "fraction notation",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLjAsIm5ldHdvcmsiOiJkYXRla2V5czpxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "escaped character",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXNcdTAwM2FxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "duplicate key",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MSwicm91bmQiOjY2ODg0MjEyfQ",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "padded Base64URL",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0=",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "non-zero trailing bits",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH1",
|
||||
"error": "ERR_DATEKEY_NON_CANONICAL"
|
||||
},
|
||||
{
|
||||
"name": "extra field",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTIsInB1YmxpY19rZXkiOiIwMCJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "missing field",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEifQ",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "version 2",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoyLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "round 0",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MH0",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "negative round",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6LTF9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "fractional round",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MS41fQ",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "round above 2^53-1",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6OTAwNzE5OTI1NDc0MDk5Mn0",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "round as string",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6IjY2ODg0MjEyIn0",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "uppercase network",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiREFURUtFWVM6UVVJQ0tORVQ6VjEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "trailing data",
|
||||
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9eA",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "byte order mark",
|
||||
"input": "dk1_77u_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "not Base64",
|
||||
"input": "dk1_!!!",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "missing prefix",
|
||||
"input": "eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "uppercase prefix",
|
||||
"input": "DK1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"spec": "0.8.1",
|
||||
"description": "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
|
||||
"profile_id": "datekeys:quicknet:v1",
|
||||
"provider": "drand",
|
||||
"network": "quicknet",
|
||||
"chain_hash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
|
||||
"public_key": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
|
||||
"period_seconds": 3,
|
||||
"genesis_time": 1692803367,
|
||||
"genesis_seed": "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e",
|
||||
"scheme": "bls-unchained-g1-rfc9380",
|
||||
"canonical_cbor": "ab007819646174656b6579732d70726f76696465722d70726f66696c6501010274646174656b6579733a717569636b6e65743a763103656472616e640468717569636b6e657405582052db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e97106586083cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a0703081a64e62127097818626c732d756e636861696e65642d67312d726663393338300a5820f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e",
|
||||
"profile_hash": "4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4"
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
{
|
||||
"spec": "0.8.1",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"description": "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "genesis exactly: round 1",
|
||||
"requested": "2023-08-23T15:09:27Z",
|
||||
"round": 1,
|
||||
"effective": "2023-08-23T15:09:27Z"
|
||||
},
|
||||
{
|
||||
"name": "genesis + 1ns: next round",
|
||||
"requested": "2023-08-23T15:09:27.000000001Z",
|
||||
"round": 2,
|
||||
"effective": "2023-08-23T15:09:30Z"
|
||||
},
|
||||
{
|
||||
"name": "genesis + 1s",
|
||||
"requested": "2023-08-23T15:09:28Z",
|
||||
"round": 2,
|
||||
"effective": "2023-08-23T15:09:30Z"
|
||||
},
|
||||
{
|
||||
"name": "genesis + one period: round 2",
|
||||
"requested": "2023-08-23T15:09:30Z",
|
||||
"round": 2,
|
||||
"effective": "2023-08-23T15:09:30Z"
|
||||
},
|
||||
{
|
||||
"name": "genesis + one period + 1ns: round 3",
|
||||
"requested": "2023-08-23T15:09:30.000000001Z",
|
||||
"round": 3,
|
||||
"effective": "2023-08-23T15:09:33Z"
|
||||
},
|
||||
{
|
||||
"name": "genesis - 1s: before the profile",
|
||||
"requested": "2023-08-23T15:09:26Z",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
},
|
||||
{
|
||||
"name": "round 1000 boundary exactly",
|
||||
"requested": "2023-08-23T15:59:24Z",
|
||||
"round": 1000,
|
||||
"effective": "2023-08-23T15:59:24Z"
|
||||
},
|
||||
{
|
||||
"name": "one second before the round 1000 boundary",
|
||||
"requested": "2023-08-23T15:59:23Z",
|
||||
"round": 1000,
|
||||
"effective": "2023-08-23T15:59:24Z"
|
||||
},
|
||||
{
|
||||
"name": "one second after the round 1000 boundary",
|
||||
"requested": "2023-08-23T15:59:25Z",
|
||||
"round": 1001,
|
||||
"effective": "2023-08-23T15:59:27Z"
|
||||
},
|
||||
{
|
||||
"name": "1ns after the round 1000 boundary",
|
||||
"requested": "2023-08-23T15:59:24.000000001Z",
|
||||
"round": 1001,
|
||||
"effective": "2023-08-23T15:59:27Z"
|
||||
},
|
||||
{
|
||||
"name": "half a second after the round 1000 boundary",
|
||||
"requested": "2023-08-23T15:59:24.5Z",
|
||||
"round": 1001,
|
||||
"effective": "2023-08-23T15:59:27Z"
|
||||
},
|
||||
{
|
||||
"name": "normative vector 2030-01-01 (spec §16)",
|
||||
"requested": "2030-01-01T00:00:00Z",
|
||||
"round": 66884212,
|
||||
"effective": "2030-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "1ns after 2030-01-01",
|
||||
"requested": "2030-01-01T00:00:00.000000001Z",
|
||||
"round": 66884213,
|
||||
"effective": "2030-01-01T00:00:03Z"
|
||||
},
|
||||
{
|
||||
"name": "normative vector round 66432123 (spec §16)",
|
||||
"requested": "2029-12-16T07:15:33Z",
|
||||
"round": 66432123,
|
||||
"effective": "2029-12-16T07:15:33Z"
|
||||
},
|
||||
{
|
||||
"name": "offset timezone equals UTC instant",
|
||||
"requested": "2026-10-22T19:00:00.001+02:00",
|
||||
"round": 33295013,
|
||||
"effective": "2026-10-22T17:00:03Z"
|
||||
},
|
||||
{
|
||||
"name": "last representable round time",
|
||||
"requested": "9999-12-31T23:59:57Z",
|
||||
"round": 83903165811,
|
||||
"effective": "9999-12-31T23:59:57Z"
|
||||
},
|
||||
{
|
||||
"name": "after the last representable round",
|
||||
"requested": "9999-12-31T23:59:59Z",
|
||||
"error": "ERR_DATEKEY_INVALID"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in new issue