v3 candidate
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// Package stego provides practical steganography helpers:
|
||||
//
|
||||
// - trailer detection (bytes after a declared end) for PNG, JPEG,
|
||||
// GIF, BMP, WAV and RIFF/WebP containers
|
||||
// - multi-trailer surfacing: identifies any embedded container
|
||||
// (PNG/JPEG/ZIP/GIF/PDF/RAR/7z) inside the trailer region
|
||||
// - LSB extract/inject over an arbitrary byte buffer
|
||||
// - PNG IDAT, BMP pixel-array and WAV sample-data resolvers so
|
||||
// callers can point LSB at the correct carrier region
|
||||
// - textual-metadata extraction for PNG, JPEG, GIF (see metadata.go)
|
||||
package stego
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trailer detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EmbeddedHit describes a recognised container signature found inside the
|
||||
// trailer region (the bytes after a file's declared end).
|
||||
type EmbeddedHit struct {
|
||||
Offset int64 `json:"offset"` // absolute offset within the outer file
|
||||
Kind string `json:"kind"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyseAppended inspects the end of the file for plausible trailers: data
|
||||
// after the declared end of a known container. The result also lists any
|
||||
// nested container magic bytes discovered inside the trailer region.
|
||||
func AnalyseAppended(d *document.Document) (map[string]any, error) {
|
||||
size := d.Size()
|
||||
head, _ := d.Range(0, 16)
|
||||
result := map[string]any{
|
||||
"fileSize": size,
|
||||
"declaredEnd": int64(-1),
|
||||
"trailerSize": int64(0),
|
||||
"container": "",
|
||||
"note": "no recognised container",
|
||||
"embedded": []EmbeddedHit{},
|
||||
}
|
||||
|
||||
end := int64(-1)
|
||||
container := ""
|
||||
|
||||
switch {
|
||||
case len(head) >= 8 && string(head[:8]) == "\x89PNG\r\n\x1a\n":
|
||||
container = "png"
|
||||
end = findPNGEnd(d)
|
||||
case len(head) >= 3 && head[0] == 0xff && head[1] == 0xd8 && head[2] == 0xff:
|
||||
container = "jpeg"
|
||||
end = findJPEGEnd(d)
|
||||
case len(head) >= 6 && (string(head[:6]) == "GIF87a" || string(head[:6]) == "GIF89a"):
|
||||
container = "gif"
|
||||
end = findGIFEnd(d)
|
||||
case len(head) >= 2 && head[0] == 'B' && head[1] == 'M':
|
||||
container = "bmp"
|
||||
end = findBMPEnd(d)
|
||||
case len(head) >= 12 && string(head[:4]) == "RIFF":
|
||||
sub := string(head[8:12])
|
||||
if sub == "WAVE" {
|
||||
container = "wav"
|
||||
} else if sub == "WEBP" {
|
||||
container = "webp"
|
||||
} else {
|
||||
container = "riff"
|
||||
}
|
||||
end = findRIFFEnd(d)
|
||||
}
|
||||
|
||||
if container != "" {
|
||||
result["container"] = container
|
||||
}
|
||||
|
||||
if end > 0 && end <= size {
|
||||
result["declaredEnd"] = end
|
||||
if end < size {
|
||||
trailer := size - end
|
||||
result["trailerSize"] = trailer
|
||||
result["note"] = "bytes after " + container + " declared end"
|
||||
if trailer > 0 {
|
||||
// Cap scanning the trailer at 16 MiB — otherwise we might
|
||||
// pull a 2-GiB tail into memory on hostile inputs.
|
||||
scanLen := trailer
|
||||
if scanLen > 16*1024*1024 {
|
||||
scanLen = 16 * 1024 * 1024
|
||||
}
|
||||
tail, _ := d.Range(end, scanLen)
|
||||
result["embedded"] = scanEmbedded(tail, end)
|
||||
}
|
||||
} else {
|
||||
result["note"] = "no trailing bytes"
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExtractTrailer returns the bytes after a container's declared end.
|
||||
func ExtractTrailer(d *document.Document) ([]byte, error) {
|
||||
a, err := AnalyseAppended(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end, _ := a["declaredEnd"].(int64)
|
||||
ts, _ := a["trailerSize"].(int64)
|
||||
if end < 0 || ts <= 0 {
|
||||
return nil, errors.New("no trailer found")
|
||||
}
|
||||
return d.Range(end, ts)
|
||||
}
|
||||
|
||||
func findPNGEnd(d *document.Document) int64 {
|
||||
size := d.Size()
|
||||
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]))
|
||||
if ln < 0 || off+12+ln > size {
|
||||
break
|
||||
}
|
||||
typ := string(hdr[4:8])
|
||||
off += 12 + ln
|
||||
if typ == "IEND" {
|
||||
return off
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findJPEGEnd(d *document.Document) int64 {
|
||||
size := d.Size()
|
||||
tailLen := int64(128 * 1024)
|
||||
if tailLen > size {
|
||||
tailLen = size
|
||||
}
|
||||
tail, _ := d.Range(size-tailLen, tailLen)
|
||||
for i := len(tail) - 2; i >= 0; i-- {
|
||||
if tail[i] == 0xff && tail[i+1] == 0xd9 {
|
||||
return size - tailLen + int64(i+2)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findGIFEnd(d *document.Document) int64 {
|
||||
size := d.Size()
|
||||
// The GIF terminator 0x3B is a single byte that follows the final
|
||||
// data block of the final image. Walking the container correctly is
|
||||
// overkill; scan from the tail and accept the last 0x3B that is
|
||||
// preceded by a block terminator (0x00) or a plausible frame end.
|
||||
tailLen := int64(32 * 1024)
|
||||
if tailLen > size {
|
||||
tailLen = size
|
||||
}
|
||||
tail, _ := d.Range(size-tailLen, tailLen)
|
||||
for i := len(tail) - 1; i >= 0; i-- {
|
||||
if tail[i] == 0x3b {
|
||||
return size - tailLen + int64(i+1)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findBMPEnd(d *document.Document) int64 {
|
||||
size := d.Size()
|
||||
hdr, err := d.Range(0, 14)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
bfSize := int64(binary.LittleEndian.Uint32(hdr[2:6]))
|
||||
if bfSize > 0 && bfSize <= size {
|
||||
return bfSize
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findRIFFEnd(d *document.Document) int64 {
|
||||
size := d.Size()
|
||||
hdr, err := d.Range(0, 8)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
// RIFF chunk size (little-endian) is the size of the rest of the
|
||||
// file *after* the first 8 bytes, so declared end = 8 + riffSize.
|
||||
riffSize := int64(binary.LittleEndian.Uint32(hdr[4:8]))
|
||||
end := 8 + riffSize
|
||||
if end > 0 && end <= size {
|
||||
// RIFF pads to even alignment.
|
||||
if end%2 == 1 && end+1 <= size {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// scanEmbedded walks a trailer buffer for known container magic bytes.
|
||||
func scanEmbedded(buf []byte, base int64) []EmbeddedHit {
|
||||
hits := []EmbeddedHit{}
|
||||
type sig struct {
|
||||
bytes []byte
|
||||
kind string
|
||||
}
|
||||
sigs := []sig{
|
||||
{[]byte("\x89PNG\r\n\x1a\n"), "png"},
|
||||
{[]byte{0xff, 0xd8, 0xff}, "jpeg"},
|
||||
{[]byte("GIF87a"), "gif"},
|
||||
{[]byte("GIF89a"), "gif"},
|
||||
{[]byte("PK\x03\x04"), "zip"},
|
||||
{[]byte("PK\x05\x06"), "zip-eocd"},
|
||||
{[]byte("Rar!\x1a\x07\x00"), "rar"},
|
||||
{[]byte("Rar!\x1a\x07\x01\x00"), "rar5"},
|
||||
{[]byte("7z\xbc\xaf\x27\x1c"), "7z"},
|
||||
{[]byte("%PDF-"), "pdf"},
|
||||
{[]byte("\x1f\x8b\x08"), "gzip"},
|
||||
{[]byte{0xfd, '7', 'z', 'X', 'Z', 0x00}, "xz"},
|
||||
{[]byte("BZh"), "bzip2"},
|
||||
{[]byte{0x28, 0xb5, 0x2f, 0xfd}, "zstd"},
|
||||
{[]byte{0x7f, 'E', 'L', 'F'}, "elf"},
|
||||
{[]byte("MZ"), "mz/pe"},
|
||||
{[]byte("ustar"), "tar"},
|
||||
{[]byte("OggS"), "ogg"},
|
||||
{[]byte("ID3"), "id3/mp3"},
|
||||
{[]byte("fLaC"), "flac"},
|
||||
{[]byte("\x1aEߣ"), "matroska/webm"},
|
||||
{[]byte("RIFF"), "riff"},
|
||||
}
|
||||
for _, s := range sigs {
|
||||
if len(s.bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
start := 0
|
||||
for {
|
||||
i := indexOf(buf[start:], s.bytes)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
hits = append(hits, EmbeddedHit{
|
||||
Offset: base + int64(start+i),
|
||||
Kind: s.kind,
|
||||
})
|
||||
// A plain "MZ" or "RIFF" hit without more context is noisy;
|
||||
// the caller can filter on kind. Keep walking past this hit.
|
||||
start += i + len(s.bytes)
|
||||
// Bound total hits per signature to keep UI manageable.
|
||||
if len(hits) > 256 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(hits) > 256 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func indexOf(hay, needle []byte) int {
|
||||
if len(needle) == 0 || len(hay) < len(needle) {
|
||||
return -1
|
||||
}
|
||||
first := needle[0]
|
||||
last := len(hay) - len(needle)
|
||||
for i := 0; i <= last; i++ {
|
||||
if hay[i] != first {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for j := 1; j < len(needle); j++ {
|
||||
if hay[i+j] != needle[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carrier resolvers (PNG IDAT, BMP pixels, WAV samples)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PNGImageData concatenates all IDAT chunks' payload bytes. Callers can then
|
||||
// feed the result into LSBExtract/LSBInject. The returned buffer is the raw
|
||||
// (still-deflated) IDAT stream — it is the conventional LSB carrier for
|
||||
// tools like zsteg, not the decoded pixel array.
|
||||
func PNGImageData(d *document.Document) ([]byte, error) {
|
||||
size := d.Size()
|
||||
off := int64(8)
|
||||
buf := []byte{}
|
||||
for off+12 <= size {
|
||||
hdr, err := d.Range(off, 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ln := int64(binary.BigEndian.Uint32(hdr[0:4]))
|
||||
typ := string(hdr[4:8])
|
||||
if typ == "IDAT" {
|
||||
chunk, err := d.Range(off+8, ln)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
}
|
||||
off += 12 + ln
|
||||
if typ == "IEND" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
return nil, errors.New("no IDAT data")
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// BMPCarrier describes a resolved BMP pixel array that can be used as an
|
||||
// LSB carrier.
|
||||
type BMPCarrier struct {
|
||||
Data []byte `json:"data"`
|
||||
Offset int64 `json:"offset"` // offset of pixel array in the file
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
BitsPerPixel uint16 `json:"bitsPerPixel"`
|
||||
RowStride int `json:"rowStride"`
|
||||
}
|
||||
|
||||
// BMPPixels locates and returns the BMP pixel array. Supports 8/24/32 bpp
|
||||
// uncompressed BI_RGB images, which are by far the most common carriers.
|
||||
func BMPPixels(d *document.Document) (*BMPCarrier, error) {
|
||||
size := d.Size()
|
||||
if size < 54 {
|
||||
return nil, errors.New("not a BMP (too small)")
|
||||
}
|
||||
hdr, err := d.Range(0, 54)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 'B' || hdr[1] != 'M' {
|
||||
return nil, errors.New("not a BMP")
|
||||
}
|
||||
bfOffBits := int64(binary.LittleEndian.Uint32(hdr[10:14]))
|
||||
dibSize := binary.LittleEndian.Uint32(hdr[14:18])
|
||||
// BITMAPINFOHEADER (40), BITMAPV4HEADER (108), BITMAPV5HEADER (124),
|
||||
// OS/2 BITMAPCOREHEADER (12). Only handle the modern family.
|
||||
if dibSize < 40 {
|
||||
return nil, errors.New("BMP: unsupported DIB header (BITMAPCOREHEADER)")
|
||||
}
|
||||
w := int32(binary.LittleEndian.Uint32(hdr[18:22]))
|
||||
h := int32(binary.LittleEndian.Uint32(hdr[22:26]))
|
||||
bpp := binary.LittleEndian.Uint16(hdr[28:30])
|
||||
compression := binary.LittleEndian.Uint32(hdr[30:34])
|
||||
if compression != 0 && compression != 3 { // BI_RGB / BI_BITFIELDS
|
||||
return nil, errors.New("BMP: compressed images not supported")
|
||||
}
|
||||
if bpp != 8 && bpp != 24 && bpp != 32 {
|
||||
return nil, errors.New("BMP: only 8/24/32 bpp supported")
|
||||
}
|
||||
absH := h
|
||||
if absH < 0 {
|
||||
absH = -absH
|
||||
}
|
||||
rowStride := ((int(w)*int(bpp) + 31) / 32) * 4
|
||||
total := int64(rowStride) * int64(absH)
|
||||
if bfOffBits < 0 || bfOffBits+total > size {
|
||||
return nil, errors.New("BMP: pixel array out of range")
|
||||
}
|
||||
data, err := d.Range(bfOffBits, total)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BMPCarrier{
|
||||
Data: data, Offset: bfOffBits,
|
||||
Width: w, Height: h, BitsPerPixel: bpp, RowStride: rowStride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WAVCarrier describes a resolved WAV "data" chunk that can be used as an
|
||||
// LSB carrier.
|
||||
type WAVCarrier struct {
|
||||
Data []byte `json:"data"`
|
||||
Offset int64 `json:"offset"` // offset of sample data
|
||||
SampleRate uint32 `json:"sampleRate"`
|
||||
NumChannels uint16 `json:"numChannels"`
|
||||
BitsPerSample uint16 `json:"bitsPerSample"`
|
||||
AudioFormat uint16 `json:"audioFormat"`
|
||||
}
|
||||
|
||||
// WAVSamples locates the "data" sub-chunk of a RIFF/WAVE file and returns
|
||||
// its payload. Only PCM (format 1) is usable as a straightforward LSB
|
||||
// carrier; other formats still return bytes but are flagged in the caller.
|
||||
func WAVSamples(d *document.Document) (*WAVCarrier, error) {
|
||||
size := d.Size()
|
||||
if size < 44 {
|
||||
return nil, errors.New("not a WAV (too small)")
|
||||
}
|
||||
hdr, err := d.Range(0, 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(hdr[:4]) != "RIFF" || string(hdr[8:12]) != "WAVE" {
|
||||
return nil, errors.New("not a RIFF/WAVE file")
|
||||
}
|
||||
out := &WAVCarrier{}
|
||||
off := int64(12)
|
||||
for off+8 <= size {
|
||||
ch, err := d.Range(off, 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := string(ch[:4])
|
||||
cs := int64(binary.LittleEndian.Uint32(ch[4:8]))
|
||||
if cs < 0 || off+8+cs > size {
|
||||
break
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
body, err := d.Range(off+8, cs)
|
||||
if err == nil && len(body) >= 16 {
|
||||
out.AudioFormat = binary.LittleEndian.Uint16(body[0:2])
|
||||
out.NumChannels = binary.LittleEndian.Uint16(body[2:4])
|
||||
out.SampleRate = binary.LittleEndian.Uint32(body[4:8])
|
||||
out.BitsPerSample = binary.LittleEndian.Uint16(body[14:16])
|
||||
}
|
||||
case "data":
|
||||
body, err := d.Range(off+8, cs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Data = body
|
||||
out.Offset = off + 8
|
||||
return out, nil
|
||||
}
|
||||
// Pad to even.
|
||||
step := 8 + cs
|
||||
if step%2 == 1 {
|
||||
step++
|
||||
}
|
||||
off += step
|
||||
}
|
||||
return nil, errors.New("WAV: data chunk not found")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LSB codec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LSBExtract returns a bitstring-as-bytes built from the low bit of each
|
||||
// byte in the selected range. Useful for exploring LSB payloads over raw
|
||||
// buffers or specific regions.
|
||||
func LSBExtract(buf []byte, bitsPerByte int) []byte {
|
||||
if bitsPerByte <= 0 || bitsPerByte > 8 {
|
||||
bitsPerByte = 1
|
||||
}
|
||||
total := len(buf) * bitsPerByte
|
||||
out := make([]byte, total/8)
|
||||
bitIdx := 0
|
||||
for _, b := range buf {
|
||||
for i := 0; i < bitsPerByte; i++ {
|
||||
bit := (b >> uint(i)) & 1
|
||||
if bit == 1 {
|
||||
out[bitIdx/8] |= 1 << uint(7-(bitIdx%8))
|
||||
}
|
||||
bitIdx++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LSBInject replaces low bits in buf with bits from payload. Returns the
|
||||
// modified buffer. When capacity is exceeded, injection stops.
|
||||
func LSBInject(buf, payload []byte, bitsPerByte int) []byte {
|
||||
if bitsPerByte <= 0 || bitsPerByte > 8 {
|
||||
bitsPerByte = 1
|
||||
}
|
||||
out := append([]byte(nil), buf...)
|
||||
mask := byte(1<<uint(bitsPerByte) - 1)
|
||||
bitIdx := 0
|
||||
total := len(payload) * 8
|
||||
for i := 0; i < len(out) && bitIdx < total; i++ {
|
||||
chunk := byte(0)
|
||||
for b := 0; b < bitsPerByte && bitIdx < total; b++ {
|
||||
bit := (payload[bitIdx/8] >> uint(7-(bitIdx%8))) & 1
|
||||
if bit != 0 {
|
||||
chunk |= 1 << uint(b)
|
||||
}
|
||||
bitIdx++
|
||||
}
|
||||
out[i] = (out[i] &^ mask) | (chunk & mask)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user