Files

138 lines
4.7 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package manifest implements the PQSAFE manifest: canonical CBOR
// encoding of the signed metadata that binds a sealed file to its
// recipients, policy, chunks, and capsules.
//
// One way only. The fields are fixed; the encoding is deterministic
// (sorted-keys core-deterministic CBOR per RFC 8949 ยง4.2.1). Two valid
// manifests with identical contents produce byte-identical bytes,
// which makes the hash deterministic and the signature reproducible.
package manifest
import (
"crypto/sha512"
"errors"
"fmt"
"time"
"github.com/fxamacker/cbor/v2"
)
// Manifest is the signed metadata bundle.
type Manifest struct {
Profile string `cbor:"profile"`
Version uint32 `cbor:"manifest_version"`
Created string `cbor:"created"` // RFC 3339
TTL string `cbor:"ttl"` // Go duration string
PolicyLabel string `cbor:"policy_label"` // e.g. "CUI"
PolicyURI string `cbor:"policy_uri,omitempty"`
BulkAEAD string `cbor:"bulk_aead"` // "AES-256-GCM" / "ChaCha20-Poly1305"
HashAlgo string `cbor:"hash"` // "SHA-384"
KDF string `cbor:"kdf"` // "HKDF-SHA-384"
KEM string `cbor:"kem"` // "X-Wing-MLKEM768"
SigClassical string `cbor:"sig_classical"` // "Ed25519"
SigPQ string `cbor:"sig_pq"` // "ML-DSA-65"
ChunkSize uint32 `cbor:"chunk_size"` // bytes
Chunks []ChunkRef `cbor:"chunks"`
MerkleRoot []byte `cbor:"merkle_root"`
NonceSeed []byte `cbor:"nonce_seed"` // 8 bytes
// envelope_id is NOT stored in the manifest. By construction
// envelope_id := SHA-384(canonical(manifest)), so storing it inside
// the manifest would either cause a self-reference cycle or admit
// a forgery where a sender produces two manifests with identical
// envelope_id but distinct content. The AAD bound into each chunk
// is computed from a subset of these fields via envelope.AAD; the
// signature over the manifest covers every content-bearing field
// (MerkleRoot, PolicyLabel, PolicyURI, TTL, BulkAEAD, ChunkSize,
// Chunks). See Red HIGH #3 for the threat model.
Sender Sender `cbor:"sender"`
Recipients []RecipientRef `cbor:"recipients"`
Audit Audit `cbor:"audit"`
}
// ChunkRef is the manifest entry for one ciphertext chunk.
type ChunkRef struct {
Index uint32 `cbor:"index"`
Len uint32 `cbor:"len"` // ciphertext byte length (chunk + tag)
SHA []byte `cbor:"sha384"` // SHA-384 of ciphertext bytes
}
// Sender identifies the sealing party.
type Sender struct {
DID string `cbor:"did,omitempty"`
X509ChainHash []byte `cbor:"x509_chain_hash,omitempty"`
Ed25519Pub []byte `cbor:"pub_ed25519"`
MLDSA65Pub []byte `cbor:"pq_pub_mldsa65"`
}
// RecipientRef is one entry in the manifest's recipients list.
type RecipientRef struct {
ID string `cbor:"recipient_id"`
XWingPub []byte `cbor:"pub_xwing"`
CapsulePath string `cbor:"capsule"` // relative path inside envelope
X509ChainHash []byte `cbor:"x509_chain_hash,omitempty"`
}
// Audit identifies where this envelope was anchored (hash-only).
type Audit struct {
AnchorChain string `cbor:"anchor_chain,omitempty"`
AuditURI string `cbor:"audit_uri,omitempty"`
CommitHash []byte `cbor:"commit_hash,omitempty"`
}
// Errors.
var (
ErrBadManifest = errors.New("manifest: malformed CBOR")
ErrBadProfile = errors.New("manifest: unknown profile")
ErrExpired = errors.New("manifest: TTL expired")
)
// MarshalCanonical produces deterministic core-deterministic CBOR.
func (m *Manifest) MarshalCanonical() ([]byte, error) {
enc, err := cbor.CoreDetEncOptions().EncMode()
if err != nil {
return nil, err
}
return enc.Marshal(m)
}
// Unmarshal parses CBOR manifest bytes.
func Unmarshal(b []byte) (*Manifest, error) {
m := &Manifest{}
if err := cbor.Unmarshal(b, m); err != nil {
return nil, fmt.Errorf("%w: %v", ErrBadManifest, err)
}
return m, nil
}
// Hash returns the SHA-384 of the canonical manifest encoding. This is
// the value used as the AEAD AAD on recipient capsules and chunk
// encryption, and as the digest signed by the sender.
func (m *Manifest) Hash() ([]byte, error) {
b, err := m.MarshalCanonical()
if err != nil {
return nil, err
}
h := sha512.Sum384(b)
return h[:], nil
}
// IsExpired reports whether the manifest's TTL has elapsed since
// Created. Returns ErrExpired if so.
func (m *Manifest) IsExpired(now time.Time) error {
created, err := time.Parse(time.RFC3339, m.Created)
if err != nil {
return err
}
ttl, err := time.ParseDuration(m.TTL)
if err != nil {
return err
}
if now.After(created.Add(ttl)) {
return ErrExpired
}
return nil
}