// 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 }