Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
// Command recovery opens a DateKeys capsule (.dkc) without any DateKeys code.
|
|
|
|
|
//
|
|
|
|
|
// It is the worked example of the informative annex "Recuperación sin
|
|
|
|
|
// software DateKeys" of the specification: everything it needs is a generic
|
|
|
|
|
// BLS12-381 library (drand/kyber-bls12381), the age library (filippo.io/age)
|
|
|
|
|
// for the X25519 layers, the Go standard library and golang.org/x/crypto for
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
// ChaCha20-Poly1305, and PBKDF2 of the standard library for a key of words.
|
|
|
|
|
// It does not import the DateKeys module, nor drand or
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
// tlock. The tlock layer and the age file that it protects, whose file key no
|
|
|
|
|
// age tool accepts, are written out here step by step.
|
|
|
|
|
//
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
// go run ./scripts/recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk | -words FILE [-unicodedata FILE]] -out PATH [-body FILE]
|
|
|
|
|
//
|
|
|
|
|
// A time_and_key capsule opens with its .dkk (-dkk) or with the words of a
|
|
|
|
|
// key of words, read from the text file -words (annex 79.7). Words of the
|
|
|
|
|
// DateKeys lists normalize without tables; any other text needs
|
|
|
|
|
// UnicodeData.txt of Unicode 18.0.0 (-unicodedata), checked by its SHA-256.
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
//
|
|
|
|
|
// FILE is the release object of the round of the capsule (spec §47.1), from
|
|
|
|
|
// any source: an archive, a cache service or any copy. Its signature is
|
|
|
|
|
// verified against the Quicknet public key, so its source need not be
|
|
|
|
|
// trusted.
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
//
|
|
|
|
|
// Formats 1 and 2 write the content to the file -out; format 3 writes each
|
|
|
|
|
// file of its head under the directory -out. -body writes the L bytes the
|
|
|
|
|
// reader delivers (the content, or BODY in format 3).
|
|
|
|
|
//
|
|
|
|
|
// It checks what decides correctness: the BLS signature of the release,
|
|
|
|
|
// r·G2 == U of the tlock stanza, the MACs of every age file and the SHA-256
|
|
|
|
|
// of every file. It is not a validator: it skips the canonical-encoding and
|
|
|
|
|
// policy checks of a full reader (spec §63).
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"crypto/hkdf"
|
|
|
|
|
"crypto/hmac"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"flag"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"math/big"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"filippo.io/age"
|
|
|
|
|
"github.com/drand/kyber"
|
|
|
|
|
bls "github.com/drand/kyber-bls12381"
|
|
|
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 1. The pinned Quicknet parameters (spec §12, §63 after the flow).
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
quicknetProfileID = "datekeys:quicknet:v1"
|
|
|
|
|
quicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
|
|
|
|
quicknetPublicKey = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c" +
|
|
|
|
|
"8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb" +
|
|
|
|
|
"5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"
|
|
|
|
|
quicknetGenesis = 1692803367 // Unix seconds of round 1
|
|
|
|
|
quicknetPeriod = 3 // seconds between rounds
|
|
|
|
|
|
|
|
|
|
// The DST of the hash to G1 of RFC 9380, 43 ASCII bytes.
|
|
|
|
|
quicknetDST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// groupOrder is q, the order of G1, G2 and GT (spec §12.2).
|
|
|
|
|
var groupOrder, _ = new(big.Int).SetString("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001", 16)
|
|
|
|
|
|
|
|
|
|
func mustHex(s string) []byte {
|
|
|
|
|
b, err := hex.DecodeString(s)
|
|
|
|
|
if err != nil {
|
|
|
|
|
panic(err)
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// roundTime is the instant a round is published: genesis + (round-1)·period.
|
|
|
|
|
func roundTime(round uint64) time.Time {
|
|
|
|
|
return time.Unix(int64(quicknetGenesis+(round-1)*quicknetPeriod), 0).UTC()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// A minimal CBOR decoder (RFC 8949): unsigned integers, byte strings, text
|
|
|
|
|
// strings, arrays, maps with unsigned keys and the simple values false, true
|
|
|
|
|
// and null, all with definite lengths. That is all a release, PUBLIC_HEADER,
|
|
|
|
|
// CONTROL_CBOR, the body of a .dkk and the head of format 3 use.
|
|
|
|
|
|
|
|
|
|
type cborReader struct {
|
|
|
|
|
b []byte
|
|
|
|
|
i int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodeCBOR(b []byte) (any, error) {
|
|
|
|
|
r := &cborReader{b: b}
|
|
|
|
|
v, err := r.item(0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if r.i != len(b) {
|
|
|
|
|
return nil, fmt.Errorf("cbor: %d trailing bytes", len(b)-r.i)
|
|
|
|
|
}
|
|
|
|
|
return v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// head reads the initial byte and its argument.
|
|
|
|
|
func (r *cborReader) head() (major byte, arg uint64, err error) {
|
|
|
|
|
if r.i >= len(r.b) {
|
|
|
|
|
return 0, 0, errors.New("cbor: truncated")
|
|
|
|
|
}
|
|
|
|
|
ib := r.b[r.i]
|
|
|
|
|
r.i++
|
|
|
|
|
major, info := ib>>5, ib&0x1f
|
|
|
|
|
var n int
|
|
|
|
|
switch {
|
|
|
|
|
case info < 24:
|
|
|
|
|
return major, uint64(info), nil
|
|
|
|
|
case info == 24:
|
|
|
|
|
n = 1
|
|
|
|
|
case info == 25:
|
|
|
|
|
n = 2
|
|
|
|
|
case info == 26:
|
|
|
|
|
n = 4
|
|
|
|
|
case info == 27:
|
|
|
|
|
n = 8
|
|
|
|
|
default:
|
|
|
|
|
return 0, 0, fmt.Errorf("cbor: unsupported additional information %d", info)
|
|
|
|
|
}
|
|
|
|
|
if len(r.b)-r.i < n {
|
|
|
|
|
return 0, 0, errors.New("cbor: truncated")
|
|
|
|
|
}
|
|
|
|
|
for _, c := range r.b[r.i : r.i+n] {
|
|
|
|
|
arg = arg<<8 | uint64(c)
|
|
|
|
|
}
|
|
|
|
|
r.i += n
|
|
|
|
|
return major, arg, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *cborReader) item(depth int) (any, error) {
|
|
|
|
|
if depth > 16 {
|
|
|
|
|
return nil, errors.New("cbor: nested too deeply")
|
|
|
|
|
}
|
|
|
|
|
major, arg, err := r.head()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
switch major {
|
|
|
|
|
case 0:
|
|
|
|
|
return arg, nil
|
|
|
|
|
case 2, 3:
|
|
|
|
|
if arg > uint64(len(r.b)-r.i) {
|
|
|
|
|
return nil, errors.New("cbor: truncated string")
|
|
|
|
|
}
|
|
|
|
|
s := r.b[r.i : r.i+int(arg)]
|
|
|
|
|
r.i += int(arg)
|
|
|
|
|
if major == 3 {
|
|
|
|
|
return string(s), nil
|
|
|
|
|
}
|
|
|
|
|
return bytes.Clone(s), nil
|
|
|
|
|
case 4:
|
|
|
|
|
if arg > uint64(len(r.b)-r.i) {
|
|
|
|
|
return nil, errors.New("cbor: truncated array")
|
|
|
|
|
}
|
|
|
|
|
a := make([]any, 0, arg)
|
|
|
|
|
for range arg {
|
|
|
|
|
v, err := r.item(depth + 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
a = append(a, v)
|
|
|
|
|
}
|
|
|
|
|
return a, nil
|
|
|
|
|
case 5:
|
|
|
|
|
if arg > uint64(len(r.b)-r.i) {
|
|
|
|
|
return nil, errors.New("cbor: truncated map")
|
|
|
|
|
}
|
|
|
|
|
m := make(map[uint64]any, arg)
|
|
|
|
|
for range arg {
|
|
|
|
|
k, err := r.item(depth + 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
key, ok := k.(uint64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, errors.New("cbor: map key is not an unsigned integer")
|
|
|
|
|
}
|
|
|
|
|
v, err := r.item(depth + 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if _, dup := m[key]; dup {
|
|
|
|
|
return nil, fmt.Errorf("cbor: duplicate key %d", key)
|
|
|
|
|
}
|
|
|
|
|
m[key] = v
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
case 7:
|
|
|
|
|
switch arg {
|
|
|
|
|
case 20:
|
|
|
|
|
return false, nil
|
|
|
|
|
case 21:
|
|
|
|
|
return true, nil
|
|
|
|
|
case 22:
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("cbor: unsupported item (major type %d)", major)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cborMap is a decoded map with typed accessors.
|
|
|
|
|
type cborMap map[uint64]any
|
|
|
|
|
|
|
|
|
|
func asMap(v any, what string) (cborMap, error) {
|
|
|
|
|
m, ok := v.(map[uint64]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("%s: not a CBOR map", what)
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m cborMap) uint(k uint64) (uint64, error) {
|
|
|
|
|
v, ok := m[k].(uint64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return 0, fmt.Errorf("key %d: missing or not an unsigned integer", k)
|
|
|
|
|
}
|
|
|
|
|
return v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m cborMap) bytes(k uint64) ([]byte, error) {
|
|
|
|
|
v, ok := m[k].([]byte)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("key %d: missing or not a byte string", k)
|
|
|
|
|
}
|
|
|
|
|
return v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m cborMap) text(k uint64) (string, error) {
|
|
|
|
|
v, ok := m[k].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return "", fmt.Errorf("key %d: missing or not a text string", k)
|
|
|
|
|
}
|
|
|
|
|
return v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkType checks the type tag (key 0) and the schema version (key 1) that
|
|
|
|
|
// every DateKeys CBOR object starts with.
|
|
|
|
|
func (m cborMap) checkType(tag string, version uint64) error {
|
|
|
|
|
t, err := m.text(0)
|
|
|
|
|
if err != nil || t != tag {
|
|
|
|
|
return fmt.Errorf("type tag is not %q", tag)
|
|
|
|
|
}
|
|
|
|
|
v, err := m.uint(1)
|
|
|
|
|
if err != nil || v != version {
|
|
|
|
|
return fmt.Errorf("%s: schema version is not %d", tag, version)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
// Step 2. The release object: a CBOR map
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
//
|
|
|
|
|
// 0 → "datekeys-release", 1 → 1, 2 → chain_hash (32 bytes),
|
|
|
|
|
// 3 → round, 4 → signature (48 bytes, a compressed point of G1)
|
|
|
|
|
|
|
|
|
|
type release struct {
|
|
|
|
|
round uint64
|
|
|
|
|
signature []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readRelease(b []byte) (release, error) {
|
|
|
|
|
v, err := decodeCBOR(b)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return release{}, fmt.Errorf("release: %w", err)
|
|
|
|
|
}
|
|
|
|
|
m, err := asMap(v, "release")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return release{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := m.checkType("datekeys-release", 1); err != nil {
|
|
|
|
|
return release{}, err
|
|
|
|
|
}
|
|
|
|
|
ch, err := m.bytes(2)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return release{}, fmt.Errorf("release: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Equal(ch, mustHex(quicknetChainHash)) {
|
|
|
|
|
return release{}, fmt.Errorf("release: chain_hash %x is not Quicknet's", ch)
|
|
|
|
|
}
|
|
|
|
|
round, err := m.uint(3)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return release{}, fmt.Errorf("release: %w", err)
|
|
|
|
|
}
|
|
|
|
|
sig, err := m.bytes(4)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return release{}, fmt.Errorf("release: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(sig) != 48 {
|
|
|
|
|
return release{}, fmt.Errorf("release: signature of %d bytes, want 48", len(sig))
|
|
|
|
|
}
|
|
|
|
|
return release{round: round, signature: sig}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 3. The .dkc frame (spec §22, §23):
|
|
|
|
|
//
|
|
|
|
|
// "DKC1" | VERSION (format 1, 2 or 3) | FLAGS 0 | RESERVED 0 0 |
|
|
|
|
|
// PUBLIC_HEADER_LEN (uint32 BE) | SEALED_CONTROL_LEN (uint32 BE) |
|
|
|
|
|
// PUBLIC_HEADER | SEALED_CONTROL | PAYLOAD_AGE to EOF
|
|
|
|
|
|
|
|
|
|
type capsule struct {
|
|
|
|
|
format int
|
|
|
|
|
capsuleID []byte
|
|
|
|
|
round uint64
|
|
|
|
|
policy uint64 // 0 time_only, 1 time_and_key (spec §25)
|
|
|
|
|
sealed []byte // SEALED_CONTROL = OUTER_TIME_AGE, an age file
|
|
|
|
|
payload []byte // PAYLOAD_AGE, an age file
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseCapsule(b []byte) (*capsule, error) {
|
|
|
|
|
if len(b) < 16 || string(b[:4]) != "DKC1" {
|
|
|
|
|
return nil, errors.New("not a .dkc file: no DKC1 prelude")
|
|
|
|
|
}
|
|
|
|
|
c := &capsule{format: int(b[4])}
|
|
|
|
|
if c.format < 1 || c.format > 3 {
|
|
|
|
|
return nil, fmt.Errorf("unknown capsule format %d", c.format)
|
|
|
|
|
}
|
|
|
|
|
hlen := uint64(binary.BigEndian.Uint32(b[8:12]))
|
|
|
|
|
slen := uint64(binary.BigEndian.Uint32(b[12:16]))
|
|
|
|
|
if 16+hlen+slen > uint64(len(b)) {
|
|
|
|
|
return nil, errors.New("truncated .dkc")
|
|
|
|
|
}
|
|
|
|
|
header := b[16 : 16+hlen]
|
|
|
|
|
c.sealed = b[16+hlen : 16+hlen+slen]
|
|
|
|
|
c.payload = b[16+hlen+slen:]
|
|
|
|
|
|
|
|
|
|
// PUBLIC_HEADER (spec §24): 0 → "datekeycap", 1 → 1, 2 → capsule_id,
|
|
|
|
|
// 3 → the DateKey as a dk1_ string, 4 → access_policy.
|
|
|
|
|
v, err := decodeCBOR(header)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("public header: %w", err)
|
|
|
|
|
}
|
|
|
|
|
m, err := asMap(v, "public header")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := m.checkType("datekeycap", 1); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if c.capsuleID, err = m.bytes(2); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("public header: %w", err)
|
|
|
|
|
}
|
|
|
|
|
dk, err := m.text(3)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("public header: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if c.round, err = roundFromDateKey(dk); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if c.policy, err = m.uint(4); err != nil || c.policy > 1 {
|
|
|
|
|
return nil, errors.New("public header: unknown access_policy")
|
|
|
|
|
}
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// roundFromDateKey decodes "dk1_" + base64url without padding of the JSON
|
|
|
|
|
// {"version":1,"network":"datekeys:quicknet:v1","round":N} (spec §18, §19)
|
|
|
|
|
// and checks that re-encoding it gives the same string.
|
|
|
|
|
func roundFromDateKey(s string) (uint64, error) {
|
|
|
|
|
rest, ok := strings.CutPrefix(s, "dk1_")
|
|
|
|
|
if !ok {
|
|
|
|
|
return 0, fmt.Errorf("DateKey %q has no dk1_ prefix", s)
|
|
|
|
|
}
|
|
|
|
|
raw, err := base64.RawURLEncoding.DecodeString(rest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("DateKey: %w", err)
|
|
|
|
|
}
|
|
|
|
|
var dk struct {
|
|
|
|
|
Version int `json:"version"`
|
|
|
|
|
Network string `json:"network"`
|
|
|
|
|
Round uint64 `json:"round"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, &dk); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("DateKey: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if dk.Version != 1 || dk.Network != quicknetProfileID || dk.Round == 0 {
|
|
|
|
|
return 0, fmt.Errorf("DateKey %s is not a Quicknet DateKey of version 1", raw)
|
|
|
|
|
}
|
|
|
|
|
canonical := fmt.Sprintf(`{"version":1,"network":"%s","round":%d}`, dk.Network, dk.Round)
|
|
|
|
|
if "dk1_"+base64.RawURLEncoding.EncodeToString([]byte(canonical)) != s {
|
|
|
|
|
return 0, fmt.Errorf("DateKey %q is not canonical", s)
|
|
|
|
|
}
|
|
|
|
|
return dk.Round, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 4. Verify the release (spec §63 step 10):
|
|
|
|
|
//
|
|
|
|
|
// M = SHA-256(uint64_be(round))
|
|
|
|
|
// e(H(M), public_key) == e(signature, G2)
|
|
|
|
|
//
|
|
|
|
|
// with H the hash to G1 of RFC 9380 (suite BLS12381G1_XMD:SHA-256_SSWU_RO_)
|
|
|
|
|
// and the DST above.
|
|
|
|
|
|
|
|
|
|
var suite = bls.NewBLS12381Suite()
|
|
|
|
|
|
|
|
|
|
func roundMessage(round uint64) []byte {
|
|
|
|
|
var b [8]byte
|
|
|
|
|
binary.BigEndian.PutUint64(b[:], round)
|
|
|
|
|
h := sha256.Sum256(b[:])
|
|
|
|
|
return h[:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hashToG1(msg []byte) kyber.Point {
|
|
|
|
|
return bls.NullKyberG1([]byte(quicknetDST)...).Hash(msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// decodePoint decodes the compressed encoding of a point of G1 (48 bytes) or
|
|
|
|
|
// G2 (96 bytes) (spec §12.2). The library checks the flags, that each
|
|
|
|
|
// coordinate is below p, that the point is on the curve and in the subgroup;
|
|
|
|
|
// re-encoding it catches any other non-canonical string, and the infinity
|
|
|
|
|
// flag is refused because no use admits the point at infinity.
|
|
|
|
|
func decodePoint(p kyber.Point, b []byte, size int, what string) error {
|
|
|
|
|
if len(b) != size {
|
|
|
|
|
return fmt.Errorf("%s: %d bytes, want %d", what, len(b), size)
|
|
|
|
|
}
|
|
|
|
|
if b[0]&0x40 != 0 {
|
|
|
|
|
return fmt.Errorf("%s: point at infinity", what)
|
|
|
|
|
}
|
|
|
|
|
if err := p.UnmarshalBinary(b); err != nil {
|
|
|
|
|
return fmt.Errorf("%s: %w", what, err)
|
|
|
|
|
}
|
|
|
|
|
again, err := p.MarshalBinary()
|
|
|
|
|
if err != nil || !bytes.Equal(again, b) {
|
|
|
|
|
return fmt.Errorf("%s: not the canonical encoding", what)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func verifyRelease(rel release) (kyber.Point, error) {
|
|
|
|
|
pk := bls.NullKyberG2()
|
|
|
|
|
if err := decodePoint(pk, mustHex(quicknetPublicKey), 96, "public key"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
sig := bls.NullKyberG1()
|
|
|
|
|
if err := decodePoint(sig, rel.signature, 48, "release signature"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
left := suite.Pair(hashToG1(roundMessage(rel.round)), pk)
|
|
|
|
|
right := suite.Pair(sig, bls.NullKyberG2().Base())
|
|
|
|
|
if !left.Equal(right) {
|
|
|
|
|
return nil, fmt.Errorf("release: the signature does not verify for round %d", rel.round)
|
|
|
|
|
}
|
|
|
|
|
return sig, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 5a. The tlock stanza (spec §63 step 11 and the paragraphs after the
|
|
|
|
|
// flow). Its body is U (96 bytes, a compressed point of G2) || V (16) || W (16):
|
|
|
|
|
//
|
|
|
|
|
// sigma = V XOR H2(e(signature, U))
|
|
|
|
|
// FK_TIME = W XOR H4(sigma)
|
|
|
|
|
// r = H3(sigma, FK_TIME), and r·G2 MUST be U
|
|
|
|
|
|
|
|
|
|
// h2 is the first 16 bytes of SHA-256("IBE-H2" || GT element), with GT in the
|
|
|
|
|
// 576-byte serialization of kilic/bls12-381: c1 before c0 at every level of
|
|
|
|
|
// the tower, each Fp element in 48 bytes big-endian. kyber-bls12381's
|
|
|
|
|
// MarshalBinary of a GT element is exactly that.
|
|
|
|
|
func h2(gt kyber.Point) ([]byte, error) {
|
|
|
|
|
b, err := gt.MarshalBinary()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
h := sha256.Sum256(append([]byte("IBE-H2"), b...))
|
|
|
|
|
return h[:16], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// h4 is the first 16 bytes of SHA-256("IBE-H4" || sigma).
|
|
|
|
|
func h4(sigma []byte) []byte {
|
|
|
|
|
h := sha256.Sum256(append([]byte("IBE-H4"), sigma...))
|
|
|
|
|
return h[:16]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// h3 turns (sigma, file key) into the scalar r:
|
|
|
|
|
//
|
|
|
|
|
// base = SHA-256("IBE-H3" || sigma || fileKey)
|
|
|
|
|
// for i = 1, 2, ..., 65534:
|
|
|
|
|
// d = SHA-256(uint16_le(i) || base); d[0] >>= 1
|
|
|
|
|
// if int_be(d) < q: return it
|
|
|
|
|
func h3(sigma, fileKey []byte) (*big.Int, error) {
|
|
|
|
|
base := sha256.Sum256(append(append([]byte("IBE-H3"), sigma...), fileKey...))
|
|
|
|
|
for i := 1; i <= 65534; i++ {
|
|
|
|
|
var ctr [2]byte
|
|
|
|
|
binary.LittleEndian.PutUint16(ctr[:], uint16(i))
|
|
|
|
|
d := sha256.Sum256(append(ctr[:], base[:]...))
|
|
|
|
|
d[0] >>= 1
|
|
|
|
|
r := new(big.Int).SetBytes(d[:])
|
|
|
|
|
if r.Cmp(groupOrder) < 0 {
|
|
|
|
|
return r, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil, errors.New("tlock: H3 found no scalar")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func xor(a, b []byte) []byte {
|
|
|
|
|
out := make([]byte, len(a))
|
|
|
|
|
for i := range a {
|
|
|
|
|
out[i] = a[i] ^ b[i]
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// unwrapTlock recovers FK_TIME from the body of the tlock stanza.
|
|
|
|
|
func unwrapTlock(body []byte, sig kyber.Point) ([]byte, error) {
|
|
|
|
|
if len(body) != 128 {
|
|
|
|
|
return nil, fmt.Errorf("tlock: stanza body of %d bytes, want 128", len(body))
|
|
|
|
|
}
|
|
|
|
|
u := bls.NullKyberG2()
|
|
|
|
|
if err := decodePoint(u, body[:96], 96, "tlock U"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
v, w := body[96:112], body[112:128]
|
|
|
|
|
mask, err := h2(suite.Pair(sig, u))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
sigma := xor(v, mask)
|
|
|
|
|
fileKey := xor(w, h4(sigma))
|
|
|
|
|
r, err := h3(sigma, fileKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// kyber's scalar reads 32 bytes big-endian (mod.Int, BigEndian).
|
|
|
|
|
s := bls.NewKyberScalar().SetBytes(r.FillBytes(make([]byte, 32)))
|
|
|
|
|
if !bls.NullKyberG2().Mul(s, bls.NullKyberG2().Base()).Equal(u) {
|
|
|
|
|
return nil, errors.New("tlock: r·G2 != U, the release does not open this stanza")
|
|
|
|
|
}
|
|
|
|
|
return fileKey, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 5b. Decrypting an age v1 file with a known file key (C2SP age).
|
|
|
|
|
//
|
|
|
|
|
// Header:
|
|
|
|
|
//
|
|
|
|
|
// age-encryption.org/v1\n
|
|
|
|
|
// -> TYPE ARG ...\n one per stanza
|
|
|
|
|
// BODY in base64, standard alphabet, no padding, 64 columns; the last
|
|
|
|
|
// line is shorter than 64 (maybe empty)
|
|
|
|
|
// --- MAC\n MAC in base64 without padding, 32 bytes
|
|
|
|
|
//
|
|
|
|
|
// MAC = HMAC-SHA-256(HKDF-SHA-256(ikm = file key, salt = empty, info =
|
|
|
|
|
// "header"), the header up to and including "---", without the space).
|
|
|
|
|
//
|
|
|
|
|
// Payload: a 16-byte nonce, then STREAM: key = HKDF-SHA-256(ikm = file key,
|
|
|
|
|
// salt = nonce, info = "payload"); ChaCha20-Poly1305 over chunks of 64 KiB
|
|
|
|
|
// of plaintext (64 KiB + 16 bytes of ciphertext); the 12-byte nonce of chunk
|
|
|
|
|
// i is uint88_be(i) || flag, with flag 0x01 on the last chunk and 0x00 on the
|
|
|
|
|
// others. Only the last chunk may be shorter, and it is empty only when the
|
|
|
|
|
// whole plaintext is empty.
|
|
|
|
|
|
|
|
|
|
type ageStanza struct {
|
|
|
|
|
args []string // args[0] is the type
|
|
|
|
|
body []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ageHeader struct {
|
|
|
|
|
stanzas []ageStanza
|
|
|
|
|
macInput []byte // the header up to and including "---"
|
|
|
|
|
mac []byte
|
|
|
|
|
payload []byte // what follows the header: nonce || STREAM
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseAgeHeader(file []byte) (*ageHeader, error) {
|
|
|
|
|
pos := 0
|
|
|
|
|
line := func() (string, int, error) {
|
|
|
|
|
start := pos
|
|
|
|
|
n := bytes.IndexByte(file[pos:], '\n')
|
|
|
|
|
if n < 0 {
|
|
|
|
|
return "", 0, errors.New("age: truncated header")
|
|
|
|
|
}
|
|
|
|
|
pos += n + 1
|
|
|
|
|
return string(file[start : start+n]), start, nil
|
|
|
|
|
}
|
|
|
|
|
b64 := base64.RawStdEncoding.Strict()
|
|
|
|
|
|
|
|
|
|
first, _, err := line()
|
|
|
|
|
if err != nil || first != "age-encryption.org/v1" {
|
|
|
|
|
return nil, errors.New("age: not an age v1 file")
|
|
|
|
|
}
|
|
|
|
|
h := &ageHeader{}
|
|
|
|
|
for {
|
|
|
|
|
l, start, err := line()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if mac, ok := strings.CutPrefix(l, "--- "); ok {
|
|
|
|
|
if h.mac, err = b64.DecodeString(mac); err != nil || len(h.mac) != 32 {
|
|
|
|
|
return nil, errors.New("age: malformed header MAC")
|
|
|
|
|
}
|
|
|
|
|
h.macInput = file[:start+3]
|
|
|
|
|
h.payload = file[pos:]
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
args, ok := strings.CutPrefix(l, "-> ")
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("age: malformed header line %q", l)
|
|
|
|
|
}
|
|
|
|
|
st := ageStanza{args: strings.Split(args, " ")}
|
|
|
|
|
for {
|
|
|
|
|
bl, _, err := line()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if len(bl) > 64 {
|
|
|
|
|
return nil, errors.New("age: stanza body line longer than 64 columns")
|
|
|
|
|
}
|
|
|
|
|
chunk, err := b64.DecodeString(bl)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("age: stanza body: %w", err)
|
|
|
|
|
}
|
|
|
|
|
st.body = append(st.body, chunk...)
|
|
|
|
|
if len(bl) < 64 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
h.stanzas = append(h.stanzas, st)
|
|
|
|
|
}
|
|
|
|
|
if len(h.stanzas) == 0 {
|
|
|
|
|
return nil, errors.New("age: header without stanzas")
|
|
|
|
|
}
|
|
|
|
|
return h, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ageDecryptWithFileKey checks the header MAC and decrypts the payload.
|
|
|
|
|
func ageDecryptWithFileKey(h *ageHeader, fileKey []byte) ([]byte, error) {
|
|
|
|
|
macKey, err := hkdf.Key(sha256.New, fileKey, nil, "header", 32)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
mac := hmac.New(sha256.New, macKey)
|
|
|
|
|
mac.Write(h.macInput)
|
|
|
|
|
if !hmac.Equal(mac.Sum(nil), h.mac) {
|
|
|
|
|
return nil, errors.New("age: wrong header MAC")
|
|
|
|
|
}
|
|
|
|
|
if len(h.payload) < 16 {
|
|
|
|
|
return nil, errors.New("age: payload without nonce")
|
|
|
|
|
}
|
|
|
|
|
key, err := hkdf.Key(sha256.New, fileKey, h.payload[:16], "payload", 32)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return streamDecrypt(key, h.payload[16:])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func streamDecrypt(key, ct []byte) ([]byte, error) {
|
|
|
|
|
const chunkSize, tagSize = 64 * 1024, 16
|
|
|
|
|
aead, err := chacha20poly1305.New(key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var out []byte
|
|
|
|
|
for counter := uint64(0); ; counter++ {
|
|
|
|
|
n := min(len(ct), chunkSize+tagSize)
|
|
|
|
|
last := n == len(ct)
|
|
|
|
|
if n < tagSize {
|
|
|
|
|
return nil, errors.New("age: truncated payload")
|
|
|
|
|
}
|
|
|
|
|
var nonce [12]byte
|
|
|
|
|
binary.BigEndian.PutUint64(nonce[3:11], counter) // uint88_be, high bytes 0
|
|
|
|
|
if last {
|
|
|
|
|
nonce[11] = 1
|
|
|
|
|
}
|
|
|
|
|
pt, err := aead.Open(nil, nonce[:], ct[:n], nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("age: payload chunk %d does not authenticate", counter)
|
|
|
|
|
}
|
|
|
|
|
if last && len(pt) == 0 && counter > 0 {
|
|
|
|
|
return nil, errors.New("age: empty last chunk")
|
|
|
|
|
}
|
|
|
|
|
out = append(out, pt...)
|
|
|
|
|
ct = ct[n:]
|
|
|
|
|
if last {
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// openSealedControl opens OUTER_TIME_AGE: exactly one stanza
|
|
|
|
|
// "-> tlock <round> <chain hash hex>", whose body gives FK_TIME.
|
|
|
|
|
func openSealedControl(sealed []byte, round uint64, sig kyber.Point) ([]byte, error) {
|
|
|
|
|
h, err := parseAgeHeader(sealed)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("sealed control: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if len(h.stanzas) != 1 || h.stanzas[0].args[0] != "tlock" {
|
|
|
|
|
return nil, errors.New("sealed control: not exactly one tlock stanza")
|
|
|
|
|
}
|
|
|
|
|
args := h.stanzas[0].args
|
|
|
|
|
if len(args) != 3 || args[1] != strconv.FormatUint(round, 10) || args[2] != quicknetChainHash {
|
|
|
|
|
return nil, fmt.Errorf("sealed control: tlock stanza for %v, want round %d of Quicknet", args[1:], round)
|
|
|
|
|
}
|
|
|
|
|
fk, err := unwrapTlock(h.stanzas[0].body, sig)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return ageDecryptWithFileKey(h, fk)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Steps 6 and 7. The X25519 layers are plain age files: a raw X25519 scalar
|
|
|
|
|
// of 32 bytes is the identity "AGE-SECRET-KEY-1..." (Bech32, not Bech32m,
|
|
|
|
|
// HRP "age-secret-key-", in upper case), which age and its library accept.
|
|
|
|
|
|
|
|
|
|
func bech32Polymod(values []byte) uint32 {
|
|
|
|
|
gen := [5]uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
|
|
|
|
chk := uint32(1)
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
top := chk >> 25
|
|
|
|
|
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
|
|
|
|
|
for i := range 5 {
|
|
|
|
|
if (top>>i)&1 == 1 {
|
|
|
|
|
chk ^= gen[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return chk
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func bech32Encode(hrp string, data []byte) string {
|
|
|
|
|
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
|
|
|
// Regroup 8-bit bytes into 5-bit groups, padding the last with zeros.
|
|
|
|
|
var groups []byte
|
|
|
|
|
acc, bits := 0, 0
|
|
|
|
|
for _, b := range data {
|
|
|
|
|
acc = acc<<8 | int(b)
|
|
|
|
|
bits += 8
|
|
|
|
|
for bits >= 5 {
|
|
|
|
|
bits -= 5
|
|
|
|
|
groups = append(groups, byte(acc>>bits)&31)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bits > 0 {
|
|
|
|
|
groups = append(groups, byte(acc<<(5-bits))&31)
|
|
|
|
|
}
|
|
|
|
|
var values []byte
|
|
|
|
|
for _, c := range []byte(hrp) {
|
|
|
|
|
values = append(values, c>>5)
|
|
|
|
|
}
|
|
|
|
|
values = append(values, 0)
|
|
|
|
|
for _, c := range []byte(hrp) {
|
|
|
|
|
values = append(values, c&31)
|
|
|
|
|
}
|
|
|
|
|
values = append(values, groups...)
|
|
|
|
|
mod := bech32Polymod(append(values, 0, 0, 0, 0, 0, 0)) ^ 1
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
sb.WriteString(hrp + "1")
|
|
|
|
|
for _, g := range groups {
|
|
|
|
|
sb.WriteByte(charset[g])
|
|
|
|
|
}
|
|
|
|
|
for i := range 6 {
|
|
|
|
|
sb.WriteByte(charset[(mod>>(5*(5-i)))&31])
|
|
|
|
|
}
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ageSecretKey(raw []byte) string {
|
|
|
|
|
return strings.ToUpper(bech32Encode("age-secret-key-", raw))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ageDecryptX25519 is `age -d -i key.txt` with key.txt holding the identity.
|
|
|
|
|
func ageDecryptX25519(file, raw []byte) ([]byte, error) {
|
|
|
|
|
id, err := age.ParseX25519Identity(ageSecretKey(raw))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
r, err := age.Decrypt(bytes.NewReader(file), id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return io.ReadAll(r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readAccessKey reads access_material from a .dkk (spec §40, §41):
|
|
|
|
|
//
|
|
|
|
|
// "DKK1" | VERSION 1 | FLAGS 0 | RESERVED 0 0 | BODY_LEN (uint32 BE) | BODY_CBOR
|
|
|
|
|
//
|
|
|
|
|
// BODY_CBOR: 0 → "datekeys-access-key", 1 → 1, 3 → capsule_id,
|
|
|
|
|
// 4 → "x25519", 5 → access_material (32 raw bytes).
|
|
|
|
|
func readAccessKey(b []byte, capsuleID []byte) ([]byte, error) {
|
|
|
|
|
if len(b) < 12 || string(b[:4]) != "DKK1" || b[4] != 1 {
|
|
|
|
|
return nil, errors.New("not a .dkk file of version 1")
|
|
|
|
|
}
|
|
|
|
|
n := uint64(binary.BigEndian.Uint32(b[8:12]))
|
|
|
|
|
if n != uint64(len(b)-12) {
|
|
|
|
|
return nil, errors.New(".dkk: BODY_LEN does not match the file")
|
|
|
|
|
}
|
|
|
|
|
v, err := decodeCBOR(b[12:])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf(".dkk: %w", err)
|
|
|
|
|
}
|
|
|
|
|
m, err := asMap(v, ".dkk")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := m.checkType("datekeys-access-key", 1); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if id, err := m.bytes(3); err != nil || !bytes.Equal(id, capsuleID) {
|
|
|
|
|
return nil, errors.New(".dkk: it is the key of another capsule")
|
|
|
|
|
}
|
|
|
|
|
if t, err := m.text(4); err != nil || t != "x25519" {
|
|
|
|
|
return nil, errors.New(".dkk: access_type is not x25519")
|
|
|
|
|
}
|
|
|
|
|
mat, err := m.bytes(5)
|
|
|
|
|
if err != nil || len(mat) != 32 {
|
|
|
|
|
return nil, errors.New(".dkk: access_material is not 32 bytes")
|
|
|
|
|
}
|
|
|
|
|
return mat, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// control is what CONTROL_CBOR gives (spec §31): 0 → "datekeys-control",
|
|
|
|
|
// 1 → the format, 3 → I_PAYLOAD (32 raw bytes); in formats 2 and 3,
|
|
|
|
|
// 6 → L (8 bytes big-endian) and 7 → the padding code.
|
|
|
|
|
type control struct {
|
|
|
|
|
payloadIdentity []byte
|
|
|
|
|
length uint64 // L, formats 2 and 3
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseControl(b []byte, format int) (control, error) {
|
|
|
|
|
v, err := decodeCBOR(b)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return control{}, fmt.Errorf("control: %w", err)
|
|
|
|
|
}
|
|
|
|
|
m, err := asMap(v, "control")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return control{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := m.checkType("datekeys-control", uint64(format)); err != nil {
|
|
|
|
|
return control{}, err
|
|
|
|
|
}
|
|
|
|
|
var c control
|
|
|
|
|
if c.payloadIdentity, err = m.bytes(3); err != nil || len(c.payloadIdentity) != 32 {
|
|
|
|
|
return control{}, errors.New("control: I_PAYLOAD is not 32 bytes")
|
|
|
|
|
}
|
|
|
|
|
if format >= 2 {
|
|
|
|
|
l, err := m.bytes(6)
|
|
|
|
|
if err != nil || len(l) != 8 {
|
|
|
|
|
return control{}, errors.New("control: L is not 8 bytes")
|
|
|
|
|
}
|
|
|
|
|
c.length = binary.BigEndian.Uint64(l)
|
|
|
|
|
}
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Step 8. The content. Format 1: the whole plaintext. Formats 2 and 3: the
|
|
|
|
|
// first L bytes; the rest is zero padding. Format 3: those L bytes are BODY
|
|
|
|
|
// (spec §29.2):
|
|
|
|
|
//
|
|
|
|
|
// AREA_LEN (uint32 BE) | SECURITY_LEN (uint32 BE) | HEAD_LEN (uint32 BE) |
|
|
|
|
|
// area (AREA_LEN bytes: SECURITY_CBOR and zeros) | HEAD_CBOR | CONTENT
|
|
|
|
|
//
|
|
|
|
|
// and the head (spec §29.4): 0 → "datekeys-head", 1 → 1, 3 → comment,
|
|
|
|
|
// 4 → declared_author, 5 → files: maps 0 → path, 1 → size, 2 → start,
|
|
|
|
|
// 3 → end (exclusive), 4 → sha256, 5 → mtime (optional). File i is
|
|
|
|
|
// CONTENT[start:end].
|
|
|
|
|
|
|
|
|
|
type fileEntry struct {
|
|
|
|
|
path string
|
|
|
|
|
start, end uint64
|
|
|
|
|
sha256 []byte
|
|
|
|
|
mtime *uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type body struct {
|
|
|
|
|
comment string
|
|
|
|
|
files []fileEntry
|
|
|
|
|
content []byte // CONTENT
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func contentOf(format int, plaintext []byte, c control) ([]byte, error) {
|
|
|
|
|
if format == 1 {
|
|
|
|
|
return plaintext, nil
|
|
|
|
|
}
|
|
|
|
|
if c.length > uint64(len(plaintext)) {
|
|
|
|
|
return nil, fmt.Errorf("payload: plaintext of %d bytes, shorter than L = %d", len(plaintext), c.length)
|
|
|
|
|
}
|
|
|
|
|
for _, b := range plaintext[c.length:] {
|
|
|
|
|
if b != 0 {
|
|
|
|
|
return nil, errors.New("payload: padding is not zero")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return plaintext[:c.length], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseBody(b []byte) (*body, error) {
|
|
|
|
|
if len(b) < 12 {
|
|
|
|
|
return nil, errors.New("body: shorter than its 12-byte frame")
|
|
|
|
|
}
|
|
|
|
|
area := uint64(binary.BigEndian.Uint32(b[0:4]))
|
|
|
|
|
headLen := uint64(binary.BigEndian.Uint32(b[8:12]))
|
|
|
|
|
if 12+area+headLen > uint64(len(b)) {
|
|
|
|
|
return nil, errors.New("body: area and head do not fit")
|
|
|
|
|
}
|
|
|
|
|
v, err := decodeCBOR(b[12+area : 12+area+headLen])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("head: %w", err)
|
|
|
|
|
}
|
|
|
|
|
m, err := asMap(v, "head")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := m.checkType("datekeys-head", 1); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
out := &body{content: b[12+area+headLen:]}
|
|
|
|
|
out.comment, _ = m[3].(string)
|
|
|
|
|
list, _ := m[5].([]any)
|
|
|
|
|
for i, e := range list {
|
|
|
|
|
fm, err := asMap(e, "head file")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var f fileEntry
|
|
|
|
|
if f.path, err = fm.text(0); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("head file %d: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
size, err1 := fm.uint(1)
|
|
|
|
|
start, err2 := fm.uint(2)
|
|
|
|
|
end, err3 := fm.uint(3)
|
|
|
|
|
sum, err4 := fm.bytes(4)
|
|
|
|
|
if err := errors.Join(err1, err2, err3, err4); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("head file %q: %w", f.path, err)
|
|
|
|
|
}
|
|
|
|
|
if start > end || end > uint64(len(out.content)) || end-start != size {
|
|
|
|
|
return nil, fmt.Errorf("head file %q: bytes %d to %d do not fit the content", f.path, start, end)
|
|
|
|
|
}
|
|
|
|
|
got := sha256.Sum256(out.content[start:end])
|
|
|
|
|
if !bytes.Equal(got[:], sum) {
|
|
|
|
|
return nil, fmt.Errorf("head file %q: SHA-256 mismatch", f.path)
|
|
|
|
|
}
|
|
|
|
|
f.start, f.end, f.sha256 = start, end, sum
|
|
|
|
|
if mt, ok := fm[5].(uint64); ok {
|
|
|
|
|
f.mtime = &mt
|
|
|
|
|
}
|
|
|
|
|
out.files = append(out.files, f)
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeFiles writes each file of the head under dir. Paths use "/" and are
|
|
|
|
|
// relative (spec §29.5); anything that would leave dir is refused.
|
|
|
|
|
func writeFiles(dir string, b *body, log io.Writer) error {
|
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, f := range b.files {
|
|
|
|
|
rel := filepath.FromSlash(f.path)
|
|
|
|
|
if strings.Contains(f.path, `\`) || !filepath.IsLocal(rel) {
|
|
|
|
|
return fmt.Errorf("refusing unsafe path %q", f.path)
|
|
|
|
|
}
|
|
|
|
|
dst := filepath.Join(dir, rel)
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(dst, b.content[f.start:f.end], 0o644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if f.mtime != nil {
|
|
|
|
|
t := time.Unix(int64(*f.mtime), 0)
|
|
|
|
|
_ = os.Chtimes(dst, t, t)
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(log, " wrote %s (%d bytes, SHA-256 ok)\n", dst, f.end-f.start)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// The whole recovery.
|
|
|
|
|
|
|
|
|
|
type result struct {
|
|
|
|
|
format int
|
|
|
|
|
content []byte // the L bytes: the content, or BODY in format 3
|
|
|
|
|
body *body // format 3 only
|
|
|
|
|
}
|
|
|
|
|
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
// credential is what opens the access layer of a time_and_key capsule: a
|
|
|
|
|
// .dkk, or the text of the words of a key of words, with UnicodeData.txt
|
|
|
|
|
// when the text needs it.
|
|
|
|
|
type credential struct {
|
|
|
|
|
dkk []byte
|
|
|
|
|
words *string
|
|
|
|
|
ucd *unicodeData
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func recoverCapsule(dkc, relObj []byte, key credential, log io.Writer) (*result, error) {
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
c, err := parseCapsule(dkc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(log, "capsule: format %d, round %d (%s), policy %d\n",
|
|
|
|
|
c.format, c.round, roundTime(c.round).Format(time.RFC3339), c.policy)
|
|
|
|
|
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
rel, err := readRelease(relObj)
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if rel.round != c.round {
|
|
|
|
|
return nil, fmt.Errorf("release of round %d, the capsule needs round %d", rel.round, c.round)
|
|
|
|
|
}
|
|
|
|
|
sig, err := verifyRelease(rel)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintln(log, "release: BLS signature verified")
|
|
|
|
|
|
|
|
|
|
inner, err := openSealedControl(c.sealed, c.round, sig)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintln(log, "sealed control: tlock opened, age MAC and STREAM ok")
|
|
|
|
|
|
|
|
|
|
ctl := inner
|
|
|
|
|
if c.policy == 1 {
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
var id []byte
|
|
|
|
|
from := "the .dkk"
|
|
|
|
|
switch {
|
|
|
|
|
case key.words != nil:
|
|
|
|
|
words, err := normalizeWords(*key.words, key.ucd)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if id, err = wordKey(words, c.round, c.capsuleID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
from = fmt.Sprintf("the %d words", len(words))
|
|
|
|
|
case key.dkk != nil:
|
|
|
|
|
if id, err = readAccessKey(key.dkk, c.capsuleID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return nil, errors.New("time_and_key capsule: it needs its .dkk (-dkk) or the words of its key (-words)")
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
}
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
if ctl, err = ageDecryptX25519(inner, id); err != nil {
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
return nil, fmt.Errorf("access layer: %w", err)
|
|
|
|
|
}
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
fmt.Fprintln(log, "access layer: opened with "+from)
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cc, err := parseControl(ctl, c.format)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
plaintext, err := ageDecryptX25519(c.payload, cc.payloadIdentity)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("payload: %w", err)
|
|
|
|
|
}
|
|
|
|
|
content, err := contentOf(c.format, plaintext, cc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(log, "payload: opened, %d bytes of content\n", len(content))
|
|
|
|
|
|
|
|
|
|
res := &result{format: c.format, content: content}
|
|
|
|
|
if c.format == 3 {
|
|
|
|
|
if res.body, err = parseBody(content); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(log, "body: %d files, every SHA-256 ok\n", len(res.body.files))
|
|
|
|
|
}
|
|
|
|
|
return res, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func run(args []string, log io.Writer) error {
|
|
|
|
|
fs := flag.NewFlagSet("recovery", flag.ContinueOnError)
|
|
|
|
|
fs.SetOutput(log)
|
|
|
|
|
dkcPath := fs.String("dkc", "", "the capsule (.dkc)")
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
relPath := fs.String("release", "", "the release object of its round")
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
dkkPath := fs.String("dkk", "", "the access key (.dkk), for time_and_key")
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
wordsPath := fs.String("words", "", "a text file with the words of a key of words, for time_and_key")
|
|
|
|
|
ucdPath := fs.String("unicodedata", "", "UnicodeData.txt of Unicode 18.0.0, for words outside the normalization without tables")
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
out := fs.String("out", "", "output: a file in formats 1 and 2, a directory in format 3")
|
|
|
|
|
bodyPath := fs.String("body", "", "optional: write the L bytes (content, or BODY in format 3)")
|
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
if *dkcPath == "" || *relPath == "" || *out == "" {
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
return errors.New("usage: recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk | -words FILE [-unicodedata FILE]] -out PATH [-body FILE]")
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
}
|
|
|
|
|
dkc, err := os.ReadFile(*dkcPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 hours ago
|
|
|
relObj, err := os.ReadFile(*relPath)
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
var key credential
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
if *dkkPath != "" {
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
if key.dkk, err = os.ReadFile(*dkkPath); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if *wordsPath != "" {
|
|
|
|
|
b, err := os.ReadFile(*wordsPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
text := string(b)
|
|
|
|
|
key.words = &text
|
|
|
|
|
}
|
|
|
|
|
if *ucdPath != "" {
|
|
|
|
|
b, err := os.ReadFile(*ucdPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if key.ucd, err = parseUnicodeData(b); err != nil {
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
v0.16: the key of words in the recovery annex, and a full last chunk
scripts/recovery opens a time_and_key capsule with the words of a key of
words (-words FILE), as the annex says in 79.7: the normalization without
tables for the text of the DateKeys lists (printable ASCII, the ASCII
spaces, á é í ó ú ü ñ and their capitals, and the marks U+0300 to U+036F),
and for any other text the full one, NFD, without the marks, simple lower
case and the spaces of 38.1, from UnicodeData.txt of Unicode 18.0.0
(-unicodedata FILE), checked by its SHA-256; then PBKDF2-HMAC-SHA256 of the
standard library. Its tests check both normalizations against every case
of wordkey.json and the two vectors of the annex.
Two fixtures of v0.16: format3_time_and_key_words, opened with the text of
the second vector of the annex, recorded in words_text with the identity it
gives; and format3_full_chunk, whose BODY and P are 65536 bytes, so that
PAYLOAD_AGE ends in a full STREAM chunk (79.5). recovery_check.sh opens
both. wordkey.json gains the text of the annex, also with its marks apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 hours ago
|
|
|
res, err := recoverCapsule(dkc, relObj, key, log)
|
Recovery check: open fixtures with the annex and no DateKeys code
scripts/recovery is a program that opens a capsule with only the Go
standard library, golang.org/x/crypto, filippo.io/age and the BLS12-381
library of drand/kyber-bls12381, as the informative annex of the draft
v0.15 describes it: the pinned Quicknet parameters, the release object,
the frame, the BLS verification of the release, the tlock stanza with H2,
H3 and H4, the age file of SEALED_CONTROL opened with its file key (HKDF,
header MAC and STREAM written out), the X25519 layers with age, and the
content of formats 1, 2 and 3. A test forbids importing this module, tlock
and drand. scripts/recovery_check.sh opens a time_only and a time_and_key
fixture of format 3 with it and compares what it recovers; scripts/check.sh
runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 day ago
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if *bodyPath != "" {
|
|
|
|
|
if err := os.WriteFile(*bodyPath, res.content, 0o644); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if res.format != 3 {
|
|
|
|
|
return os.WriteFile(*out, res.content, 0o644)
|
|
|
|
|
}
|
|
|
|
|
if res.body.comment != "" {
|
|
|
|
|
fmt.Fprintf(log, "comment:\n%s\n", res.body.comment)
|
|
|
|
|
}
|
|
|
|
|
return writeFiles(*out, res.body, log)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
if err := run(os.Args[1:], os.Stderr); err != nil {
|
|
|
|
|
fmt.Fprintln(os.Stderr, "recovery:", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|