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/capsule/open.go

274 lines
10 KiB

package capsule
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"io"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/agewrap"
"github.com/datekeys/datekeys-go/extension"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider"
)
// OpenOptions configures Open.
type OpenOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the critical extensions the application implements.
Extensions extension.Registry
// Source fetches the release. Required. Its answer is always verified
// locally.
Source provider.ReleaseSource
// Identities are the caller's own X25519 identities, for time_and_key
// capsules encrypted to known recipients.
Identities []age.Identity
// AccessKey is a portable .dkk, for time_and_key capsules.
AccessKey *accesskey.AccessKey
// Now is the clock. Required: no package of this module reads the wall
// clock on its own. Open does not ask Source for a round whose time has
// not been reached.
Now func() time.Time
}
// Opened describes a capsule that Open decrypted completely.
type Opened struct {
Inspection *Inspection
Release provider.Release
// ControlCritical and ControlNoncritical are the extensions of the sealed
// CONTROL_CBOR, only visible after opening.
ControlCritical []extension.Extension
ControlNoncritical []extension.Extension
}
// Open runs the complete decryption flow of spec §63 and streams the
// plaintext to dst.
//
// Everything verifiable locally is checked before a release is requested or a
// secret is used (steps 1 to 8, the presence and capsule binding of access
// credentials, and the .dkk capsule_digest when r is seekable). The stanza
// rules are enforced again, as a MUST, by the identities that open each age
// file (steps 11, 13 and 17).
//
// Open returns nil only after age has authenticated the whole payload
// (step 18). On error, dst may hold a partial plaintext that MUST be
// discarded: write to a temporary file and publish it only on success
// (spec §56), as the datekeys CLI does.
func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*Opened, error) {
if opts.Source == nil {
return nil, errors.New("capsule: OpenOptions.Source is required")
}
if opts.Now == nil {
return nil, errors.New("capsule: OpenOptions.Now is required")
}
start, seekable := int64(0), false
if s, ok := r.(io.Seeker); ok {
if pos, err := s.Seek(0, io.SeekCurrent); err == nil {
start, seekable = pos, true
}
}
// Steps 1 to 8.
in, st, err := inspect(r, InspectOptions{Registry: opts.Registry, Extensions: opts.Extensions})
out := &Opened{Inspection: in}
if err != nil {
return out, err
}
h, p := in.Header, in.Profile
// Access credentials are checked before any network request.
var ids []age.Identity
if h.Policy == TimeAndKey {
ids = append(ids, opts.Identities...)
if k := opts.AccessKey; k != nil {
if err := checkAccessKey(k, h, opts.Extensions); err != nil {
return out, in.fail(9, "access credential", err)
}
if k.Verification != nil && seekable {
payload, err := checkCapsuleDigest(r.(io.ReadSeeker), start, in.PayloadOffset, k.Verification.CapsuleDigest)
if err != nil {
return out, in.fail(9, "access credential", err)
}
st.payload = payload
}
id, err := k.Identity()
if err != nil {
return out, in.fail(9, "access credential", err)
}
ids = append(ids, id)
}
if len(ids) == 0 {
return out, in.fail(9, "access credential", fmt.Errorf("capsule: time_and_key capsule and no identity or .dkk supplied: %w", datekeys.ErrAccessRequired))
}
in.pass(9, "access credential", fmt.Sprintf("%d identities to try", len(ids)))
}
// Step 9: obtain the release, never before its round time.
cond := provider.Condition{Round: h.DateKey.Round}
if now := opts.Now(); now.Before(in.UnlockAt) {
err := fmt.Errorf("capsule: round %d is published at %s, it is %s: %w", cond.Round,
in.UnlockAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
return out, in.fail(9, "release", err)
}
release, err := opts.Source.Fetch(ctx, p, cond)
if err != nil {
if datekeys.Code(err) == "" {
err = fmt.Errorf("capsule: %v: %w", err, datekeys.ErrReleaseUnavailable)
}
return out, in.fail(9, "release", err)
}
in.pass(9, "release", fmt.Sprintf("round %d obtained", release.Round))
// Step 10: verify the release locally.
if err := provider.Verify(p, cond, release); err != nil {
return out, in.fail(10, "release verification", err)
}
out.Release = release
in.pass(10, "release verification", "BLS signature valid under the pinned key")
// Step 11: open OUTER_TIME_AGE with the strict tlock identity.
timeID, err := agewrap.NewTimeIdentity(p, cond.Round, release)
if err != nil {
return out, in.fail(11, "open sealed control", err)
}
inner, err := decryptAll(st.sealed, timeID)
if err != nil {
return out, in.fail(11, "open sealed control", err)
}
defer clear(inner)
in.pass(11, "open sealed control", "one tlock stanza, header MAC valid")
// Step 12: the structure must match access_policy (spec §36).
var controlBytes []byte
switch h.Policy {
case TimeOnly:
if looksLikeAge(inner) {
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_only capsule seals an age file: %w", datekeys.ErrPolicyStructureMismatch))
}
controlBytes = inner
in.pass(12, "policy structure", "time_only: CONTROL_CBOR sealed directly")
case TimeAndKey:
stanzas, err := agewrap.Stanzas(bytes.NewReader(inner))
if err != nil {
return out, in.fail(12, "policy structure", fmt.Errorf("capsule: time_and_key capsule does not seal an age file: %w", datekeys.ErrPolicyStructureMismatch))
}
if err := agewrap.CheckAccessStanzas(stanzas); err != nil {
return out, in.fail(12, "policy structure", err)
}
in.pass(12, "policy structure", fmt.Sprintf("time_and_key: INNER_ACCESS_AGE with %d X25519 stanzas", len(stanzas)))
// Step 13: open INNER_ACCESS_AGE with the caller's identities.
accessID, err := agewrap.NewAccessIdentity(ids...)
if err != nil {
return out, in.fail(13, "open access layer", err)
}
if controlBytes, err = decryptAll(inner, accessID); err != nil {
return out, in.fail(13, "open access layer", err)
}
defer clear(controlBytes)
in.pass(13, "open access layer", "identity matched exactly one stanza")
}
// Step 14: parse the canonical CONTROL_CBOR.
control, err := DecodeControl(controlBytes)
if err != nil {
return out, in.fail(14, "control", err)
}
defer clear(control.PayloadIdentity[:])
if err := extension.CheckCritical(control.Critical, opts.Extensions); err != nil {
return out, in.fail(14, "control", fmt.Errorf("capsule: CONTROL_CBOR: %w", err))
}
out.ControlCritical, out.ControlNoncritical = control.Critical, control.Noncritical
in.pass(14, "control", "canonical CONTROL_CBOR")
// Step 15: verify header_binding over the exact stored bytes.
binding := HeaderBinding(st.prelude, in.PublicHeader)
if !hmac.Equal(binding[:], control.HeaderBinding[:]) {
return out, in.fail(15, "header binding", fmt.Errorf("capsule: header_binding does not match PRELUDE || PUBLIC_HEADER: %w", datekeys.ErrHeaderBinding))
}
in.pass(15, "header binding", "matches")
// Steps 16 and 17: recover I_PAYLOAD and open PAYLOAD_AGE with it.
payloadID, err := agewrap.NewPayloadIdentity(control.PayloadIdentity[:])
if err != nil {
return out, in.fail(16, "payload identity", err)
}
in.pass(16, "payload identity", "I_PAYLOAD recovered")
pr, err := age.Decrypt(st.payload, payloadID)
if err != nil {
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
}
if _, err := io.Copy(dst, pr); err != nil {
return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", err))
}
// Step 18: age completed without error.
in.pass(18, "commit", "payload authenticated completely")
return out, nil
}
// checkAccessKey validates a .dkk against the capsule before it is used.
func checkAccessKey(k *accesskey.AccessKey, h *Header, reg extension.Registry) error {
if k.CapsuleID != h.CapsuleID {
return fmt.Errorf("capsule: the .dkk is for capsule %x, this is %x: %w", k.CapsuleID, h.CapsuleID, datekeys.ErrAccessInvalid)
}
if err := extension.CheckCritical(k.Critical, reg); err != nil {
return fmt.Errorf("capsule: .dkk: %w", err)
}
return nil
}
// checkCapsuleDigest compares the .dkk capsule_digest with SHA-256 of the
// .dkc (spec §43), then repositions r at the payload. The digest is a fast
// failure for a wrong file, not a security property.
func checkCapsuleDigest(r io.ReadSeeker, start, payloadOffset int64, want []byte) (io.Reader, error) {
if _, err := r.Seek(start, io.SeekStart); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
sum := sha256.New()
if _, err := io.Copy(sum, r); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
if !hmac.Equal(sum.Sum(nil), want) {
return nil, fmt.Errorf("capsule: the .dkk capsule_digest does not match this .dkc: %w", datekeys.ErrAccessInvalid)
}
if _, err := r.Seek(start+payloadOffset, io.SeekStart); err != nil {
return nil, fmt.Errorf("capsule: %w", err)
}
return r, nil
}
// decryptAll opens a bounded, in-memory age file. The plaintext is never
// longer than the ciphertext.
func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(ciphertext), id)
if err != nil {
return nil, classify("age", err)
}
out, err := io.ReadAll(io.LimitReader(r, int64(len(ciphertext))))
if err != nil {
clear(out)
return nil, classify("age", err)
}
return out, nil
}
// classify keeps the normative error an identity returned from Unwrap, and
// maps every other age failure (malformed header, bad header MAC, STREAM
// authentication, truncation, trailing data) to ErrIntegrity.
func classify(what string, err error) error {
if datekeys.Code(err) != "" {
return fmt.Errorf("capsule: %s: %w", what, err)
}
return fmt.Errorf("capsule: %s: %v: %w", what, err, datekeys.ErrIntegrity)
}

Powered by TurnKey Linux.