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.
62 lines
2.0 KiB
Go
62 lines
2.0 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package envelope
|
|
|
|
import (
|
|
"crypto/sha512"
|
|
|
|
"github.com/luxfi/pqsafe/pkg/manifest"
|
|
)
|
|
|
|
// AADInput is the public set of inputs that determine the per-chunk
|
|
// AEAD AAD. The AAD is computed BEFORE chunk encryption (because chunk
|
|
// ciphertexts depend on it) so it can only contain fields that are
|
|
// fully determined pre-encryption. Seal and Open hash the same
|
|
// transcript and must agree byte-for-byte.
|
|
//
|
|
// Decomplected: the AAD is NOT the envelope_id. envelope_id is the
|
|
// hash of the signed manifest, which additionally binds content-bearing
|
|
// fields (BulkAEAD, PolicyLabel, PolicyURI, TTL, ChunkSize, Chunks,
|
|
// MerkleRoot) — those cannot live in the AAD because they depend on
|
|
// the very ciphertexts the AAD is sealing.
|
|
//
|
|
// Red HIGH #3 closed: a sender holding signing keys can no longer
|
|
// produce two envelopes with identical envelope_id but different
|
|
// content — envelope_id := manifestHash and the manifest commits to
|
|
// every content-bearing field.
|
|
type AADInput struct {
|
|
Profile string
|
|
Version uint32
|
|
SenderEd []byte
|
|
SenderML []byte
|
|
NonceSeed []byte
|
|
Created string
|
|
Recipients []manifest.RecipientRef
|
|
}
|
|
|
|
// AAD returns the 48-byte SHA-384 pre-encryption AEAD AAD. This value
|
|
// is opaque to external code; it exists only to bind every chunk's AEAD
|
|
// seal to the sender + nonce + recipient set at Seal-time, before any
|
|
// chunk has been encrypted. The authenticated envelope identifier
|
|
// (envelope_id) is the manifest hash, returned in `Sealed.EnvelopeID`.
|
|
func AAD(in AADInput) []byte {
|
|
h := sha512.New384()
|
|
h.Write([]byte(in.Profile))
|
|
var v [4]byte
|
|
v[0] = byte(in.Version >> 24)
|
|
v[1] = byte(in.Version >> 16)
|
|
v[2] = byte(in.Version >> 8)
|
|
v[3] = byte(in.Version)
|
|
h.Write(v[:])
|
|
h.Write(in.SenderEd)
|
|
h.Write(in.SenderML)
|
|
h.Write(in.NonceSeed)
|
|
h.Write([]byte(in.Created))
|
|
for _, r := range in.Recipients {
|
|
h.Write([]byte(r.ID))
|
|
h.Write(r.XWingPub)
|
|
}
|
|
return h.Sum(nil)
|
|
}
|