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.
DateKeys/internal/testkit/reseal.go

327 lines
11 KiB

package testkit
import (
"bytes"
"crypto/ecdh"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"filippo.io/age"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/cbortest"
)
// This file rebuilds age files deterministically, with a file key and a nonce
// that are already known: the STREAM of the age payload (C2SP age.md) and an
// X25519 stanza with a chosen ephemeral key. With them, the mutations of the
// format 2 fixtures edit a control, a stanza list or a plaintext and seal it
// again with the file key of the fixture, so that the result is the same on
// every run and keeps every MAC valid, as the holder of that file key could
// make it.
const (
streamChunkSize = 64 << 10
streamNonceSize = 16
)
// StreamLen returns the length of the STREAM of an age payload of n bytes:
// the plaintext and a 16-byte tag for each 64 KiB chunk, at least one.
func StreamLen(n int) int {
return n + chacha20poly1305.Overhead*max(1, (n+streamChunkSize-1)/streamChunkSize)
}
// StreamSeal encrypts plaintext as the payload of an age file whose file key
// and 16-byte nonce are given: ChaCha20-Poly1305 over 64 KiB chunks, with the
// key HKDF-SHA-256(file key, nonce, "payload") and a nonce of an 11-byte
// big-endian counter and a last-chunk flag.
func StreamSeal(fileKey, nonce, plaintext []byte) ([]byte, error) {
if len(nonce) != streamNonceSize {
return nil, fmt.Errorf("testkit: STREAM nonce of %d bytes", len(nonce))
}
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(hkdf.New(sha256.New, fileKey, nonce, []byte("payload")), key); err != nil {
return nil, err
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
out := make([]byte, 0, StreamLen(len(plaintext)))
var counter [chacha20poly1305.NonceSize]byte
for i := uint64(0); ; i++ {
chunk := plaintext[:min(len(plaintext), streamChunkSize)]
plaintext = plaintext[len(chunk):]
// The counter fills bytes 0 to 10; a payload never needs 2^64 chunks.
binary.BigEndian.PutUint64(counter[3:11], i)
if len(plaintext) == 0 {
counter[11] = 1
return aead.Seal(out, counter[:], chunk, nil), nil
}
out = aead.Seal(out, counter[:], chunk, nil)
}
}
// AgeParts splits an age file into its header, its nonce and its STREAM.
func AgeParts(file []byte) (header, nonce, stream []byte, err error) {
n, err := HeaderLen(file)
if err != nil {
return nil, nil, nil, err
}
if len(file) < n+streamNonceSize {
return nil, nil, nil, errors.New("testkit: age file without a nonce")
}
return file[:n], file[n : n+streamNonceSize], file[n+streamNonceSize:], nil
}
// ResealAge returns file with its payload replaced by plaintext, sealed with
// fileKey and the nonce of file. The header, and with it its MAC, is kept.
func ResealAge(file, fileKey, plaintext []byte) ([]byte, error) {
header, nonce, _, err := AgeParts(file)
if err != nil {
return nil, err
}
stream, err := StreamSeal(fileKey, nonce, plaintext)
if err != nil {
return nil, err
}
return Join(header, nonce, stream), nil
}
// SealAge returns an age file with the given stanzas, a header MAC computed
// with fileKey, the given nonce and plaintext as its payload.
func SealAge(stanzas []*age.Stanza, fileKey, nonce, plaintext []byte) ([]byte, error) {
header, err := MarshalHeader(stanzas, fileKey)
if err != nil {
return nil, err
}
stream, err := StreamSeal(fileKey, nonce, plaintext)
if err != nil {
return nil, err
}
return Join(header, nonce, stream), nil
}
// OpenAgeWithKey decrypts an age file whose file key is known, verifying its
// header MAC and its STREAM.
func OpenAgeWithKey(file, fileKey []byte) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(file), age.NewInjectedFileKeyIdentity(fileKey))
if err != nil {
return nil, err
}
return io.ReadAll(r)
}
// FixedX25519Stanza returns the X25519 stanza that wraps fileKey for
// recipient with the ephemeral scalar SHA-256(seed), as age builds it with a
// random one: the share X25519(e, base point), and the file key sealed with
// the key HKDF-SHA-256(X25519(e, recipient), share || recipient,
// "age-encryption.org/v1/X25519") and a zero nonce.
func FixedX25519Stanza(fileKey []byte, recipient *age.X25519Recipient, seed string) (*age.Stanza, error) {
their, err := agewrap.RawX25519Recipient(recipient)
if err != nil {
return nil, err
}
scalar := sha256.Sum256([]byte(seed))
e, err := ecdh.X25519().NewPrivateKey(scalar[:])
if err != nil {
return nil, err
}
pub, err := ecdh.X25519().NewPublicKey(their)
if err != nil {
return nil, err
}
shared, err := e.ECDH(pub)
if err != nil {
return nil, err
}
share := e.PublicKey().Bytes()
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(hkdf.New(sha256.New, shared, Join(share, their), []byte("age-encryption.org/v1/X25519")), key); err != nil {
return nil, err
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
body := aead.Seal(nil, make([]byte, chacha20poly1305.NonceSize), fileKey, nil)
return &age.Stanza{Type: agewrap.StanzaX25519, Args: []string{base64.RawStdEncoding.EncodeToString(share)}, Body: body}, nil
}
// withSealedLen returns a copy of prelude with SEALED_CONTROL_LEN set to n.
func withSealedLen(prelude []byte, n int) []byte {
p := bytes.Clone(prelude[:capsule.PreludeSize])
binary.BigEndian.PutUint32(p[12:16], uint32(n))
return p
}
// bind returns control, a CONTROL_CBOR map, with its header_binding (key 2)
// computed for prelude and header, and encoded; without key 2 it is only
// encoded.
func bind(control map[uint64]any, prelude, header []byte) ([]byte, error) {
if _, ok := control[2]; ok {
var p [capsule.PreludeSize]byte
copy(p[:], prelude)
b := capsule.HeaderBinding(p, header)
control[2] = b[:]
}
return cbortest.Marshal(control)
}
// AccessIdentity returns the identity of the .dkk of a time_and_key fixture.
func (f *LoadedFixture) AccessIdentity() (*age.X25519Identity, error) {
if f.DKK == nil {
return nil, errors.New("testkit: the fixture has no .dkk")
}
k, err := accesskey.Decode(bytes.NewReader(f.DKK))
if err != nil {
return nil, err
}
defer k.Wipe()
return agewrap.X25519IdentityFromRaw(k.Material)
}
// withIdentity makes in offer the identity of the .dkk of f instead of the
// .dkk itself, whose capsule_digest a mutation of the .dkc breaks.
func (f *LoadedFixture) withIdentity(in *MutationInput) (*MutationInput, error) {
id, err := f.AccessIdentity()
if err != nil {
return nil, err
}
in.DKK, in.Identities = nil, []string{id.String()}
return in, nil
}
// WithControl returns f, a time_only fixture, with its CONTROL_CBOR decoded
// as a map, changed by edit and sealed again with FK_TIME and the nonce of
// OUTER_TIME_AGE, as anyone can once the round is published (spec §36.1).
// When the length of SEALED_CONTROL changes, the PRELUDE says so, and the
// header_binding of the control, if edit keeps one, covers the new PRELUDE.
func (f *LoadedFixture) WithControl(edit func(control map[uint64]any)) (*MutationInput, error) {
if f.AccessPolicy != capsule.TimeOnly.String() {
return nil, errors.New("testkit: WithControl needs a time_only fixture")
}
fk, err := f.TimeFileKey()
if err != nil {
return nil, err
}
control, err := OpenAgeWithKey(f.Parts.Sealed, fk)
if err != nil {
return nil, err
}
m, err := cbortest.UnmarshalMap(control)
if err != nil {
return nil, err
}
edit(m)
draft, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
header, _, _, err := AgeParts(f.Parts.Sealed)
if err != nil {
return nil, err
}
prelude := withSealedLen(f.Parts.Prelude, len(header)+streamNonceSize+StreamLen(len(draft)))
control, err = bind(m, prelude, f.Parts.Header)
if err != nil {
return nil, err
}
sealed, err := ResealAge(f.Parts.Sealed, fk, control)
if err != nil {
return nil, err
}
return f.input(Join(prelude, f.Parts.Header, sealed, f.Parts.Payload)), nil
}
// WithPayloadPlaintext returns f with the plaintext of its PAYLOAD_AGE
// replaced by plaintext, sealed with FK_PAYLOAD and the nonce of PAYLOAD_AGE,
// as whoever knows I_PAYLOAD can make it.
func (f *LoadedFixture) WithPayloadPlaintext(plaintext []byte) (*MutationInput, error) {
payload, err := f.resealPayload(plaintext)
if err != nil {
return nil, err
}
return f.input(Join(f.Parts.Prelude, f.Parts.Header, f.Parts.Sealed, payload)), nil
}
// WithInnerStanzas returns f, a time_and_key fixture with a .dkk, with the
// stanzas of INNER_ACCESS_AGE replaced by edit(FK_ACCESS, stanzas), as its
// creator could make them: the header MAC is recomputed with FK_ACCESS, the
// PRELUDE follows the new length of SEALED_CONTROL, the control is bound to
// the new PRELUDE, and INNER_ACCESS_AGE and OUTER_TIME_AGE are sealed again
// with FK_ACCESS, FK_TIME and their nonces. The input offers the identity of
// the .dkk, whose capsule_digest no longer matches.
func (f *LoadedFixture) WithInnerStanzas(edit func(fileKey []byte, stanzas []*age.Stanza) ([]*age.Stanza, error)) (*MutationInput, error) {
fkTime, err := f.TimeFileKey()
if err != nil {
return nil, err
}
inner, err := OpenAgeWithKey(f.Parts.Sealed, fkTime)
if err != nil {
return nil, err
}
id, err := f.AccessIdentity()
if err != nil {
return nil, err
}
stanzas, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return nil, err
}
var fkAccess []byte
for _, s := range stanzas {
if fk, err := id.Unwrap([]*age.Stanza{s}); err == nil {
fkAccess = fk
}
}
if fkAccess == nil {
return nil, errors.New("testkit: the .dkk opens no stanza of INNER_ACCESS_AGE")
}
control, err := OpenAgeWithKey(inner, fkAccess)
if err != nil {
return nil, err
}
if stanzas, err = edit(fkAccess, stanzas); err != nil {
return nil, err
}
innerHeader, err := MarshalHeader(stanzas, fkAccess)
if err != nil {
return nil, err
}
_, innerNonce, _, err := AgeParts(inner)
if err != nil {
return nil, err
}
outerHeader, _, _, err := AgeParts(f.Parts.Sealed)
if err != nil {
return nil, err
}
innerLen := len(innerHeader) + streamNonceSize + StreamLen(len(control))
prelude := withSealedLen(f.Parts.Prelude, len(outerHeader)+streamNonceSize+StreamLen(innerLen))
m, err := cbortest.UnmarshalMap(control)
if err != nil {
return nil, err
}
if control, err = bind(m, prelude, f.Parts.Header); err != nil {
return nil, err
}
stream, err := StreamSeal(fkAccess, innerNonce, control)
if err != nil {
return nil, err
}
sealed, err := ResealAge(f.Parts.Sealed, fkTime, Join(innerHeader, innerNonce, stream))
if err != nil {
return nil, err
}
return f.withIdentity(f.input(Join(prelude, f.Parts.Header, sealed, f.Parts.Payload)))
}

Powered by TurnKey Linux.