mirror of
https://github.com/luxfi/pqsafe.git
synced 2026-10-30 15:21:41 +00:00
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.
202 lines
6.0 KiB
Go
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
|
|
}
|