You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1154 lines
33 KiB
1154 lines
33 KiB
// 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, and PBKDF2 of the standard library for a key of words.
|
|
// 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 [-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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 object: 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 <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
|
|
}
|
|
|
|
// 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) {
|
|
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(relObj)
|
|
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 {
|
|
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)")
|
|
}
|
|
if ctl, err = ageDecryptX25519(inner, id); err != nil {
|
|
return nil, fmt.Errorf("access layer: %w", err)
|
|
}
|
|
fmt.Fprintln(log, "access layer: opened with "+from)
|
|
}
|
|
|
|
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)")
|
|
relPath := fs.String("release", "", "the release object of its round")
|
|
dkkPath := fs.String("dkk", "", "the access key (.dkk), for time_and_key")
|
|
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")
|
|
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 == "" || *relPath == "" || *out == "" {
|
|
return errors.New("usage: recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk | -words FILE [-unicodedata FILE]] -out PATH [-body FILE]")
|
|
}
|
|
dkc, err := os.ReadFile(*dkcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relObj, err := os.ReadFile(*relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var key credential
|
|
if *dkkPath != "" {
|
|
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 {
|
|
return err
|
|
}
|
|
}
|
|
res, err := recoverCapsule(dkc, relObj, key, 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)
|
|
}
|
|
}
|