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

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