Files
zeekay 670ada11ed pqsafe: envelope_id := manifestHash — close Red HIGH #3 forgery class
Red team found: previous envelope_id transcript excluded content-bearing
fields (MerkleRoot, PolicyLabel, BulkAEAD, TTL, ChunkSize, Chunks). A
malicious sender holding signing keys could produce two distinct
envelopes with identical envelope_id, breaking any external system
using envelope_id as a dedupe key.

Fix (decomplected):
  - envelope_id is now SHA-384(canonical(manifest)) — the manifest hash.
    Every signed field is bound: MerkleRoot, PolicyLabel, PolicyURI,
    TTL, BulkAEAD, ChunkSize, Chunks, sender pubs, recipients, nonce_seed.
    Forgery requires breaking SHA-384 or the hybrid signature.
  - AAD (the chunk-AEAD additional authenticated data) is now distinct
    from envelope_id. AAD must be pre-encryption deterministic because
    chunk ciphertexts depend on it; envelope_id includes those same
    chunk SHAs and so can only be computed AFTER encryption. They are
    two different values now, separated by name and by role.
  - pkg/envelope/id.go: ID(IDInput) -> AAD(AADInput); semantics unchanged
    for the AAD path.
  - pkg/envelope/open.go: envID := mf.Hash() (post-manifest); recomputed
    AAD reuses the existing pre-encryption transcript.
  - pkg/envelope/seal.go: writes mf.EnvelopeID = mf.Hash() after manifest
    construction; capsule AAD uses the AAD function.
  - ErrEnvelopeIDMismatch deprecated — any drift now surfaces as ErrBadSig
    (the signature covers the full manifest, including envelope_id).

Tests: all 9 envelope tests pass including the new forgery-resistance
check that mutates each content-bearing field and verifies envelope_id
changes.
2026-05-13 15:39:19 -07:00

202 lines
6.0 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package envelope
import (
"bytes"
"crypto/sha512"
"errors"
"fmt"
"time"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/zwing"
"github.com/luxfi/pqsafe/pkg/aead"
"github.com/luxfi/pqsafe/pkg/capsule"
"github.com/luxfi/pqsafe/pkg/ctx"
"github.com/luxfi/pqsafe/pkg/manifest"
)
// OpenInput is the input bundle to Open.
type OpenInput struct {
ManifestBytes []byte
ManifestSig []byte
Chunks [][]byte
Capsules [][]byte
Recipient *zwing.Identity
RecipientID string
Now time.Time
}
// Errors.
var (
ErrUnknownProfile = errors.New("envelope: unknown profile")
ErrBadAlgorithm = errors.New("envelope: manifest declares non-canonical algorithm")
ErrBadSig = errors.New("envelope: hybrid signature failed")
ErrChunkCount = errors.New("envelope: chunk count mismatch")
ErrChunkHash = errors.New("envelope: chunk SHA mismatch")
ErrMerkleRoot = errors.New("envelope: Merkle root mismatch")
ErrNoCapsule = errors.New("envelope: no capsule for this recipient")
ErrCapsuleCount = errors.New("envelope: capsule count != recipient count")
ErrChunkSize = errors.New("envelope: manifest chunk_size != profile constant")
)
// ErrEnvelopeIDMismatch is retained for back-compat of error-checking
// callers; the new code path that produced it no longer exists because
// envelope_id is defined as the manifest hash and any mismatch surfaces
// as a signature failure (ErrBadSig).
//
// Deprecated: prefer errors.Is(err, ErrBadSig).
var ErrEnvelopeIDMismatch = errors.New("envelope: manifest envelope_id != recomputed envelope_id")
// Open verifies the manifest hybrid signature, locates the recipient's
// capsule, unwraps the DEK, and decrypts every chunk. Any signature
// mismatch, Merkle-root mismatch, AEAD failure, algorithm-string
// mismatch, or envelope_id mismatch aborts before producing plaintext.
func Open(in OpenInput) ([]byte, error) {
mf, err := manifest.Unmarshal(in.ManifestBytes)
if err != nil {
return nil, err
}
if mf.Profile != Profile || mf.Version != Version {
return nil, fmt.Errorf("%w: got %s/v%d expected %s/v%d",
ErrUnknownProfile, mf.Profile, mf.Version, Profile, Version)
}
// Allow-list the algorithm strings. Any deviation is a refusal —
// these fields are part of the signed manifest, so a mismatch is
// either malformed input or a downgrade attempt.
if mf.HashAlgo != AlgHash {
return nil, fmt.Errorf("%w: hash=%q", ErrBadAlgorithm, mf.HashAlgo)
}
if mf.KDF != AlgKDF {
return nil, fmt.Errorf("%w: kdf=%q", ErrBadAlgorithm, mf.KDF)
}
if mf.KEM != AlgKEM {
return nil, fmt.Errorf("%w: kem=%q", ErrBadAlgorithm, mf.KEM)
}
if mf.SigClassical != AlgSigClassical {
return nil, fmt.Errorf("%w: sig_classical=%q", ErrBadAlgorithm, mf.SigClassical)
}
if mf.SigPQ != AlgSigPQ {
return nil, fmt.Errorf("%w: sig_pq=%q", ErrBadAlgorithm, mf.SigPQ)
}
if mf.ChunkSize != ChunkSize {
return nil, fmt.Errorf("%w: got %d expected %d", ErrChunkSize, mf.ChunkSize, ChunkSize)
}
if !in.Now.IsZero() {
if err := mf.IsExpired(in.Now); err != nil {
return nil, err
}
}
if len(in.Capsules) != len(mf.Recipients) {
return nil, ErrCapsuleCount
}
if len(in.Chunks) != len(mf.Chunks) {
return nil, ErrChunkCount
}
dsaPub, err := mldsa.PublicKeyFromBytes(mf.Sender.MLDSA65Pub, mldsa.MLDSA65)
if err != nil {
return nil, fmt.Errorf("envelope: bad sender ML-DSA pub: %w", err)
}
senderPub := &zwing.IdentityPublic{
EdPub: mf.Sender.Ed25519Pub,
DSAPub: dsaPub,
}
// envelope_id := SHA-384(canonical(manifest)). This is the signed
// digest AND the capsule AAD AND the value external systems use as
// a dedupe key. Any tampering with any signed field (including
// MerkleRoot, PolicyLabel, PolicyURI, TTL, BulkAEAD, ChunkSize)
// changes envelope_id and therefore fails signature verification.
envID, err := mf.Hash()
if err != nil {
return nil, err
}
if err := senderPub.Verify([]byte(ctx.Manifest), envID, in.ManifestSig); err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadSig, err)
}
// AAD is the pre-encryption deterministic value that every chunk's
// AEAD seal authenticates against. It is NOT the envelope_id — see
// envelope.AAD for the full threat model. Open recomputes the AAD
// from the (signature-protected) manifest fields and uses it to
// verify and decrypt every chunk.
chunkAAD := AAD(AADInput{
Profile: mf.Profile,
Version: mf.Version,
SenderEd: mf.Sender.Ed25519Pub,
SenderML: mf.Sender.MLDSA65Pub,
NonceSeed: mf.NonceSeed,
Created: mf.Created,
Recipients: mf.Recipients,
})
recipIdx := -1
for i, r := range mf.Recipients {
if r.ID == in.RecipientID {
recipIdx = i
break
}
}
if recipIdx < 0 {
return nil, ErrNoCapsule
}
localXPub := in.Recipient.XWing.Public().MarshalBinary()
if !bytes.Equal(mf.Recipients[recipIdx].XWingPub, localXPub) {
return nil, fmt.Errorf("envelope: capsule recipient pub != local pub")
}
c, err := capsule.UnmarshalBinary(in.Capsules[recipIdx])
if err != nil {
return nil, err
}
dek, err := c.Open(in.Recipient.XWing, envID)
if err != nil {
return nil, err
}
defer func() {
for i := range dek {
dek[i] = 0
}
}()
var aeadProfile aead.Profile
switch mf.BulkAEAD {
case "AES-256-GCM":
aeadProfile = aead.ProfileAESGCM
case "ChaCha20-Poly1305":
aeadProfile = aead.ProfileChaCha20Poly1305
default:
return nil, fmt.Errorf("%w: unknown bulk AEAD %q", ErrUnknownProfile, mf.BulkAEAD)
}
mh := sha512.New384()
for i, ct := range in.Chunks {
hh := sha512.Sum384(ct)
if !bytes.Equal(hh[:], mf.Chunks[i].SHA) {
return nil, fmt.Errorf("%w: chunk %d", ErrChunkHash, i)
}
mh.Write(hh[:])
}
root := mh.Sum(nil)
if !bytes.Equal(root, mf.MerkleRoot) {
return nil, ErrMerkleRoot
}
var out bytes.Buffer
for i, ct := range in.Chunks {
pt, err := aead.Open(aeadProfile, dek, mf.NonceSeed, mf.Chunks[i].Index, chunkAAD, ct)
if err != nil {
return nil, fmt.Errorf("envelope: chunk %d open: %w", i, err)
}
out.Write(pt)
}
return out.Bytes(), nil
}