mirror of
https://github.com/luxfi/pqsafe.git
synced 2026-10-30 15:21:41 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
200 lines
4.8 KiB
Go
200 lines
4.8 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package envelope
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha512"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Canonical algorithm strings for PQSAFE-IL5-HYBRID-v1. The Open path
|
|
// allow-lists exactly these values; any deviation refuses the envelope.
|
|
const (
|
|
AlgHash = "SHA-384"
|
|
AlgKDF = "HKDF-SHA-384"
|
|
AlgKEM = "X-Wing-MLKEM768"
|
|
AlgSigClassical = "Ed25519"
|
|
AlgSigPQ = "ML-DSA-65"
|
|
)
|
|
|
|
// SealInput is the input bundle to Seal.
|
|
type SealInput struct {
|
|
Plaintext []byte
|
|
Sender *zwing.Identity
|
|
Recipients []Recipient
|
|
PolicyLabel string
|
|
PolicyURI string
|
|
TTL time.Duration
|
|
BulkAEAD aead.Profile
|
|
}
|
|
|
|
// Recipient is one recipient bundle.
|
|
type Recipient struct {
|
|
ID string
|
|
Public *zwing.IdentityPublic
|
|
}
|
|
|
|
// Sealed is the result of Seal.
|
|
type Sealed struct {
|
|
ManifestBytes []byte
|
|
ManifestSig []byte
|
|
Chunks [][]byte
|
|
Capsules [][]byte
|
|
EnvelopeID []byte
|
|
}
|
|
|
|
// Errors.
|
|
var ErrNoRecipient = errors.New("envelope: at least one recipient required")
|
|
|
|
// Seal encrypts the plaintext under a fresh DEK and produces a fully
|
|
// signed envelope. zwing supplies the canonical X-Wing + hybrid
|
|
// identity primitives.
|
|
func Seal(in SealInput) (*Sealed, error) {
|
|
if len(in.Recipients) == 0 {
|
|
return nil, ErrNoRecipient
|
|
}
|
|
if in.BulkAEAD == 0 {
|
|
in.BulkAEAD = aead.ProfileAESGCM
|
|
}
|
|
if in.TTL == 0 {
|
|
in.TTL = 7 * 24 * time.Hour
|
|
}
|
|
|
|
dek := make([]byte, aead.KeySize)
|
|
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
|
return nil, err
|
|
}
|
|
nonceSeed := make([]byte, aead.NonceSeedLen)
|
|
if _, err := io.ReadFull(rand.Reader, nonceSeed); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
chunkCount := (len(in.Plaintext) + ChunkSize - 1) / ChunkSize
|
|
if chunkCount == 0 {
|
|
chunkCount = 1
|
|
}
|
|
|
|
senderEd := []byte(in.Sender.EdPub)
|
|
senderML := in.Sender.DSAPriv.PublicKey.Bytes()
|
|
created := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
recipients := make([]manifest.RecipientRef, len(in.Recipients))
|
|
for i, r := range in.Recipients {
|
|
recipients[i] = manifest.RecipientRef{
|
|
ID: r.ID,
|
|
XWingPub: r.Public.XWingPub.MarshalBinary(),
|
|
CapsulePath: fmt.Sprintf("capsules/%s.bin", r.ID),
|
|
}
|
|
}
|
|
|
|
// Pre-encryption AEAD AAD: deterministic at chunk-seal time, derived
|
|
// from fields known before any chunk is encrypted. NOT the envelope
|
|
// identifier — see envelope.AAD for the threat model.
|
|
chunkAAD := AAD(AADInput{
|
|
Profile: Profile,
|
|
Version: Version,
|
|
SenderEd: senderEd,
|
|
SenderML: senderML,
|
|
NonceSeed: nonceSeed,
|
|
Created: created,
|
|
Recipients: recipients,
|
|
})
|
|
|
|
chunks := make([][]byte, chunkCount)
|
|
chunkRefs := make([]manifest.ChunkRef, chunkCount)
|
|
for i := 0; i < chunkCount; i++ {
|
|
lo := i * ChunkSize
|
|
hi := min(lo+ChunkSize, len(in.Plaintext))
|
|
ct, err := aead.Seal(in.BulkAEAD, dek, nonceSeed, uint32(i+1), chunkAAD, in.Plaintext[lo:hi])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chunks[i] = ct
|
|
hh := sha512.Sum384(ct)
|
|
chunkRefs[i] = manifest.ChunkRef{
|
|
Index: uint32(i + 1),
|
|
Len: uint32(len(ct)),
|
|
SHA: hh[:],
|
|
}
|
|
}
|
|
|
|
mh := sha512.New384()
|
|
for _, cr := range chunkRefs {
|
|
mh.Write(cr.SHA)
|
|
}
|
|
merkleRoot := mh.Sum(nil)
|
|
|
|
mf := &manifest.Manifest{
|
|
Profile: Profile,
|
|
Version: Version,
|
|
Created: created,
|
|
TTL: in.TTL.String(),
|
|
PolicyLabel: in.PolicyLabel,
|
|
PolicyURI: in.PolicyURI,
|
|
BulkAEAD: in.BulkAEAD.String(),
|
|
HashAlgo: AlgHash,
|
|
KDF: AlgKDF,
|
|
KEM: AlgKEM,
|
|
SigClassical: AlgSigClassical,
|
|
SigPQ: AlgSigPQ,
|
|
ChunkSize: ChunkSize,
|
|
NonceSeed: nonceSeed,
|
|
Chunks: chunkRefs,
|
|
MerkleRoot: merkleRoot,
|
|
Sender: manifest.Sender{
|
|
Ed25519Pub: senderEd,
|
|
MLDSA65Pub: senderML,
|
|
},
|
|
Recipients: recipients,
|
|
}
|
|
|
|
manifestBytes, err := mf.MarshalCanonical()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// envelope_id := SHA-384(canonical(manifest)). This is the same value
|
|
// the sender signs (binding every content-bearing field) and the value
|
|
// recipient capsules use as AAD. External dedupe / audit keys on
|
|
// envelope_id are now forgery-resistant: any change to any signed
|
|
// field changes envelope_id.
|
|
envID, err := mf.Hash()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
capsules := make([][]byte, len(in.Recipients))
|
|
for i, r := range in.Recipients {
|
|
c, err := capsule.Seal(r.Public.XWingPub, dek, envID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
capsules[i] = c.MarshalBinary()
|
|
}
|
|
|
|
sig := in.Sender.Sign([]byte(ctx.Manifest), envID)
|
|
|
|
for i := range dek {
|
|
dek[i] = 0
|
|
}
|
|
|
|
return &Sealed{
|
|
ManifestBytes: manifestBytes,
|
|
ManifestSig: sig,
|
|
Chunks: chunks,
|
|
Capsules: capsules,
|
|
EnvelopeID: envID,
|
|
}, nil
|
|
}
|