commit c3fcbb6616ca0a49d3a17091bb4ecb5d8ea39a5e Author: Mattia Mascarello Date: Thu May 22 17:22:27 2025 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..b79f43c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Live Steganography Experiments + +This project explores the concept of serving live image requests while embedding encrypted metadata into images using real-time steganography. + +## Core Concept + +The server begins by caching all images into memory (if configured to do so). When a client requests an image: + +1. The server intercepts the request. +2. It embeds encrypted metadata — including the client’s IP address, timestamp, and headers — directly into the image pixels using LSB (Least Significant Bit) encoding. +3. The modified image is returned to the client in real time. +4. If the image is later disseminated (and remains uncompressed), it can be traced back to the original request. + +Key properties of the system: + +- **Invisible**: Embedding is imperceptible to the human eye. +- **Encrypted**: Metadata is secured with AES-GCM; only those with the key can decrypt it. +- **Stateless**: No logs are stored on the server; the information is embedded within the image itself. + +## Use Cases + +- Watermarking downloaded content per user +- Forensic tracking and auditing of media distribution + +## Limitations + +- For large images, encoding can introduce noticeable latency. +- Embedded images are generated per request, which limits caching efficiency. + +## Components + +- `stego-server/`: HTTP server that encodes client metadata into images on-the-fly. +- `stego-client/`: CLI tool to decode and decrypt embedded metadata from images. + +## Disclaimer + +Do not use this technology in ways that violate laws, privacy regulations, or platform terms of service. diff --git a/stego-client/config.yaml b/stego-client/config.yaml new file mode 100644 index 0000000..973a70e --- /dev/null +++ b/stego-client/config.yaml @@ -0,0 +1,3 @@ +security: + # Generate with: openssl rand -base64 32 + aes_key: "mBH9cMfhD939xPiQz1mnDvECeUu8ydrVEg1YptjTzH4=" \ No newline at end of file diff --git a/stego-client/decode.go b/stego-client/decode.go new file mode 100644 index 0000000..f00fa9f --- /dev/null +++ b/stego-client/decode.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "fmt" + "image" + "log" + "os" + + "github.com/auyer/steganography" + "github.com/chai2010/webp" + "gopkg.in/yaml.v3" +) + +type DecodeConfig struct { + Security struct { + AESKey string `yaml:"aes_key"` // Base64 encoded 32-byte key + } `yaml:"security"` +} + +var ( + aesKey []byte + cfg DecodeConfig +) + +func loadDecodeConfig(filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("config file read error: %w", err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("config parse error: %w", err) + } + return nil +} + +func decryptData(ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} + +func decodeImage(file *os.File) (image.Image, error) { + reader := bufio.NewReader(file) + + // Try to decode with image.Decode first + img, _, err := image.Decode(reader) + if err == nil { + return img, nil + } + + // Try fallback: WebP + // Reset file offset to 0 + _, _ = file.Seek(0, 0) + img, err = webp.Decode(file) + if err != nil { + return nil, fmt.Errorf("unsupported image format or decode failure: %w", err) + } + return img, nil +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go run decode.go ") + return + } + + if err := loadDecodeConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + filePath := os.Args[1] + + inFile, err := os.Open(filePath) + if err != nil { + fmt.Printf("Failed to open file: %v\n", err) + return + } + defer inFile.Close() + + // Initialize AES key + if cfg.Security.AESKey != "" { + aesKey, err = base64.StdEncoding.DecodeString(cfg.Security.AESKey) + if err != nil { + log.Fatalf("Invalid AES key in config: %v", err) + } + } else { + log.Fatal("AES key not provided in config.") + } + + img, err := decodeImage(inFile) + if err != nil { + fmt.Printf("Failed to decode image: %v\n", err) + return + } + + size := steganography.GetMessageSizeFromImage(img) + message := steganography.Decode(size, img) + if message == nil { + fmt.Println("No message found in the image.") + return + } + + decryptedMessage, err := decryptData(message) + if err != nil { + fmt.Printf("Failed to decrypt message: %v\n", err) + return + } + + fmt.Printf("Decrypted message: %s\n", decryptedMessage) +} diff --git a/stego-client/go.mod b/stego-client/go.mod new file mode 100644 index 0000000..bbeb2f4 --- /dev/null +++ b/stego-client/go.mod @@ -0,0 +1,9 @@ +module stego-client + +go 1.21.9 + +require ( + github.com/auyer/steganography v1.0.3 + github.com/chai2010/webp v1.4.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/stego-client/go.sum b/stego-client/go.sum new file mode 100644 index 0000000..3cfe9df --- /dev/null +++ b/stego-client/go.sum @@ -0,0 +1,8 @@ +github.com/auyer/steganography v1.0.3 h1:NAFie4xVYY/LbYLkYCr0CJyyG/7yIXwC+yEC6H2jiSA= +github.com/auyer/steganography v1.0.3/go.mod h1:Q2qN+f1ixaXnKTCT4xkSDCZ/5NiOpUeTgOCLwQdJD+A= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/stego-client/stego-client b/stego-client/stego-client new file mode 100755 index 0000000..08020df Binary files /dev/null and b/stego-client/stego-client differ diff --git a/stego-server/config.yaml b/stego-server/config.yaml new file mode 100644 index 0000000..efdf9fc --- /dev/null +++ b/stego-server/config.yaml @@ -0,0 +1,10 @@ +server: + port: "8219" + image_dir: "../images/dist" + base_path: "/images/dist" + cache_images: true + + +security: + # Generate with: openssl rand -base64 32 + aes_key: "mBH9cMfhD939xPiQz1mnDvECeUu8ydrVEg1YptjTzH4=" \ No newline at end of file diff --git a/stego-server/go.mod b/stego-server/go.mod new file mode 100644 index 0000000..32e939d --- /dev/null +++ b/stego-server/go.mod @@ -0,0 +1,11 @@ +module stego-server + +go 1.23.0 + +toolchain go1.23.9 + +require ( + github.com/auyer/steganography v1.0.3 // indirect + golang.org/x/image v0.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/stego-server/go.sum b/stego-server/go.sum new file mode 100644 index 0000000..94d327a --- /dev/null +++ b/stego-server/go.sum @@ -0,0 +1,7 @@ +github.com/auyer/steganography v1.0.3 h1:NAFie4xVYY/LbYLkYCr0CJyyG/7yIXwC+yEC6H2jiSA= +github.com/auyer/steganography v1.0.3/go.mod h1:Q2qN+f1ixaXnKTCT4xkSDCZ/5NiOpUeTgOCLwQdJD+A= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/stego-server/main.go b/stego-server/main.go new file mode 100644 index 0000000..a419246 --- /dev/null +++ b/stego-server/main.go @@ -0,0 +1,349 @@ +package main + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/auyer/steganography" + "golang.org/x/image/webp" + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + Port string `yaml:"port"` + ImageDir string `yaml:"image_dir"` + BasePath string `yaml:"base_path"` + CacheImages bool `yaml:"cache_images"` + } `yaml:"server"` + Security struct { + AESKey string `yaml:"aes_key"` // Base64 encoded 32-byte key + } `yaml:"security"` +} + +func loadConfig(filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("config file read error: %w", err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("config parse error: %w", err) + } + + // Set defaults if not specified + if cfg.Server.Port == "" { + cfg.Server.Port = "8219" + } + if cfg.Server.ImageDir == "" { + cfg.Server.ImageDir = "./images" + } + + if cfg.Server.BasePath == "" { + cfg.Server.BasePath = "/" + } + + return nil +} + +var ( + decodedImageCache sync.Map // path -> *cachedImage + aesKey []byte + cfg Config +) + +type cachedImage struct { + img *image.NRGBA + mimeType string + maxPayloadSize uint32 + size uint32 +} + +func init() { + if err := loadConfig("config.yaml"); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + if cfg.Security.AESKey != "" { + var err error + aesKey, err = base64.StdEncoding.DecodeString(cfg.Security.AESKey) + if err != nil { + log.Fatalf("Invalid AES key in config: %v", err) + } + } else { + aesKey = make([]byte, 32) + if _, err := rand.Read(aesKey); err != nil { + log.Fatalf("Failed to generate AES key: %v", err) + } + log.Printf("Generated new AES key (base64): %s", base64.StdEncoding.EncodeToString(aesKey)) + } + + if cfg.Server.CacheImages { + if err := loadImages(); err != nil { + log.Printf("Warning: could not preload images: %v", err) + } + } +} + +func loadImages() error { + return filepath.WalkDir(cfg.Server.ImageDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + data, err := os.ReadFile(path) + size := uint32(len(data)) + if err != nil { + return err + } + // Preload decoded image + img, mimeType, err := decodeImageData(data, ext) + if err != nil { + return fmt.Errorf("error decoding %s: %v", path, err) + } + + nrgba := image.NewNRGBA(img.Bounds()) + draw.Draw(nrgba, nrgba.Bounds(), img, img.Bounds().Min, draw.Src) + + maxSize := steganography.MaxEncodeSize(nrgba) + maxPlaintext, err := maxPlaintextSize(maxSize) + if err != nil { + log.Printf("Skipping %s: %v", path, err) + return nil + } + + decodedImageCache.Store(path, &cachedImage{ + img: nrgba, + mimeType: mimeType, + maxPayloadSize: maxPlaintext, + size: size, + }) + log.Printf("Cached decoded image: %s", path) + } + return nil + }) +} + +func decodeImageData(data []byte, ext string) (image.Image, string, error) { + switch ext { + case ".jpg", ".jpeg": + img, err := jpeg.Decode(bytes.NewReader(data)) + return img, "image/jpeg", err + case ".png": + img, err := png.Decode(bytes.NewReader(data)) + return img, "image/png", err + case ".webp": + img, err := webp.Decode(bytes.NewReader(data)) + return img, "image/webp", err + default: + return nil, "", fmt.Errorf("unsupported format: %s", ext) + } +} + +func encryptData(plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func maxPlaintextSize(totalOutputSize uint32) (uint32, error) { + block, err := aes.NewCipher(aesKey) + if err != nil { + return 0, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return 0, err + } + + overhead := uint32(gcm.NonceSize() + gcm.Overhead()) + if totalOutputSize < overhead { + return 0, fmt.Errorf("output size too small") + } + + return totalOutputSize - overhead, nil +} + +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func embedData(img *image.NRGBA, data []byte) ([]byte, error) { + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + if buf.Cap() <= 2<<20 { // 2MB cap + bufferPool.Put(buf) + } + }() + + err := steganography.Encode(buf, img, data) + if err != nil { + return nil, fmt.Errorf("failed to encode data into image: %w", err) + } + + result := make([]byte, buf.Len()) + copy(result, buf.Bytes()) + return result, nil +} + +func getClientInfo(r *http.Request) string { + ip := getClientIP(r) + var headers strings.Builder + for name, values := range r.Header { + for _, value := range values { + headers.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + } + } + return fmt.Sprintf("%s\n%s\n%s", time.Now().UTC().Format(time.RFC3339), ip, headers.String()) +} + +func getClientIP(r *http.Request) string { + headers := []string{"X-Forwarded-For"} + for _, h := range headers { + if ip := r.Header.Get(h); ip != "" { + parts := strings.Split(ip, ",") + clientIP := strings.TrimSpace(parts[0]) + if len(parts) > 1 && isTrustedProxy(clientIP) { + return strings.TrimSpace(parts[1]) + } + return clientIP + } + } + + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + return ip +} + +func isTrustedProxy(ip string) bool { + return true // Implement actual IP validation if needed +} + +func imageHandler(w http.ResponseWriter, r *http.Request) { + relativePath := strings.TrimPrefix(r.URL.Path, cfg.Server.BasePath) + requestedPath := filepath.Clean(strings.TrimPrefix(relativePath, "/")) + imagePath := filepath.Join(cfg.Server.ImageDir, requestedPath) + + log.Printf("Requested image path: %s", imagePath) + + if !strings.HasPrefix(imagePath, filepath.Clean(cfg.Server.ImageDir)+string(os.PathSeparator)) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + if _, err := os.Stat(imagePath); errors.Is(err, os.ErrNotExist) { + http.NotFound(w, r) + return + } + + var ci *cachedImage + if cached, ok := decodedImageCache.Load(imagePath); ok { + ci = cached.(*cachedImage) + } else { + data, err := os.ReadFile(imagePath) + if err != nil { + http.Error(w, "File read error", http.StatusInternalServerError) + return + } + + ext := strings.ToLower(filepath.Ext(imagePath)) + img, mimeType, err := decodeImageData(data, ext) + if err != nil { + http.ServeFile(w, r, imagePath) + return + } + + nrgba := image.NewNRGBA(img.Bounds()) + draw.Draw(nrgba, nrgba.Bounds(), img, img.Bounds().Min, draw.Src) + + maxSize := steganography.MaxEncodeSize(nrgba) + maxPlaintext, err := maxPlaintextSize(maxSize) + if err != nil { + http.ServeFile(w, r, imagePath) + return + } + + ci = &cachedImage{ + img: nrgba, + mimeType: mimeType, + maxPayloadSize: maxPlaintext, + size: uint32(len(data)), + } + decodedImageCache.Store(imagePath, ci) + } + + metadata := getClientInfo(r) + if len(metadata) > int(ci.maxPayloadSize) { + metadata = metadata[:ci.maxPayloadSize] + } + + encryptedData, err := encryptData([]byte(metadata)) + if err != nil { + http.ServeFile(w, r, imagePath) + return + } + + modifiedImage, err := embedData(ci.img, encryptedData) + if err != nil { + http.ServeFile(w, r, imagePath) + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", fmt.Sprint(len(modifiedImage))) + w.Write(modifiedImage) +} + +func main() { + if _, err := os.Stat(cfg.Server.ImageDir); os.IsNotExist(err) { + log.Fatalf("Image directory does not exist: %s", cfg.Server.ImageDir) + } + + basePath := strings.TrimSuffix(cfg.Server.BasePath, "/") + http.HandleFunc(basePath+"/", imageHandler) + + log.Printf("Starting server on :%s", cfg.Server.Port) + log.Fatal(http.ListenAndServe(":"+cfg.Server.Port, nil)) +} diff --git a/stego-server/stego-server b/stego-server/stego-server new file mode 100755 index 0000000..5a456bd Binary files /dev/null and b/stego-server/stego-server differ