Files
nexus/internal/leviathan/stego/metadata.go
T
2026-06-16 11:51:07 +01:00

695 lines
19 KiB
Go

package stego
import (
"bytes"
"compress/zlib"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"github.com/andydixon/nexus/internal/leviathan/document"
)
// MetaEntry is one piece of textual/EXIF metadata recovered from an image.
type MetaEntry struct {
Offset int64 `json:"offset"`
Length int64 `json:"length"`
Container string `json:"container"`
Kind string `json:"kind"`
Keyword string `json:"keyword"`
Value string `json:"value"`
Note string `json:"note,omitempty"`
}
// ScanMetadata walks PNG chunks or JPEG segments (APPn / COM) and decodes
// human-readable metadata. Output is best-effort — malformed containers
// return whatever has been parsed up to the failure point.
func ScanMetadata(d *document.Document) ([]MetaEntry, error) {
size := d.Size()
if size < 8 {
return nil, errors.New("file too small")
}
head, err := d.Range(0, minInt64(16, size))
if err != nil {
return nil, err
}
switch {
case len(head) >= 8 && string(head[:8]) == "\x89PNG\r\n\x1a\n":
return scanPNGMeta(d)
case len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff:
return scanJPEGMeta(d)
case len(head) >= 6 && (string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a"):
return scanGIFMeta(d)
}
return nil, errors.New("unsupported container (PNG, JPEG and GIF handled)")
}
func scanPNGMeta(d *document.Document) ([]MetaEntry, error) {
size := d.Size()
out := []MetaEntry{}
off := int64(8)
for off+12 <= size {
hdr, err := d.Range(off, 8)
if err != nil {
break
}
ln := int64(binary.BigEndian.Uint32(hdr[0:4]))
typ := string(hdr[4:8])
if ln < 0 || off+12+ln > size {
break
}
if typ == "tEXt" || typ == "zTXt" || typ == "iTXt" {
data, err := d.Range(off+8, ln)
if err == nil {
entry := decodePNGTextChunk(typ, data)
entry.Offset = off
entry.Length = 12 + ln
entry.Container = "png"
out = append(out, entry)
}
}
off += 12 + ln
if typ == "IEND" {
break
}
}
return out, nil
}
func decodePNGTextChunk(typ string, data []byte) MetaEntry {
e := MetaEntry{Kind: typ}
i := bytes.IndexByte(data, 0)
if i < 0 {
e.Note = "no keyword separator"
e.Value = safeText(data)
return e
}
e.Keyword = string(data[:i])
rest := data[i+1:]
switch typ {
case "tEXt":
e.Value = safeText(rest)
case "zTXt":
if len(rest) < 1 {
e.Note = "truncated"
return e
}
// rest[0] = compression method (0 = zlib)
method := rest[0]
raw, err := zlibDecompress(rest[1:])
if err != nil {
e.Note = fmt.Sprintf("zlib fail (method=%d): %v", method, err)
return e
}
e.Value = safeText(raw)
case "iTXt":
// compression flag, compression method, language tag\0, translated keyword\0, text
if len(rest) < 2 {
e.Note = "truncated"
return e
}
compressed := rest[0] == 1
rest = rest[2:]
j := bytes.IndexByte(rest, 0)
if j < 0 {
e.Value = safeText(rest)
return e
}
lang := string(rest[:j])
rest = rest[j+1:]
k := bytes.IndexByte(rest, 0)
if k < 0 {
e.Value = safeText(rest)
return e
}
trk := string(rest[:k])
rest = rest[k+1:]
if compressed {
if raw, err := zlibDecompress(rest); err == nil {
rest = raw
} else {
e.Note = "zlib fail: " + err.Error()
}
}
v := safeText(rest)
if lang != "" || trk != "" {
v = fmt.Sprintf("[%s/%s] %s", lang, trk, v)
}
e.Value = v
}
return e
}
func scanJPEGMeta(d *document.Document) ([]MetaEntry, error) {
size := d.Size()
out := []MetaEntry{}
off := int64(2) // skip SOI
for off+4 <= size {
hdr, err := d.Range(off, 2)
if err != nil || hdr[0] != 0xff {
break
}
marker := hdr[1]
// Standalone markers (no payload): SOI/EOI/RSTn
if marker == 0xd9 || marker == 0xd8 || (marker >= 0xd0 && marker <= 0xd7) {
off += 2
continue
}
// Read segment length (big-endian, includes the length bytes themselves)
lnBytes, err := d.Range(off+2, 2)
if err != nil {
break
}
segLen := int64(binary.BigEndian.Uint16(lnBytes))
if segLen < 2 || off+2+segLen > size {
break
}
payload, err := d.Range(off+4, segLen-2)
if err != nil {
break
}
switch {
case marker == 0xfe: // COM
out = append(out, MetaEntry{
Offset: off, Length: 2 + segLen,
Container: "jpeg", Kind: "COM",
Value: safeText(payload),
})
case marker == 0xe1: // APP1 — EXIF / XMP
entries := decodeJPEGApp1(payload)
for _, e := range entries {
e.Offset = off
e.Length = 2 + segLen
e.Container = "jpeg"
out = append(out, e)
}
case marker == 0xe0: // APP0 — JFIF
out = append(out, MetaEntry{
Offset: off, Length: 2 + segLen,
Container: "jpeg", Kind: "APP0",
Keyword: sniffMarker(payload),
Value: fmt.Sprintf("%d bytes", len(payload)),
})
case marker >= 0xe2 && marker <= 0xef: // APP2..APP15
out = append(out, MetaEntry{
Offset: off, Length: 2 + segLen,
Container: "jpeg", Kind: fmt.Sprintf("APP%d", marker-0xe0),
Keyword: sniffMarker(payload),
Value: fmt.Sprintf("%d bytes", len(payload)),
})
}
off += 2 + segLen
// SOS (Start of Scan) — entropy-coded data follows, stop walking.
if marker == 0xda {
break
}
}
return out, nil
}
func sniffMarker(p []byte) string {
i := bytes.IndexByte(p, 0)
if i < 0 || i > 16 {
if len(p) > 16 {
return string(p[:16])
}
return ""
}
return string(p[:i])
}
func decodeJPEGApp1(p []byte) []MetaEntry {
out := []MetaEntry{}
if len(p) >= 6 && string(p[:6]) == "Exif\x00\x00" {
tiff := p[6:]
entries, err := decodeEXIF(tiff)
if err == nil {
out = append(out, entries...)
} else {
out = append(out, MetaEntry{Kind: "APP1/EXIF", Note: err.Error()})
}
return out
}
if len(p) >= 28 && string(p[:28]) == "http://ns.adobe.com/xap/1.0/" {
xmp := p[29:]
out = append(out, MetaEntry{
Kind: "APP1/XMP", Keyword: "xmp", Value: safeText(xmp),
})
return out
}
out = append(out, MetaEntry{
Kind: "APP1", Keyword: sniffMarker(p), Value: fmt.Sprintf("%d bytes", len(p)),
})
return out
}
// EXIF reader: TIFF header + IFD0 + IFD1 + Exif SubIFD + GPS SubIFD +
// Interoperability IFD. Handles ASCII/BYTE/SHORT/LONG/RATIONAL/SRATIONAL
// values, plus UNDEFINED as hex preview. Detects MakerNote presence.
func decodeEXIF(tiff []byte) ([]MetaEntry, error) {
if len(tiff) < 8 {
return nil, errors.New("short TIFF header")
}
var bo binary.ByteOrder
switch string(tiff[:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return nil, errors.New("bad TIFF byte-order mark")
}
if bo.Uint16(tiff[2:4]) != 0x002a {
return nil, errors.New("bad TIFF magic")
}
ifd0 := int(bo.Uint32(tiff[4:8]))
out := []MetaEntry{}
ptrs := ifdPointers{}
nextIFD := 0
out = append(out, readIFD(tiff, ifd0, bo, "IFD0", &ptrs, &nextIFD)...)
// IFD1 (thumbnail) if present.
if nextIFD > 0 {
out = append(out, readIFD(tiff, nextIFD, bo, "IFD1", nil, nil)...)
}
if ptrs.exif > 0 {
out = append(out, readIFD(tiff, ptrs.exif, bo, "ExifIFD", &ptrs, nil)...)
}
if ptrs.gps > 0 {
out = append(out, readIFD(tiff, ptrs.gps, bo, "GPSIFD", nil, nil)...)
}
if ptrs.interop > 0 {
out = append(out, readIFD(tiff, ptrs.interop, bo, "InteropIFD", nil, nil)...)
}
return out, nil
}
type ifdPointers struct {
exif int
gps int
interop int
}
func readIFD(tiff []byte, off int, bo binary.ByteOrder, name string, ptrs *ifdPointers, nextPtr *int) []MetaEntry {
out := []MetaEntry{}
if off < 0 || off+2 > len(tiff) {
return out
}
n := int(bo.Uint16(tiff[off : off+2]))
base := off + 2
// Cap directory count to head off hostile input.
if n > 512 {
n = 512
}
for i := 0; i < n; i++ {
p := base + i*12
if p+12 > len(tiff) {
break
}
tag := bo.Uint16(tiff[p : p+2])
typ := bo.Uint16(tiff[p+2 : p+4])
count := int(bo.Uint32(tiff[p+4 : p+8]))
valRaw := tiff[p+8 : p+12]
// Sub-IFD pointers never appear as regular entries in the output.
if ptrs != nil {
switch tag {
case 0x8769: // Exif SubIFD pointer
ptrs.exif = int(bo.Uint32(valRaw))
continue
case 0x8825: // GPS IFD pointer
ptrs.gps = int(bo.Uint32(valRaw))
continue
case 0xa005: // Interoperability IFD pointer
ptrs.interop = int(bo.Uint32(valRaw))
continue
}
}
e := MetaEntry{Kind: name, Keyword: exifTagName(tag, name)}
if tag == 0x927c { // MakerNote
e.Value = fmt.Sprintf("%d bytes (vendor-specific, not decoded)", count)
out = append(out, e)
continue
}
e.Value = readEXIFValue(tiff, bo, typ, count, valRaw)
out = append(out, e)
}
// "Next IFD" pointer follows the directory entries (4 bytes).
if nextPtr != nil {
tail := base + n*12
if tail+4 <= len(tiff) {
*nextPtr = int(bo.Uint32(tiff[tail : tail+4]))
}
}
return out
}
func readEXIFValue(tiff []byte, bo binary.ByteOrder, typ uint16, count int, valRaw []byte) string {
typeSize := map[uint16]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
sz, ok := typeSize[typ]
if !ok {
return fmt.Sprintf("type=%d count=%d", typ, count)
}
total := sz * count
var data []byte
if total <= 4 {
data = valRaw[:total]
} else {
off := int(bo.Uint32(valRaw))
if off < 0 || off+total > len(tiff) {
return fmt.Sprintf("type=%d count=%d (oob)", typ, count)
}
data = tiff[off : off+total]
}
switch typ {
case 2: // ASCII (null-terminated)
return safeText(bytes.TrimRight(data, "\x00"))
case 1, 7: // BYTE, UNDEFINED — show as hex preview
if len(data) > 32 {
return fmt.Sprintf("%x… (%d bytes)", data[:32], len(data))
}
return fmt.Sprintf("%x", data)
case 3: // SHORT
parts := []string{}
for i := 0; i < count && i < 8; i++ {
parts = append(parts, fmt.Sprintf("%d", bo.Uint16(data[i*2:i*2+2])))
}
if count > 8 {
parts = append(parts, "…")
}
return strings.Join(parts, ", ")
case 4: // LONG
parts := []string{}
for i := 0; i < count && i < 6; i++ {
parts = append(parts, fmt.Sprintf("%d", bo.Uint32(data[i*4:i*4+4])))
}
if count > 6 {
parts = append(parts, "…")
}
return strings.Join(parts, ", ")
case 5, 10: // RATIONAL / SRATIONAL
parts := []string{}
for i := 0; i < count && i < 4; i++ {
n := bo.Uint32(data[i*8 : i*8+4])
dn := bo.Uint32(data[i*8+4 : i*8+8])
if dn == 0 {
parts = append(parts, fmt.Sprintf("%d/0", n))
} else {
parts = append(parts, fmt.Sprintf("%d/%d", n, dn))
}
}
return strings.Join(parts, ", ")
case 9: // SLONG
parts := []string{}
for i := 0; i < count && i < 6; i++ {
parts = append(parts, fmt.Sprintf("%d", int32(bo.Uint32(data[i*4:i*4+4]))))
}
return strings.Join(parts, ", ")
}
return fmt.Sprintf("type=%d count=%d", typ, count)
}
func exifTagName(tag uint16, ifd string) string {
if ifd == "GPSIFD" {
if n, ok := gpsTags[tag]; ok {
return n
}
return fmt.Sprintf("GPS tag 0x%04x", tag)
}
if n, ok := exifTags[tag]; ok {
return n
}
return fmt.Sprintf("tag 0x%04x", tag)
}
// exifTags covers the standard TIFF/Exif tags routinely seen in
// JPEG/TIFF images produced by consumer cameras and phones.
var exifTags = map[uint16]string{
// TIFF / IFD0
0x0100: "ImageWidth", 0x0101: "ImageLength",
0x0102: "BitsPerSample", 0x0103: "Compression",
0x0106: "PhotometricInterpretation", 0x010a: "FillOrder",
0x010d: "DocumentName", 0x010e: "ImageDescription",
0x010f: "Make", 0x0110: "Model", 0x0111: "StripOffsets",
0x0112: "Orientation", 0x0115: "SamplesPerPixel",
0x0116: "RowsPerStrip", 0x0117: "StripByteCounts",
0x011a: "XResolution", 0x011b: "YResolution",
0x011c: "PlanarConfiguration", 0x0128: "ResolutionUnit",
0x012d: "TransferFunction", 0x0131: "Software", 0x0132: "DateTime",
0x013b: "Artist", 0x013c: "HostComputer",
0x013e: "WhitePoint", 0x013f: "PrimaryChromaticities",
0x0211: "YCbCrCoefficients", 0x0212: "YCbCrSubSampling",
0x0213: "YCbCrPositioning", 0x0214: "ReferenceBlackWhite",
0x02bc: "XMP",
0x8298: "Copyright", 0x83bb: "IPTCNAA",
0x8769: "ExifIFDPointer", 0x8773: "InterColorProfile",
0x8825: "GPSIFDPointer",
// Exif SubIFD
0x829a: "ExposureTime", 0x829d: "FNumber",
0x8822: "ExposureProgram", 0x8824: "SpectralSensitivity",
0x8827: "ISOSpeedRatings", 0x8828: "OECF",
0x9000: "ExifVersion",
0x9003: "DateTimeOriginal", 0x9004: "DateTimeDigitized",
0x9010: "OffsetTime", 0x9011: "OffsetTimeOriginal", 0x9012: "OffsetTimeDigitized",
0x9101: "ComponentsConfiguration", 0x9102: "CompressedBitsPerPixel",
0x9201: "ShutterSpeedValue", 0x9202: "ApertureValue",
0x9203: "BrightnessValue", 0x9204: "ExposureBiasValue",
0x9205: "MaxApertureValue", 0x9206: "SubjectDistance",
0x9207: "MeteringMode", 0x9208: "LightSource", 0x9209: "Flash",
0x920a: "FocalLength",
0x9214: "SubjectArea", 0x927c: "MakerNote", 0x9286: "UserComment",
0x9290: "SubSecTime", 0x9291: "SubSecTimeOriginal", 0x9292: "SubSecTimeDigitized",
0xa000: "FlashpixVersion", 0xa001: "ColorSpace",
0xa002: "PixelXDimension", 0xa003: "PixelYDimension",
0xa004: "RelatedSoundFile", 0xa005: "InteropIFDPointer",
0xa20b: "FlashEnergy", 0xa20c: "SpatialFrequencyResponse",
0xa20e: "FocalPlaneXResolution", 0xa20f: "FocalPlaneYResolution",
0xa210: "FocalPlaneResolutionUnit",
0xa214: "SubjectLocation", 0xa215: "ExposureIndex",
0xa217: "SensingMethod", 0xa300: "FileSource", 0xa301: "SceneType",
0xa302: "CFAPattern", 0xa401: "CustomRendered",
0xa402: "ExposureMode", 0xa403: "WhiteBalance",
0xa404: "DigitalZoomRatio", 0xa405: "FocalLengthIn35mmFilm",
0xa406: "SceneCaptureType", 0xa407: "GainControl",
0xa408: "Contrast", 0xa409: "Saturation", 0xa40a: "Sharpness",
0xa40b: "DeviceSettingDescription", 0xa40c: "SubjectDistanceRange",
0xa420: "ImageUniqueID", 0xa430: "OwnerName", 0xa431: "SerialNumber",
0xa432: "LensSpecification", 0xa433: "LensMake", 0xa434: "LensModel",
0xa435: "LensSerialNumber",
// Thumbnail IFD1 re-uses IFD0 tags; additional ones:
0x0201: "JPEGInterchangeFormat", 0x0202: "JPEGInterchangeFormatLength",
}
var gpsTags = map[uint16]string{
0x0000: "GPSVersionID", 0x0001: "GPSLatitudeRef", 0x0002: "GPSLatitude",
0x0003: "GPSLongitudeRef", 0x0004: "GPSLongitude",
0x0005: "GPSAltitudeRef", 0x0006: "GPSAltitude",
0x0007: "GPSTimeStamp", 0x0008: "GPSSatellites", 0x0009: "GPSStatus",
0x000a: "GPSMeasureMode", 0x000b: "GPSDOP", 0x000c: "GPSSpeedRef",
0x000d: "GPSSpeed", 0x000e: "GPSTrackRef", 0x000f: "GPSTrack",
0x0010: "GPSImgDirectionRef", 0x0011: "GPSImgDirection",
0x0012: "GPSMapDatum", 0x0013: "GPSDestLatitudeRef", 0x0014: "GPSDestLatitude",
0x0015: "GPSDestLongitudeRef", 0x0016: "GPSDestLongitude",
0x0017: "GPSDestBearingRef", 0x0018: "GPSDestBearing",
0x0019: "GPSDestDistanceRef", 0x001a: "GPSDestDistance",
0x001b: "GPSProcessingMethod", 0x001c: "GPSAreaInformation",
0x001d: "GPSDateStamp", 0x001e: "GPSDifferential", 0x001f: "GPSHPositioningError",
}
func zlibDecompress(b []byte) ([]byte, error) {
r, err := zlib.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
// 2 MiB cap — textual metadata should never be near this.
lim := &io.LimitedReader{R: r, N: 2 * 1024 * 1024}
return io.ReadAll(lim)
}
func safeText(b []byte) string {
// Preserve text but trim extreme size and strip control chars other than tab/newline.
if len(b) > 4096 {
b = b[:4096]
}
sb := strings.Builder{}
sb.Grow(len(b))
for _, c := range b {
if c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c <= 0x7e) {
sb.WriteByte(c)
continue
}
if c >= 0x80 {
// Pass through; browser will render as UTF-8 if valid.
sb.WriteByte(c)
continue
}
// Drop other controls.
}
return sb.String()
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
// scanGIFMeta walks a GIF file and surfaces every extension block that
// can carry human-readable data: Comment (0x21 0xFE), Plain Text
// (0x21 0x01), and Application (0x21 0xFF — XMP, Netscape loop, etc.).
func scanGIFMeta(d *document.Document) ([]MetaEntry, error) {
size := d.Size()
out := []MetaEntry{}
// Skip header (6) + logical screen descriptor (7 fixed) and the
// optional global colour table.
hdr, err := d.Range(0, 13)
if err != nil {
return nil, err
}
off := int64(13)
packed := hdr[10]
if packed&0x80 != 0 {
gctSize := int64(3) * (int64(1) << uint((packed&0x07)+1))
off += gctSize
}
for off < size {
b, err := d.Range(off, 1)
if err != nil {
break
}
switch b[0] {
case 0x3b: // trailer
return out, nil
case 0x2c: // Image Descriptor: skip descriptor + optional LCT + LZW data
next, ok := skipGIFImage(d, off)
if !ok {
return out, nil
}
off = next
case 0x21: // Extension Introducer
if off+2 > size {
return out, nil
}
lbl, _ := d.Range(off+1, 1)
start := off
payload, next, ok := readGIFSubBlocks(d, off+2)
if !ok {
return out, nil
}
switch lbl[0] {
case 0xfe:
out = append(out, MetaEntry{
Offset: start, Length: next - start,
Container: "gif", Kind: "Comment",
Value: safeText(payload),
})
case 0x01:
// Plain Text: first 12 bytes are text-grid parameters.
text := payload
if len(text) > 12 {
text = text[12:]
} else {
text = nil
}
out = append(out, MetaEntry{
Offset: start, Length: next - start,
Container: "gif", Kind: "PlainText",
Value: safeText(text),
})
case 0xff:
// Application Extension: first sub-block is an 11-byte
// identifier like "NETSCAPE2.0" or "XMP DataXMP".
if len(payload) >= 11 {
appID := string(bytes.TrimRight(payload[:11], "\x00 "))
rest := payload[11:]
out = append(out, MetaEntry{
Offset: start, Length: next - start,
Container: "gif", Kind: "Application",
Keyword: appID,
Value: safeText(rest),
})
} else {
out = append(out, MetaEntry{
Offset: start, Length: next - start,
Container: "gif", Kind: "Application",
Value: safeText(payload),
})
}
default:
// Graphic control (0xf9) and others — skip.
}
off = next
default:
// Unknown byte; bail.
return out, nil
}
}
return out, nil
}
// readGIFSubBlocks reads a chain of GIF sub-blocks: each starts with a
// length byte (0 terminates). Returns (concatenated payload, offset of
// next byte after terminator, ok).
func readGIFSubBlocks(d *document.Document, off int64) ([]byte, int64, bool) {
size := d.Size()
out := []byte{}
for off < size {
ln, err := d.Range(off, 1)
if err != nil {
return nil, off, false
}
n := int64(ln[0])
off++
if n == 0 {
return out, off, true
}
if off+n > size {
return nil, off, false
}
block, err := d.Range(off, n)
if err != nil {
return nil, off, false
}
out = append(out, block...)
off += n
// Hard cap to prevent OOM on malformed input.
if len(out) > 16*1024*1024 {
return out, off, true
}
}
return nil, off, false
}
// skipGIFImage walks an Image Descriptor block starting at off (the 0x2c
// byte), skipping the descriptor, any local colour table, the LZW
// minimum-code-size byte and the image data sub-blocks. Returns the
// offset of the next block, or (0, false) on truncation.
func skipGIFImage(d *document.Document, off int64) (int64, bool) {
size := d.Size()
if off+10 > size {
return 0, false
}
desc, err := d.Range(off, 10)
if err != nil {
return 0, false
}
packed := desc[9]
off += 10
if packed&0x80 != 0 {
lctSize := int64(3) * (int64(1) << uint((packed&0x07)+1))
off += lctSize
}
// LZW minimum code size byte, then sub-blocks.
if off+1 > size {
return 0, false
}
off++
_, next, ok := readGIFSubBlocks(d, off)
if !ok {
return 0, false
}
return next, true
}