273 lines
6.4 KiB
Go
273 lines
6.4 KiB
Go
/*
|
|
©AngelaMos | 2026
|
|
pdf.go
|
|
|
|
PDF carrier with three techniques: embedded-file attachment, Info-dict metadata keys, and raw append-after-EOF
|
|
*/
|
|
|
|
package pdf
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/CarterPerez-dev/crypha/internal/carrier"
|
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
|
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
|
)
|
|
|
|
type Technique string
|
|
|
|
const (
|
|
TechniqueAttachment Technique = "attachment"
|
|
TechniqueMetadata Technique = "metadata"
|
|
TechniqueAppend Technique = "append"
|
|
)
|
|
|
|
const (
|
|
Format = "pdf"
|
|
|
|
lengthPrefixBytes = 4
|
|
unboundedCapacity = math.MaxInt32
|
|
|
|
attachmentID = "crypha-payload.bin"
|
|
attachmentDesc = "crypha embedded payload"
|
|
|
|
metaKeyPrefix = "X-Crypha-Payload-"
|
|
metaCountKey = "X-Crypha-Payload-Count"
|
|
metaChunkSize = 8192
|
|
)
|
|
|
|
var (
|
|
pdfSignature = []byte("%PDF-")
|
|
appendMagic = []byte{0x43, 0x72, 0x79, 0x50}
|
|
epoch = time.Unix(0, 0)
|
|
)
|
|
|
|
var (
|
|
ErrEmptyPayload = errors.New("crypha/pdf: empty payload")
|
|
ErrUnsupportedFormat = errors.New("crypha/pdf: cover must be a PDF")
|
|
ErrNoPayload = errors.New("crypha/pdf: no crypha payload found")
|
|
)
|
|
|
|
type pdfCarrier struct {
|
|
technique Technique
|
|
}
|
|
|
|
func init() {
|
|
model.ConfigPath = "disable"
|
|
carrier.Register(pdfCarrier{technique: TechniqueAttachment})
|
|
}
|
|
|
|
func New(t Technique) carrier.Carrier {
|
|
return pdfCarrier{technique: t}
|
|
}
|
|
|
|
func (pdfCarrier) Format() string {
|
|
return Format
|
|
}
|
|
|
|
func (c pdfCarrier) Hide(cover io.Reader, payload []byte, out io.Writer) error {
|
|
if len(payload) == 0 {
|
|
return ErrEmptyPayload
|
|
}
|
|
|
|
data, err := io.ReadAll(cover)
|
|
if err != nil {
|
|
return fmt.Errorf("crypha/pdf: read cover: %w", err)
|
|
}
|
|
if !isPDF(data) {
|
|
return ErrUnsupportedFormat
|
|
}
|
|
|
|
switch c.technique {
|
|
case TechniqueMetadata:
|
|
return hideMetadata(data, payload, out)
|
|
case TechniqueAppend:
|
|
return hideAppend(data, payload, out)
|
|
default:
|
|
return hideAttachment(data, payload, out)
|
|
}
|
|
}
|
|
|
|
func (pdfCarrier) Reveal(stego io.Reader) ([]byte, error) {
|
|
data, err := io.ReadAll(stego)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypha/pdf: read stego: %w", err)
|
|
}
|
|
if !isPDF(data) {
|
|
return nil, ErrUnsupportedFormat
|
|
}
|
|
|
|
if payload, ok := revealAttachment(data); ok && len(payload) > 0 {
|
|
return payload, nil
|
|
}
|
|
if payload, ok := revealMetadata(data); ok && len(payload) > 0 {
|
|
return payload, nil
|
|
}
|
|
if payload, ok := revealAppend(data); ok && len(payload) > 0 {
|
|
return payload, nil
|
|
}
|
|
return nil, ErrNoPayload
|
|
}
|
|
|
|
func (pdfCarrier) Capacity(cover io.Reader) (int, error) {
|
|
data, err := io.ReadAll(cover)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("crypha/pdf: read cover: %w", err)
|
|
}
|
|
if !isPDF(data) {
|
|
return 0, ErrUnsupportedFormat
|
|
}
|
|
return unboundedCapacity, nil
|
|
}
|
|
|
|
func (pdfCarrier) Sniff(stego io.ReadSeeker) bool {
|
|
head := make([]byte, len(pdfSignature))
|
|
if _, err := io.ReadFull(stego, head); err != nil {
|
|
return false
|
|
}
|
|
return bytes.Equal(head, pdfSignature)
|
|
}
|
|
|
|
func hideAttachment(cover, payload []byte, out io.Writer) error {
|
|
conf := newConfig()
|
|
ctx, err := api.ReadValidateAndOptimize(bytes.NewReader(cover), conf)
|
|
if err != nil {
|
|
return fmt.Errorf("crypha/pdf: read cover: %w", err)
|
|
}
|
|
att := model.Attachment{
|
|
Reader: bytes.NewReader(payload),
|
|
ID: attachmentID,
|
|
FileName: attachmentID,
|
|
Desc: attachmentDesc,
|
|
ModTime: &epoch,
|
|
}
|
|
if err := ctx.AddAttachment(att, false); err != nil {
|
|
return fmt.Errorf("crypha/pdf: attach payload: %w", err)
|
|
}
|
|
if err := api.Write(ctx, out, conf); err != nil {
|
|
return fmt.Errorf("crypha/pdf: write pdf: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revealAttachment(stego []byte) ([]byte, bool) {
|
|
conf := newConfig()
|
|
attachments, err := api.ExtractAttachmentsRaw(bytes.NewReader(stego), "", nil, conf)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
for _, att := range attachments {
|
|
if att.ID != attachmentID && att.FileName != attachmentID {
|
|
continue
|
|
}
|
|
payload, rerr := io.ReadAll(att)
|
|
if rerr != nil {
|
|
return nil, false
|
|
}
|
|
return payload, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func hideMetadata(cover, payload []byte, out io.Writer) error {
|
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
|
props := map[string]string{}
|
|
count := 0
|
|
for offset := 0; offset < len(encoded); offset += metaChunkSize {
|
|
end := offset + metaChunkSize
|
|
if end > len(encoded) {
|
|
end = len(encoded)
|
|
}
|
|
props[metaKeyPrefix+strconv.Itoa(count)] = encoded[offset:end]
|
|
count++
|
|
}
|
|
props[metaCountKey] = strconv.Itoa(count)
|
|
|
|
if err := api.AddProperties(bytes.NewReader(cover), out, props, newConfig()); err != nil {
|
|
return fmt.Errorf("crypha/pdf: write metadata: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revealMetadata(stego []byte) ([]byte, bool) {
|
|
props, err := api.Properties(bytes.NewReader(stego), newConfig())
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
countStr, ok := props[metaCountKey]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
count, err := strconv.Atoi(countStr)
|
|
if err != nil || count <= 0 {
|
|
return nil, false
|
|
}
|
|
|
|
var encoded bytes.Buffer
|
|
for i := 0; i < count; i++ {
|
|
chunk, ok := props[metaKeyPrefix+strconv.Itoa(i)]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
encoded.WriteString(chunk)
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(encoded.String())
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return payload, true
|
|
}
|
|
|
|
func hideAppend(cover, payload []byte, out io.Writer) error {
|
|
if _, err := out.Write(cover); err != nil {
|
|
return err
|
|
}
|
|
if _, err := out.Write(appendMagic); err != nil {
|
|
return err
|
|
}
|
|
if _, err := out.Write(payload); err != nil {
|
|
return err
|
|
}
|
|
var lenField [lengthPrefixBytes]byte
|
|
binary.BigEndian.PutUint32(lenField[:], uint32(len(payload)))
|
|
_, err := out.Write(lenField[:])
|
|
return err
|
|
}
|
|
|
|
func revealAppend(stego []byte) ([]byte, bool) {
|
|
trailer := len(appendMagic) + lengthPrefixBytes
|
|
if len(stego) < trailer {
|
|
return nil, false
|
|
}
|
|
payloadLen := binary.BigEndian.Uint32(stego[len(stego)-lengthPrefixBytes:])
|
|
if uint64(len(appendMagic))+uint64(payloadLen)+uint64(lengthPrefixBytes) > uint64(len(stego)) {
|
|
return nil, false
|
|
}
|
|
magicStart := len(stego) - lengthPrefixBytes - int(payloadLen) - len(appendMagic)
|
|
if !bytes.Equal(stego[magicStart:magicStart+len(appendMagic)], appendMagic) {
|
|
return nil, false
|
|
}
|
|
payloadStart := magicStart + len(appendMagic)
|
|
return stego[payloadStart : payloadStart+int(payloadLen)], true
|
|
}
|
|
|
|
func newConfig() *model.Configuration {
|
|
conf := model.NewDefaultConfiguration()
|
|
conf.ValidationMode = model.ValidationRelaxed
|
|
return conf
|
|
}
|
|
|
|
func isPDF(data []byte) bool {
|
|
return bytes.HasPrefix(data, pdfSignature)
|
|
}
|