From 18769be758b4292f9a75bc229d321064d3e7457c Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 7 Oct 2026 00:07:05 +0200 Subject: [PATCH] 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 --- scripts/check.sh | 6 +- scripts/recovery/main.go | 1102 +++++++++++++++++++++++++++++++++ scripts/recovery/main_test.go | 417 +++++++++++++ scripts/recovery_check.sh | 31 + 4 files changed, 1555 insertions(+), 1 deletion(-) create mode 100644 scripts/recovery/main.go create mode 100644 scripts/recovery/main_test.go create mode 100644 scripts/recovery_check.sh diff --git a/scripts/check.sh b/scripts/check.sh index eee6da4..ac26276 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -3,7 +3,8 @@ # for forges without runners. Run it before every push. # # scripts/check.sh # format, modules, vet, race tests, coverage, -# # govulncheck, vectors and fixtures +# # govulncheck, the recovery check, vectors +# # and fixtures # scripts/check.sh 20s # additionally fuzz every parser for 20 s set -euo pipefail cd "$(dirname "$0")/.." @@ -46,6 +47,9 @@ done echo "== govulncheck" go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... +echo "== the recovery annex opens fixtures without DateKeys code" +bash scripts/recovery_check.sh + echo "== vectors reproduce and fixtures are frozen" go run ./internal/testkit/genfixtures -out testdata git diff --exit-code -- testdata diff --git a/scripts/recovery/main.go b/scripts/recovery/main.go new file mode 100644 index 0000000..d676462 --- /dev/null +++ b/scripts/recovery/main.go @@ -0,0 +1,1102 @@ +// 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 +// ChaCha20-Poly1305. It does not import the DateKeys module, nor drand or +// 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. +// +// go run ./scripts/recovery -dkc FILE.dkc -release FILE.dkr [-dkk FILE.dkk] -out PATH [-body FILE] +// +// 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 +} + +// --------------------------------------------------------------------------- +// Step 2. The release (.dkr): a CBOR map +// +// 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 ", 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 +} + +func recoverCapsule(dkc, dkr, dkk []byte, log io.Writer) (*result, error) { + 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) + + rel, err := readRelease(dkr) + 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 { + if dkk == nil { + return nil, errors.New("time_and_key capsule: it needs its .dkk (-dkk)") + } + mat, err := readAccessKey(dkk, c.capsuleID) + if err != nil { + return nil, err + } + if ctl, err = ageDecryptX25519(inner, mat); err != nil { + return nil, fmt.Errorf("access layer: %w", err) + } + fmt.Fprintln(log, "access layer: opened with the .dkk") + } + + 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)") + dkrPath := fs.String("release", "", "the release of its round (.dkr)") + dkkPath := fs.String("dkk", "", "the access key (.dkk), for time_and_key") + 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 + } + if *dkcPath == "" || *dkrPath == "" || *out == "" { + return errors.New("usage: recovery -dkc FILE.dkc -release FILE.dkr [-dkk FILE.dkk] -out PATH [-body FILE]") + } + dkc, err := os.ReadFile(*dkcPath) + if err != nil { + return err + } + dkr, err := os.ReadFile(*dkrPath) + if err != nil { + return err + } + var dkk []byte + if *dkkPath != "" { + if dkk, err = os.ReadFile(*dkkPath); err != nil { + return err + } + } + res, err := recoverCapsule(dkc, dkr, dkk, log) + 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) + } +} diff --git a/scripts/recovery/main_test.go b/scripts/recovery/main_test.go new file mode 100644 index 0000000..41579ac --- /dev/null +++ b/scripts/recovery/main_test.go @@ -0,0 +1,417 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "go/parser" + "go/token" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "filippo.io/age" + bls "github.com/drand/kyber-bls12381" +) + +const ( + fixturesDir = "../../testdata/fixtures" + releasesDir = "../../testdata/releases" + vectorsDir = "../../testdata/vectors" +) + +type fixtureRecord struct { + Format int `json:"format"` + Release struct { + Round uint64 `json:"round"` + Signature string `json:"signature"` + } `json:"release"` + PayloadLength uint64 `json:"payload_length"` + PlaintextFile string `json:"plaintext_file"` + PlaintextSHA256 string `json:"plaintext_sha256"` + AccessKeyFile string `json:"access_key_file"` + Files []struct { + Path string `json:"path"` + Size uint64 `json:"size"` + SHA256 string `json:"sha256"` + } `json:"files"` +} + +func readJSON(t *testing.T, path string, v any) { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, v); err != nil { + t.Fatalf("%s: %v", path, err) + } +} + +func unhex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +// A tiny deterministic CBOR encoder, enough for a release object. +func cborHead(major byte, n uint64) []byte { + switch { + case n < 24: + return []byte{major<<5 | byte(n)} + case n < 1<<8: + return []byte{major<<5 | 24, byte(n)} + case n < 1<<16: + return []byte{major<<5 | 25, byte(n >> 8), byte(n)} + case n < 1<<32: + return []byte{major<<5 | 26, byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} + } + return []byte{major<<5 | 27, byte(n >> 56), byte(n >> 48), byte(n >> 40), byte(n >> 32), + byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} +} + +func encodeRelease(chainHash []byte, round uint64, sig []byte) []byte { + var b []byte + b = append(b, cborHead(5, 5)...) + b = append(b, cborHead(0, 0)...) + b = append(b, cborHead(3, 16)...) + b = append(b, "datekeys-release"...) + b = append(b, cborHead(0, 1)...) + b = append(b, cborHead(0, 1)...) + b = append(b, cborHead(0, 2)...) + b = append(b, cborHead(2, uint64(len(chainHash)))...) + b = append(b, chainHash...) + b = append(b, cborHead(0, 3)...) + b = append(b, cborHead(0, round)...) + b = append(b, cborHead(0, 4)...) + b = append(b, cborHead(2, uint64(len(sig)))...) + b = append(b, sig...) + return b +} + +// committedRelease reads testdata/releases/.dkr and checks that it is +// the release object built here from the fixture record. +func committedRelease(t *testing.T, round uint64, sigHex string) []byte { + t.Helper() + want := encodeRelease(unhex(t, quicknetChainHash), round, unhex(t, sigHex)) + path := filepath.Join(releasesDir, strconv.FormatUint(round, 10)+".dkr") + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("the committed release is missing: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("%s:\n got %x\nwant %x", path, got, want) + } + return got +} + +func loadFixture(t *testing.T, name string) (fixtureRecord, []byte, []byte, []byte) { + t.Helper() + var rec fixtureRecord + readJSON(t, filepath.Join(fixturesDir, name+".json"), &rec) + dkc, err := os.ReadFile(filepath.Join(fixturesDir, name+".dkc")) + if err != nil { + t.Fatal(err) + } + var dkk []byte + if rec.AccessKeyFile != "" { + if dkk, err = os.ReadFile(filepath.Join(fixturesDir, rec.AccessKeyFile)); err != nil { + t.Fatal(err) + } + } + return rec, dkc, committedRelease(t, rec.Release.Round, rec.Release.Signature), dkk +} + +func TestRecoverFixtures(t *testing.T) { + for _, name := range []string{ + "format3_single", + "format3_time_and_key_portable", + "format3_tree", + "format3_signed", + "format2_time_only", // two STREAM chunks + "format2_time_and_key_portable", + "format2_time_and_key_sixteen", // round 2000; no .dkk, so only its release is checked below + "time_only", + "time_and_key_portable", + } { + t.Run(name, func(t *testing.T) { + rec, dkc, dkr, dkk := loadFixture(t, name) + if name == "format2_time_and_key_sixteen" { + _, err := recoverCapsule(dkc, dkr, nil, io.Discard) + if err == nil || !strings.Contains(err.Error(), "needs its .dkk") { + t.Fatalf("got %v, want the .dkk to be required", err) + } + return + } + res, err := recoverCapsule(dkc, dkr, dkk, io.Discard) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join(fixturesDir, rec.PlaintextFile)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(res.content, want) { + t.Fatalf("content differs from %s", rec.PlaintextFile) + } + if res.format != rec.Format || uint64(len(res.content)) != rec.PayloadLength { + t.Fatalf("format %d, L %d; want %d, %d", res.format, len(res.content), rec.Format, rec.PayloadLength) + } + if res.format != 3 { + return + } + if len(res.body.files) != len(rec.Files) { + t.Fatalf("%d files, want %d", len(res.body.files), len(rec.Files)) + } + dir := t.TempDir() + if err := writeFiles(dir, res.body, io.Discard); err != nil { + t.Fatal(err) + } + for i, f := range rec.Files { + if res.body.files[i].path != f.Path { + t.Fatalf("file %d is %q, want %q", i, res.body.files[i].path, f.Path) + } + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f.Path))) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(b) + if hex.EncodeToString(sum[:]) != f.SHA256 || uint64(len(b)) != f.Size { + t.Fatalf("%s: written file differs from the record", f.Path) + } + } + }) + } +} + +func TestRun(t *testing.T) { + dir := t.TempDir() + out, bodyFile := filepath.Join(dir, "out"), filepath.Join(dir, "body") + args := []string{ + "-dkc", filepath.Join(fixturesDir, "format3_time_and_key_portable.dkc"), + "-release", filepath.Join(releasesDir, "1000.dkr"), + "-dkk", filepath.Join(fixturesDir, "format3_time_and_key_portable.dkk"), + "-out", out, "-body", bodyFile, + } + var log bytes.Buffer + if err := run(args, &log); err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + got, _ := os.ReadFile(bodyFile) + want, _ := os.ReadFile(filepath.Join(fixturesDir, "format3_time_and_key_portable.plaintext")) + if !bytes.Equal(got, want) { + t.Fatal("-body differs from the plaintext fixture") + } + if _, err := os.Stat(filepath.Join(out, "secreto.txt")); err != nil { + t.Fatal(err) + } +} + +func TestBadReleases(t *testing.T) { + var single, other fixtureRecord + readJSON(t, filepath.Join(fixturesDir, "format3_single.json"), &single) + readJSON(t, filepath.Join(fixturesDir, "empty_payload.json"), &other) // round 1001 + dkc, err := os.ReadFile(filepath.Join(fixturesDir, "format3_single.dkc")) + if err != nil { + t.Fatal(err) + } + chain := unhex(t, quicknetChainHash) + wrongChain := bytes.Clone(chain) + wrongChain[0] ^= 1 + sig, otherSig := unhex(t, single.Release.Signature), unhex(t, other.Release.Signature) + + for _, tc := range []struct { + name, want string + dkr []byte + }{ + {"wrong chain_hash", "not Quicknet's", encodeRelease(wrongChain, 1000, sig)}, + {"release of another round", "needs round 1000", encodeRelease(chain, other.Release.Round, otherSig)}, + {"signature of another round", "does not verify", encodeRelease(chain, 1000, otherSig)}, + {"wrong type tag", "type tag", bytes.Replace(encodeRelease(chain, 1000, sig), []byte("release"), []byte("relaxed"), 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := recoverCapsule(dkc, tc.dkr, nil, io.Discard) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("got %v, want an error with %q", err, tc.want) + } + }) + } +} + +// TestTlockVectors checks steps 10 and 11 value by value against +// testdata/vectors/tlock_steps.json and the GT serialization against +// tlock_ibe.json. +func TestTlockVectors(t *testing.T) { + var ibe struct { + Vectors []struct{ G1, G2, GT, H2 string } + } + readJSON(t, filepath.Join(vectorsDir, "tlock_ibe.json"), &ibe) + for _, v := range ibe.Vectors { + g1, g2 := bls.NullKyberG1(), bls.NullKyberG2() + if err := decodePoint(g1, unhex(t, v.G1), 48, "g1"); err != nil { + t.Fatal(err) + } + if err := decodePoint(g2, unhex(t, v.G2), 96, "g2"); err != nil { + t.Fatal(err) + } + if !g1.Equal(bls.NullKyberG1().Base()) || !g2.Equal(bls.NullKyberG2().Base()) { + t.Fatal("vector points are not the generators") + } + gt := suite.Pair(g1, g2) + raw, _ := gt.MarshalBinary() + if hex.EncodeToString(raw) != v.GT { + t.Fatal("GT serialization differs") + } + if h, _ := h2(gt); hex.EncodeToString(h) != v.H2 { + t.Fatalf("H2 = %x, want %s", h, v.H2) + } + } + + var steps struct { + ChainHash string `json:"chain_hash"` + PublicKey string `json:"public_key"` + DST string `json:"dst"` + Vectors []struct { + Round uint64 + Signature, Message string + HashToG1 string `json:"hash_to_g1"` + Body, Pairing, H2, Sigma, H4, R string + FileKey string `json:"file_key"` + } + } + readJSON(t, filepath.Join(vectorsDir, "tlock_steps.json"), &steps) + if steps.ChainHash != quicknetChainHash || steps.PublicKey != quicknetPublicKey || steps.DST != quicknetDST { + t.Fatal("pinned parameters differ from the vectors") + } + for _, v := range steps.Vectors { + if hex.EncodeToString(roundMessage(v.Round)) != v.Message { + t.Fatalf("round %d: M differs", v.Round) + } + hm, _ := hashToG1(roundMessage(v.Round)).MarshalBinary() + if hex.EncodeToString(hm) != v.HashToG1 { + t.Fatalf("round %d: H(M) differs", v.Round) + } + sig, err := verifyRelease(release{round: v.Round, signature: unhex(t, v.Signature)}) + if err != nil { + t.Fatal(err) + } + body := unhex(t, v.Body) + u := bls.NullKyberG2() + if err := decodePoint(u, body[:96], 96, "U"); err != nil { + t.Fatal(err) + } + gt := suite.Pair(sig, u) + raw, _ := gt.MarshalBinary() + mask, _ := h2(gt) + sigma := xor(body[96:112], mask) + r, err := h3(sigma, xor(body[112:], h4(sigma))) + if err != nil { + t.Fatal(err) + } + fk, err := unwrapTlock(body, sig) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct{ name, got, want string }{ + {"pairing", hex.EncodeToString(raw), v.Pairing}, + {"H2", hex.EncodeToString(mask), v.H2}, + {"sigma", hex.EncodeToString(sigma), v.Sigma}, + {"H4", hex.EncodeToString(h4(sigma)), v.H4}, + {"r", hex.EncodeToString(r.FillBytes(make([]byte, 32))), v.R}, + {"file key", hex.EncodeToString(fk), v.FileKey}, + } { + if c.got != c.want { + t.Fatalf("round %d: %s = %s, want %s", v.Round, c.name, c.got, c.want) + } + } + } +} + +func TestPinnedRoundTime(t *testing.T) { + // format3_single.json: round 1000 unlocks at 2023-08-23T15:59:24Z. + if got := roundTime(1000).Format("2006-01-02T15:04:05Z"); got != "2023-08-23T15:59:24Z" { + t.Fatal(got) + } +} + +// TestAgeSecretKey checks the Bech32 encoder against age's own. +func TestAgeSecretKey(t *testing.T) { + var rec struct { + AccessMaterial string `json:"access_material"` + } + readJSON(t, filepath.Join(fixturesDir, "format3_time_and_key_portable.dkk.json"), &rec) + key := ageSecretKey(unhex(t, rec.AccessMaterial)) + got, err := age.ParseX25519Identity(key) + if err != nil { + t.Fatal(err) + } + if got.String() != key { + t.Fatalf("age re-encodes %s as %s", key, got.String()) + } +} + +func TestStreamEdges(t *testing.T) { + if _, err := streamDecrypt(make([]byte, 32), nil); err == nil { + t.Fatal("an empty STREAM must fail") + } +} + +// TestTampered flips one byte in each part of a capsule: the tlock U, the +// sealed age payload and PAYLOAD_AGE must each make the recovery fail. +func TestTampered(t *testing.T) { + var rec fixtureRecord + readJSON(t, filepath.Join(fixturesDir, "format3_single.json"), &rec) + dkc, err := os.ReadFile(filepath.Join(fixturesDir, "format3_single.dkc")) + if err != nil { + t.Fatal(err) + } + dkr := encodeRelease(unhex(t, quicknetChainHash), rec.Release.Round, unhex(t, rec.Release.Signature)) + sealedEnd := 16 + 0x79 + 0x1ca // PUBLIC_HEADER_LEN and SEALED_CONTROL_LEN of its prelude + stanza := bytes.Index(dkc, []byte("-> tlock")) + body := stanza + bytes.IndexByte(dkc[stanza:], '\n') + 1 + for _, tc := range []struct { + name string + at int + }{ + {"tlock U", body + 10}, + {"sealed control STREAM", sealedEnd - 1}, + {"PAYLOAD_AGE STREAM", len(dkc) - 1}, + } { + t.Run(tc.name, func(t *testing.T) { + bad := bytes.Clone(dkc) + bad[tc.at] ^= 0x01 + if _, err := recoverCapsule(bad, dkr, nil, io.Discard); err == nil { + t.Fatal("a tampered capsule opened") + } + }) + } +} + +// TestImports keeps the program independent of DateKeys and of the drand +// tools: only the standard library, age, kyber and x/crypto. +func TestImports(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + var errs []error + for _, imp := range f.Imports { + p, _ := strconv.Unquote(imp.Path.Value) + for _, banned := range []string{"g.activething.com/go/DateKeys", "github.com/drand/tlock", "github.com/drand/drand"} { + if strings.HasPrefix(p, banned) { + errs = append(errs, errors.New("forbidden import "+p)) + } + } + } + if err := errors.Join(errs...); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/recovery_check.sh b/scripts/recovery_check.sh new file mode 100644 index 0000000..bc20446 --- /dev/null +++ b/scripts/recovery_check.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Opens official fixture capsules with scripts/recovery, a program that uses +# no DateKeys code (only Go, age, kyber and x/crypto), and compares what it +# recovers with the plaintext fixtures. It shows that the annex +# "Recuperación sin software DateKeys" of the specification is enough. +# +# scripts/recovery_check.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +fx=testdata/fixtures +rel=testdata/releases + +recover_one() { + local name=$1 round=$2 + shift 2 + echo "== $name (round $round)" + go run ./scripts/recovery -dkc "$fx/$name.dkc" -release "$rel/$round.dkr" "$@" \ + -out "$tmp/$name" -body "$tmp/$name.body" + cmp "$tmp/$name.body" "$fx/$name.plaintext" + echo "files written:" + (cd "$tmp/$name" && find . -type f | sort) +} + +recover_one format3_single 1000 +recover_one format3_time_and_key_portable 1000 -dkk "$fx/format3_time_and_key_portable.dkk" + +echo "recovery check passed"