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