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.
244 lines
8.4 KiB
244 lines
8.4 KiB
|
2 weeks ago
|
// Package accesskey implements the DateKeys Access Key, the portable .dkk
|
||
|
|
// credential (spec §38, §40-§44).
|
||
|
|
//
|
||
|
|
// A .dkk is a sensitive capability (spec §7.4). Its X25519 identity is stored
|
||
|
|
// as 32 raw bytes; the Bech32 AGE-SECRET-KEY-1... form is only an export
|
||
|
|
// format for humans (spec §38). No type in this package prints the material.
|
||
|
|
package accesskey
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/binary"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
|
||
|
|
"filippo.io/age"
|
||
|
|
|
||
|
|
datekeys "github.com/datekeys/datekeys-go"
|
||
|
|
"github.com/datekeys/datekeys-go/agewrap"
|
||
|
|
"github.com/datekeys/datekeys-go/codec"
|
||
|
|
"github.com/datekeys/datekeys-go/extension"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Framing and schema constants (spec §40, §41).
|
||
|
|
const (
|
||
|
|
Magic = "DKK1"
|
||
|
|
FramingVersion = 1
|
||
|
|
PreludeSize = 12
|
||
|
|
// MaxBodyLen is the parser limit of spec §57, checked before allocating.
|
||
|
|
MaxBodyLen = 16 << 20
|
||
|
|
TypeTag = "datekeys-access-key"
|
||
|
|
SchemaVersion = 1
|
||
|
|
// TypeX25519 is the only access_type of V1 (spec §41).
|
||
|
|
TypeX25519 = "x25519"
|
||
|
|
|
||
|
|
idSize = 16
|
||
|
|
digestSize = 32
|
||
|
|
x25519Size = 32
|
||
|
|
)
|
||
|
|
|
||
|
|
// AccessKey is a decoded .dkk.
|
||
|
|
type AccessKey struct {
|
||
|
|
CredentialID [16]byte // key 2, random and opaque (spec §42)
|
||
|
|
CapsuleID [16]byte // key 3, the only capsule this credential is for (spec §38)
|
||
|
|
Type string // key 4, access_type
|
||
|
|
Material []byte // key 5, access_material: 32 raw X25519 identity bytes. SECRET.
|
||
|
|
// Verification is key 6, optional; nil when absent (spec §43, §58.1).
|
||
|
|
Verification *Verification
|
||
|
|
Critical []extension.Extension // key 7
|
||
|
|
Noncritical []extension.Extension // key 8
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verification is verification_metadata (spec §43). It supports fast failure
|
||
|
|
// and UX only; it is not a security property.
|
||
|
|
type Verification struct {
|
||
|
|
CapsuleDigest []byte // key 0, SHA-256 of the exact .dkc bytes
|
||
|
|
}
|
||
|
|
|
||
|
|
type bodyWire struct {
|
||
|
|
Type string `cbor:"0,keyasint"`
|
||
|
|
Version uint64 `cbor:"1,keyasint"`
|
||
|
|
CredentialID []byte `cbor:"2,keyasint"`
|
||
|
|
CapsuleID []byte `cbor:"3,keyasint"`
|
||
|
|
AccessType string `cbor:"4,keyasint"`
|
||
|
|
Material []byte `cbor:"5,keyasint"`
|
||
|
|
Verification *verificationWire `cbor:"6,keyasint,omitempty"`
|
||
|
|
Critical []extension.Wire `cbor:"7,keyasint,omitempty"`
|
||
|
|
Noncritical []extension.Wire `cbor:"8,keyasint,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type verificationWire struct {
|
||
|
|
CapsuleDigest []byte `cbor:"0,keyasint,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// String describes k without its material.
|
||
|
|
func (k AccessKey) String() string {
|
||
|
|
return fmt.Sprintf("AccessKey{credential_id=%x capsule_id=%x type=%s material=REDACTED}", k.CredentialID, k.CapsuleID, k.Type)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GoString describes k without its material.
|
||
|
|
func (k AccessKey) GoString() string { return k.String() }
|
||
|
|
|
||
|
|
// Identity returns the age identity of an x25519 access key.
|
||
|
|
func (k *AccessKey) Identity() (age.Identity, error) {
|
||
|
|
if err := k.validateMaterial(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
id, err := agewrap.X25519IdentityFromRaw(k.Material)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: %v: %w", err, datekeys.ErrAccessInvalid)
|
||
|
|
}
|
||
|
|
return id, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wipe overwrites the material in place. It is best effort: Go may have made
|
||
|
|
// copies that cannot be reached.
|
||
|
|
func (k *AccessKey) Wipe() { clear(k.Material) }
|
||
|
|
|
||
|
|
func (k *AccessKey) validateMaterial() error {
|
||
|
|
if k.Type != TypeX25519 {
|
||
|
|
return fmt.Errorf("accesskey: access_type %q is not supported by V1: %w", k.Type, datekeys.ErrAccessInvalid)
|
||
|
|
}
|
||
|
|
if len(k.Material) != x25519Size {
|
||
|
|
return fmt.Errorf("accesskey: x25519 access_material is %d bytes, want %d: %w", len(k.Material), x25519Size, datekeys.ErrAccessInvalid)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MarshalBody returns BODY_CBOR, the Deterministic CBOR body of k (spec §41).
|
||
|
|
func (k *AccessKey) MarshalBody() ([]byte, error) {
|
||
|
|
if err := k.validateMaterial(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
w := bodyWire{
|
||
|
|
Type: TypeTag,
|
||
|
|
Version: SchemaVersion,
|
||
|
|
CredentialID: k.CredentialID[:],
|
||
|
|
CapsuleID: k.CapsuleID[:],
|
||
|
|
AccessType: k.Type,
|
||
|
|
Material: k.Material,
|
||
|
|
}
|
||
|
|
if k.Verification != nil {
|
||
|
|
if len(k.Verification.CapsuleDigest) != digestSize {
|
||
|
|
// An empty map is not a canonical representation of absence (spec §43).
|
||
|
|
return nil, fmt.Errorf("accesskey: capsule_digest must be %d bytes: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
|
||
|
|
}
|
||
|
|
w.Verification = &verificationWire{CapsuleDigest: k.Verification.CapsuleDigest}
|
||
|
|
}
|
||
|
|
var err error
|
||
|
|
if w.Critical, err = extension.Encode(k.Critical); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if w.Noncritical, err = extension.Encode(k.Noncritical); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
b, err := codec.Marshal(w)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if len(b) > MaxBodyLen {
|
||
|
|
return nil, fmt.Errorf("accesskey: body of %d bytes exceeds %d", len(b), MaxBodyLen)
|
||
|
|
}
|
||
|
|
return b, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Encode writes k as a complete .dkk: prelude and BODY_CBOR (spec §40).
|
||
|
|
func Encode(w io.Writer, k *AccessKey) error {
|
||
|
|
body, err := k.MarshalBody()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var pre [PreludeSize]byte
|
||
|
|
copy(pre[0:4], Magic)
|
||
|
|
pre[4] = FramingVersion
|
||
|
|
binary.BigEndian.PutUint32(pre[8:12], uint32(len(body)))
|
||
|
|
if _, err := w.Write(pre[:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err = w.Write(body)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode reads exactly one .dkk from r and validates its framing, its
|
||
|
|
// canonical body and its fields. Bytes after BODY_CBOR are rejected.
|
||
|
|
//
|
||
|
|
// Decode does not decide whether critical extensions are known; the consumer
|
||
|
|
// checks them against its extension.Registry (capsule.Open does).
|
||
|
|
func Decode(r io.Reader) (*AccessKey, error) {
|
||
|
|
var pre [PreludeSize]byte
|
||
|
|
n, err := io.ReadFull(r, pre[:])
|
||
|
|
if n < 4 || string(pre[0:4]) != Magic {
|
||
|
|
return nil, fmt.Errorf("accesskey: %w", datekeys.ErrInvalidMagic)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: truncated prelude: %w", datekeys.ErrIntegrity)
|
||
|
|
}
|
||
|
|
if pre[4] != FramingVersion {
|
||
|
|
return nil, fmt.Errorf("accesskey: framing version %d: %w", pre[4], datekeys.ErrUnsupportedVersion)
|
||
|
|
}
|
||
|
|
if pre[5] != 0 || pre[6] != 0 || pre[7] != 0 {
|
||
|
|
return nil, fmt.Errorf("accesskey: flags %#x, reserved %#x%02x: %w", pre[5], pre[6], pre[7], datekeys.ErrInvalidFlags)
|
||
|
|
}
|
||
|
|
bodyLen := binary.BigEndian.Uint32(pre[8:12])
|
||
|
|
if bodyLen > MaxBodyLen {
|
||
|
|
return nil, fmt.Errorf("accesskey: BODY_LEN %d exceeds the %d-byte limit: %w", bodyLen, MaxBodyLen, datekeys.ErrIntegrity)
|
||
|
|
}
|
||
|
|
// The buffer grows with the data actually read, so a short file that
|
||
|
|
// declares a large BODY_LEN does not force an allocation of that size.
|
||
|
|
var buf bytes.Buffer
|
||
|
|
if _, err := io.CopyN(&buf, r, int64(bodyLen)); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: truncated body: %w", datekeys.ErrIntegrity)
|
||
|
|
}
|
||
|
|
body := buf.Bytes()
|
||
|
|
var extra [1]byte
|
||
|
|
switch n, err := io.ReadFull(r, extra[:]); {
|
||
|
|
case n != 0:
|
||
|
|
return nil, fmt.Errorf("accesskey: data after BODY_CBOR: %w", datekeys.ErrIntegrity)
|
||
|
|
case !errors.Is(err, io.EOF):
|
||
|
|
return nil, fmt.Errorf("accesskey: reading after BODY_CBOR: %w", err)
|
||
|
|
}
|
||
|
|
return DecodeBody(body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// DecodeBody validates and decodes BODY_CBOR.
|
||
|
|
func DecodeBody(body []byte) (*AccessKey, error) {
|
||
|
|
if err := codec.CheckSchema(body, TypeTag, SchemaVersion); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: %w", err)
|
||
|
|
}
|
||
|
|
var w bodyWire
|
||
|
|
if err := codec.Unmarshal(body, &w); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: %w", err)
|
||
|
|
}
|
||
|
|
if len(w.CredentialID) != idSize || len(w.CapsuleID) != idSize {
|
||
|
|
return nil, fmt.Errorf("accesskey: credential_id and capsule_id must be %d bytes: %w", idSize, datekeys.ErrNonCanonicalCBOR)
|
||
|
|
}
|
||
|
|
k := &AccessKey{Type: w.AccessType, Material: bytes.Clone(w.Material)}
|
||
|
|
copy(k.CredentialID[:], w.CredentialID)
|
||
|
|
copy(k.CapsuleID[:], w.CapsuleID)
|
||
|
|
if w.Verification != nil {
|
||
|
|
if len(w.Verification.CapsuleDigest) != digestSize {
|
||
|
|
return nil, fmt.Errorf("accesskey: verification_metadata must hold a %d-byte capsule_digest: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
|
||
|
|
}
|
||
|
|
k.Verification = &Verification{CapsuleDigest: bytes.Clone(w.Verification.CapsuleDigest)}
|
||
|
|
}
|
||
|
|
var err error
|
||
|
|
if k.Critical, err = extension.Decode(w.Critical); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: critical_extensions: %w", err)
|
||
|
|
}
|
||
|
|
if k.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: noncritical_extensions: %w", err)
|
||
|
|
}
|
||
|
|
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
|
||
|
|
return nil, fmt.Errorf("accesskey: %w", err)
|
||
|
|
}
|
||
|
|
if err := k.validateMaterial(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
clear(w.Material)
|
||
|
|
return k, nil
|
||
|
|
}
|