mirror of
https://github.com/zap-proto/go.git
synced 2026-10-30 15:05:00 +00:00
Red-team review found a real fail-open: Go's Verify ACCEPTED a cap with an unknown caveat kind (e.g. 0x42424242) while the Rust verifier REJECTED the identical bytes. A caveat is a RESTRICTION; accepting an unrecognized one silently ignores a constraint the issuer intended (privilege escalation). SPEC §2.3 line 106 mandates fail-closed. Verify (and via it VerifyChain) now refuses kinds > CaveatNonceHash with ErrUnknownCaveat. +2 regression tests.
309 lines
11 KiB
Go
309 lines
11 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package cap implements the ZAP capability runtime.
|
|
//
|
|
// A Capability is a signed, attenuable token of authority. It grants a
|
|
// holder permission to perform a bitmask of operations on a target, with
|
|
// optional caveats. Caps form a chain: a parent's holder can issue an
|
|
// attenuated child cap whose permissions are a subset of the parent's.
|
|
// VerifyChain walks the chain back to a root, checking each signature,
|
|
// the intersection of permissions, expiry, revocation, and caveats.
|
|
//
|
|
// Wire format is canonical ZAP — magic+version header, object data,
|
|
// length-prefixed list of Caveat sub-messages. The bytes are produced
|
|
// and read via the generated zero-copy views in capabilities_zap.go.
|
|
// This file is the thin idiomatic-Go wrapper that the IAM/KMS/MPC
|
|
// consumers see: Wrap / Cap.Field / Verifier.
|
|
//
|
|
// Signature scope: per SPEC.md §3, the signed bytes are the canonical
|
|
// concatenation Capability[0..164) || canonical(Caveats) — the fixed
|
|
// header up to and including the Caveats list pointer, followed by each
|
|
// Caveat encoded as Kind:u32-LE || len(Value):u32-LE || Value in list
|
|
// order. This EXCLUDES the Sig field itself and the ZAP heap-area
|
|
// indirection bytes, and is recomputed identically by signer and
|
|
// verifier (see canonical.go, Cap.CanonicalBytes), so heap layout cannot
|
|
// be tampered with without breaking the signature and the signed bytes
|
|
// are identical across every language runtime.
|
|
package cap
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
|
|
zap "github.com/zap-proto/go"
|
|
)
|
|
|
|
// SigSize is the fixed signature footer width in bytes. Sized at v1.1 to
|
|
// hold any of:
|
|
//
|
|
// - secp256k1 ECDSA (65 bytes)
|
|
// - Ed25519 (64 bytes)
|
|
// - ML-DSA-65 (3309 bytes, FIPS 204 §5.2 Level-3)
|
|
// - hybrid Ed25519+ML-DSA-65 (3373 bytes + 16-byte separator)
|
|
//
|
|
// 3408 is the smallest 16-byte-aligned size that fits FIPS 204 ML-DSA-65
|
|
// (3309 B) with a 99-byte headroom. Schemes shorter than SigSize are
|
|
// zero-padded on the right; verifiers identify the scheme via the
|
|
// algorithm tag in the final byte (Sig[SigSize-1]) and decode the
|
|
// leading L_scheme bytes accordingly. See zap-spec/capabilities.zap and
|
|
// capabilities_kinds.md for the canonical tag table.
|
|
const SigSize = 3408
|
|
|
|
// AlgTagOffset is the offset of the algorithm-tag byte within the SigSize
|
|
// signature footer. The byte at [SigSize-1] identifies which signature
|
|
// primitive a verifier MUST use; it is part of the signed payload, so a
|
|
// tag flip changes the signature and is caught by verifier mismatch.
|
|
const AlgTagOffset = SigSize - 1
|
|
|
|
// Scheme is the wire-level signature algorithm tag. The numeric values
|
|
// MUST match capabilities_kinds.md "Wire schemes" table; the byte written
|
|
// at Sig[AlgTagOffset] is one of these constants.
|
|
//
|
|
// Verifiers fail-closed on SchemeReserved and on values not enumerated
|
|
// here. A consumer's auth layer may re-export a typed alias whose
|
|
// constants reuse these numeric values one-for-one so the wire byte and
|
|
// the typed enum agree.
|
|
type Scheme uint8
|
|
|
|
const (
|
|
// SchemeReserved (0x00) MUST NOT appear in valid caps. Verifiers
|
|
// reject it so a zero-filled / never-initialised footer is caught.
|
|
SchemeReserved Scheme = 0x00
|
|
// SchemeSecp256k1 is 65-byte secp256k1 ECDSA (R||S||v).
|
|
SchemeSecp256k1 Scheme = 0x01
|
|
// SchemeEd25519 is 64-byte Ed25519 (RFC 8032).
|
|
SchemeEd25519 Scheme = 0x02
|
|
// SchemeMLDSA65 is the 3309-byte FIPS 204 Level-3 ML-DSA-65 signature.
|
|
SchemeMLDSA65 Scheme = 0x03
|
|
// SchemeHybrid is concatenated Ed25519 || ML-DSA-65 (64 + 3309 = 3373 B).
|
|
SchemeHybrid Scheme = 0x04
|
|
)
|
|
|
|
// known reports whether s is one of the registered signature schemes a
|
|
// verifier may accept. Per SPEC §2.3 step 3c the valid set is exactly
|
|
// {0x01,0x02,0x03,0x04}; SchemeReserved (0x00) and any unassigned tag
|
|
// are NOT known, so verifiers fail-closed on them.
|
|
func (s Scheme) known() bool {
|
|
switch s {
|
|
case SchemeSecp256k1, SchemeEd25519, SchemeMLDSA65, SchemeHybrid:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CapKind enumerates the kinds of authority a capability can confer.
|
|
type CapKind uint32
|
|
|
|
const (
|
|
KindReserved CapKind = 0x00
|
|
KindIAMSession CapKind = 0x01
|
|
KindIAMAPIKey CapKind = 0x02
|
|
KindKMSAccess CapKind = 0x10
|
|
KindKMSSign CapKind = 0x11
|
|
KindMPCSign CapKind = 0x20
|
|
KindATSOrder CapKind = 0x30
|
|
KindBridgeXfer CapKind = 0x40
|
|
KindStake CapKind = 0x50
|
|
KindDelegate CapKind = 0xFF
|
|
)
|
|
|
|
// CaveatKind enumerates the kinds of caveat that can be attached.
|
|
type CaveatKind uint32
|
|
|
|
const (
|
|
CaveatExpiresAt CaveatKind = 0x00
|
|
CaveatMaxAmount CaveatKind = 0x01
|
|
CaveatDestChain CaveatKind = 0x02
|
|
CaveatRateLimit CaveatKind = 0x03
|
|
CaveatIPCIDR CaveatKind = 0x04
|
|
CaveatAssetID CaveatKind = 0x05
|
|
CaveatOpAllow CaveatKind = 0x06
|
|
CaveatMaxDepth CaveatKind = 0x07
|
|
CaveatAudience CaveatKind = 0x08
|
|
CaveatNonceHash CaveatKind = 0x09
|
|
)
|
|
|
|
// Caveat is one constraint attached to a capability. Value bytes alias
|
|
// the underlying ZAP buffer when produced via Cap.CaveatAt / Cap.Caveats;
|
|
// callers must not mutate Value in-place. Caveat literals passed into
|
|
// Issue/Attenuate are copied during build.
|
|
type Caveat struct {
|
|
Kind CaveatKind
|
|
Value []byte
|
|
}
|
|
|
|
// Errors returned by Wrap and chain validation.
|
|
var (
|
|
ErrTooShort = errors.New("cap: buffer too short")
|
|
ErrBadMagic = errors.New("cap: bad magic")
|
|
ErrBadCaveats = errors.New("cap: caveat block malformed")
|
|
ErrUnknownCaveat = errors.New("cap: unknown caveat kind (fail-closed per SPEC §2.3)")
|
|
ErrSigMismatch = errors.New("cap: signature does not verify")
|
|
ErrExpired = errors.New("cap: expired")
|
|
ErrRevoked = errors.New("cap: revoked")
|
|
ErrChainBroken = errors.New("cap: chain link broken")
|
|
ErrPermsExceedPar = errors.New("cap: permissions exceed parent")
|
|
ErrNotDelegable = errors.New("cap: parent does not permit attenuation")
|
|
ErrOpNotPermitted = errors.New("cap: op not in permission mask")
|
|
ErrTargetMismatch = errors.New("cap: target does not match")
|
|
ErrHolderMismatch = errors.New("cap: holder does not match")
|
|
ErrIssuerUnknown = errors.New("cap: issuer key unknown")
|
|
ErrCaveatViolation = errors.New("cap: caveat violated")
|
|
// ErrUnhandledScheme means the algorithm-tag byte is one the verifier
|
|
// does not implement (or SchemeReserved / an unknown tag). It is both
|
|
// the value a SchemeVerify hook returns to decline a tag (so the
|
|
// dispatcher may try its built-in ed25519 bootstrap path for
|
|
// SchemeEd25519) AND the terminal error the dispatcher returns when no
|
|
// path handles the tag — fail-closed per SPEC §2.3 step 3c.
|
|
ErrUnhandledScheme = errors.New("cap: signature scheme not handled")
|
|
)
|
|
|
|
// Cap is a zero-copy view over a capability buffer. Constructed by Wrap.
|
|
// All accessors read directly from raw without allocating; Value slices
|
|
// returned from CaveatAt / Caveats alias the underlying buffer.
|
|
type Cap struct {
|
|
raw []byte
|
|
view CapabilityView
|
|
}
|
|
|
|
// Wrap parses a capability buffer and returns a typed zero-copy view.
|
|
// Validates ZAP framing (magic, version, size) plus capability-specific
|
|
// structural checks (sig field within bounds). Cryptographic verification
|
|
// lives in Verifier.
|
|
func Wrap(b []byte) (Cap, error) {
|
|
if len(b) < zap.HeaderSize {
|
|
return Cap{}, ErrTooShort
|
|
}
|
|
view, err := WrapCapabilityView(b)
|
|
if err != nil {
|
|
// Distinguish "bad magic" from generic short-buffer to keep the
|
|
// historic error surface stable.
|
|
if errors.Is(err, zap.ErrInvalidMagic) {
|
|
return Cap{}, ErrBadMagic
|
|
}
|
|
if errors.Is(err, zap.ErrBufferTooSmall) {
|
|
return Cap{}, ErrTooShort
|
|
}
|
|
return Cap{}, err
|
|
}
|
|
// Sanity-check that the sig field is within bounds: the sig must
|
|
// occupy SigSize bytes inside the buffer.
|
|
if sigAbsOff(b)+SigSize > len(b) {
|
|
return Cap{}, ErrTooShort
|
|
}
|
|
c := Cap{raw: b, view: view}
|
|
// Walk caveats once to catch bad framing up front; expensive paths
|
|
// (Verify) re-walk via the view, so this is a cheap eager check.
|
|
list := view.Caveats()
|
|
for i := 0; i < list.Length(); i++ {
|
|
if list.BytesAt(i) == nil && list.Length() > 0 {
|
|
return Cap{}, ErrBadCaveats
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// sigAbsOff returns the absolute byte offset of the Sig field in raw.
|
|
// The Sig sits at capabilityViewSigOff bytes after the root-object's
|
|
// absolute start. The root-object offset is stored in the ZAP header at
|
|
// bytes [8:12].
|
|
func sigAbsOff(raw []byte) int {
|
|
return capRootOff(raw) + capabilityViewSigOff
|
|
}
|
|
|
|
// Bytes returns the underlying buffer without copying.
|
|
func (c Cap) Bytes() []byte { return c.raw }
|
|
|
|
// Kind reads the kind field.
|
|
func (c Cap) Kind() uint32 { return c.view.Kind() }
|
|
|
|
// Target reads the 32-byte target hash.
|
|
func (c Cap) Target() [32]byte { return c.view.Target() }
|
|
|
|
// Holder reads the 32-byte holder hash.
|
|
func (c Cap) Holder() [32]byte { return c.view.Holder() }
|
|
|
|
// Issuer reads the 32-byte issuer hash.
|
|
func (c Cap) Issuer() [32]byte { return c.view.Issuer() }
|
|
|
|
// Permissions reads the permission bitmask.
|
|
func (c Cap) Permissions() uint64 { return c.view.Permissions() }
|
|
|
|
// Parent reads the 32-byte parent cap ID. Zero means root.
|
|
func (c Cap) Parent() [32]byte { return c.view.Parent() }
|
|
|
|
// IssuedAt reads the unix-second issued-at timestamp.
|
|
func (c Cap) IssuedAt() uint64 { return c.view.IssuedAt() }
|
|
|
|
// ExpiresAt reads the unix-second expiry. Zero means never.
|
|
func (c Cap) ExpiresAt() uint64 { return c.view.ExpiresAt() }
|
|
|
|
// NumCaveats returns the number of caveats attached to this cap.
|
|
func (c Cap) NumCaveats() int { return c.view.Caveats().Length() }
|
|
|
|
// CaveatAt returns the i-th caveat. Out-of-range returns a zero Caveat.
|
|
// The Value slice aliases the underlying buffer; callers must not mutate
|
|
// it. Each call re-walks the variable-element list: O(i).
|
|
func (c Cap) CaveatAt(i int) Caveat {
|
|
list := c.view.Caveats()
|
|
if i < 0 || i >= list.Length() {
|
|
return Caveat{}
|
|
}
|
|
sub := list.ObjectAt(i)
|
|
if sub.IsNull() {
|
|
return Caveat{}
|
|
}
|
|
return Caveat{
|
|
Kind: CaveatKind(sub.Uint32(caveatViewKindOff)),
|
|
Value: sub.Bytes(caveatViewValueOff),
|
|
}
|
|
}
|
|
|
|
// Caveats returns the slice of caveats decoded in one walk. Values alias
|
|
// the buffer; do not mutate.
|
|
func (c Cap) Caveats() []Caveat {
|
|
list := c.view.Caveats()
|
|
n := list.Length()
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
out := make([]Caveat, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
sub := list.ObjectAt(i)
|
|
if sub.IsNull() {
|
|
return out
|
|
}
|
|
out = append(out, Caveat{
|
|
Kind: CaveatKind(sub.Uint32(caveatViewKindOff)),
|
|
Value: sub.Bytes(caveatViewValueOff),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Signature returns the SigSize-byte signature stored in the Sig field.
|
|
func (c Cap) Signature() [SigSize]byte { return c.view.Sig() }
|
|
|
|
// ID returns the canonical 32-byte identifier of this cap. Per SPEC.md §4
|
|
// the CapID is Hash32(CanonicalBytes(cap) || Sig) — the exact bytes
|
|
// signed at issue time plus the signature footer. Revocation records key
|
|
// on ID, and the chain walk matches each child's Parent to its parent's
|
|
// ID, so this construction is what binds the chain.
|
|
func (c Cap) ID() [32]byte {
|
|
sig := c.view.Sig()
|
|
buf := c.CanonicalBytes()
|
|
buf = append(buf, sig[:]...)
|
|
return Hash32(buf)
|
|
}
|
|
|
|
// Hash32 is the package's canonical 32-byte hash function. Exposed so
|
|
// signers and verifiers agree on the digest construction. SHA-256 is the
|
|
// spec-mandated CapID hash (SPEC.md §4): in every target language's stdlib,
|
|
// so cross-language CapIDs are trivially reproducible and the runtime stays
|
|
// zero-dependency. Treat the result as an opaque content identifier.
|
|
func Hash32(b []byte) [32]byte {
|
|
return sha256.Sum256(b)
|
|
}
|