package frame import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "errors" "fmt" "image" "io" "sync" "gocv.io/x/gocv" "golang.org/x/crypto/pbkdf2" ) const ( saltSize = 16 keySize = 32 nonceSize = 12 // MaxPBKDF2Iter bounds the iteration count a Clip may claim on the wire, // so a malicious sender cannot force a receiver into an arbitrarily long // key-derivation CPU burn. Kept generous above every default and // configured value, but low enough that a rejected clip costs at most a // few hundred milliseconds of KDF work. MaxPBKDF2Iter = 1_000_000 // encryptedOverhead is how many bytes Frame.Encrypt prepends to a // plaintext frame: 16 salt + 12 nonce + 16 GCM tag. encryptedOverhead = saltSize + nonceSize + 16 // clipEncryptedOverhead is the per-frame overhead added by Clip.Encrypt: // the 16-byte salt lives on the Clip, so each frame gains only the // 12-byte nonce and 16-byte GCM tag. clipEncryptedOverhead = nonceSize + 16 ) // DefaultPBKDF2Iter is the PBKDF2 iteration count used for new clip and frame // encryption. Encrypt stores the count actually used on the Clip, so Decrypt // honors whatever the origin matched regardless of a node's local setting // (override per node with frame.SetDefaultPBKDF2Iter). var DefaultPBKDF2Iter = 600_000 // SetDefaultPBKDF2Iter overrides the default PBKDF2 iteration count used to // encrypt new clips and decrypt foreign clips that do not carry their own // count. func SetDefaultPBKDF2Iter(n int) { if n < 1 { n = 1 } if n > MaxPBKDF2Iter { n = MaxPBKDF2Iter } DefaultPBKDF2Iter = n } // isCV8U reports whether a MatType is an 8-bit unsigned depth, the only depth // the pipeline understands: PixelBytes is counted as one byte per element, so // a 16-bit or float type with a matching byte count would be decoded as a // different size of matrix than the dimensions claim. func isCV8U(t gocv.MatType) bool { return int(t)&0x07 == 0 } // matTypeChannels recovers the channel count encoded in a MatType, so a clip // declaring Channels can be cross-checked against its declared type. func matTypeChannels(t gocv.MatType) int { return (int(t)>>3)&0x07 + 1 } type DetectionCollection map[string][]Detection type Detection struct { DetectionTitle string // Car, human, human torso, bear, cat, animal, etc... DetectionMajorVersion uint DetectionMinorVersion uint DetectionPostfix string // 2.3.4a4444248f DetectionRegion image.Rectangle DetectionCertainty float32 } // Stores single frame data type Frame struct { PixelBytes []byte Width, Height uint GocvImageType gocv.MatType Detections map[string][]Detection Channels int Guid []byte SourceData string // sourcedata is . Timestamp uint64 } func (f *Frame) ToMat() (gocv.Mat, error) { if f.Width == 0 || f.Height == 0 || f.Channels < 1 || f.Channels > 4 { return gocv.NewMat(), fmt.Errorf("frame: implausible dimensions %dx%d (%d channels)", f.Width, f.Height, f.Channels) } want := int(f.Width) * int(f.Height) * f.Channels if len(f.PixelBytes) != want { return gocv.NewMat(), fmt.Errorf("frame: %d pixel bytes, want %d for %dx%d x%d", len(f.PixelBytes), want, f.Width, f.Height, f.Channels) } mat, err := gocv.NewMatFromBytes(int(f.Height), int(f.Width), f.GocvImageType, f.PixelBytes) if err != nil { return gocv.NewMat(), err } return mat, nil } // Series of frames in a sequence from the same camera. // Most metadata should be identical // Frames should be in order as stored, so Timestamps[5] should be taken directly from PIxelMats[5] and Guids[5] type Clip struct { PixelMats [][]byte // First order is different frames, second order is pixel bytes from frames Width, Height uint Types gocv.MatType Detections []map[string][]Detection Comparisons []Comparison Channels int Guids [][]byte SourceData string Timestamps []uint64 Salt []byte PBKDF2Iter int // iteration count used by Encrypt, so a decrypting node can match it (0 = unknown) } func (clp *Clip) Sublimate() []Frame { frames := make([]Frame, len(clp.PixelMats)) for i := range clp.PixelMats { f := Frame{ PixelBytes: clp.PixelMats[i], Width: clp.Width, Height: clp.Height, GocvImageType: clp.Types, Detections: nil, Channels: clp.Channels, SourceData: clp.SourceData, } if i < len(clp.Detections) { f.Detections = clp.Detections[i] } if i < len(clp.Guids) { f.Guid = clp.Guids[i] } if i < len(clp.Timestamps) { f.Timestamp = clp.Timestamps[i] } frames[i] = f } return frames } // PixelMats, Guids, and Timestamps should all be the same len. func (clp *Clip) CheckLenCorrelations() (bool, error) { l1 := len(clp.PixelMats) l2 := len(clp.Guids) l3 := len(clp.Timestamps) switch { case l1 != l2: return false, fmt.Errorf("clip has %d pixel frames but %d guids", l1, l2) case l2 != l3: return false, fmt.Errorf("clip has %d guids but %d timestamps", l2, l3) case l1 != l3: return false, fmt.Errorf("clip has %d pixel frames but %d timestamps", l1, l3) default: return true, nil } } func deriveKey(passphrase string, salt []byte, iterations int) []byte { return pbkdf2.Key([]byte(passphrase), salt, iterations, keySize, sha256.New) } // deriverCache memoizes the last handful of PBKDF2 derivations keyed by // (salt, iterations), so a flood of repeated clips (replays or forged copies // that share a salt) cannot repeatedly re-run an expensive KDF to bog the // node down. It is bounded and safe for concurrent use. var deriverCache = struct { sync.Mutex entries map[string][]byte }{} func cachedDeriveKey(passphrase string, salt []byte, iterations int) []byte { cacheKey := fmt.Sprintf("%x/%d", salt, iterations) deriverCache.Lock() got, ok := deriverCache.entries[cacheKey] deriverCache.Unlock() if ok { return got } derived := deriveKey(passphrase, salt, iterations) deriverCache.Lock() if deriverCache.entries == nil { deriverCache.entries = make(map[string][]byte) } if len(deriverCache.entries) >= 16 { deriverCache.entries = make(map[string][]byte) } deriverCache.entries[cacheKey] = derived deriverCache.Unlock() return derived } // gcmEncrypt seals plaintext with fresh random nonce and returns // nonce||ciphertext (ciphertext includes the GCM tag). func gcmEncrypt(plaintext, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) 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 append(nonce, gcm.Seal(nil, nonce, plaintext, nil)...), nil } // gcmDecrypt opens data produced by gcmEncrypt with the same key. func gcmDecrypt(data, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } if len(data) < gcm.NonceSize() { return nil, errors.New("encrypted data too short") } nonce := data[:gcm.NonceSize()] return gcm.Open(nil, nonce, data[gcm.NonceSize():], nil) } // Encrypt encrypts PixelBytes using AES-256-GCM with a key derived from passphrase. func (f *Frame) Encrypt(passphrase string) error { if len(f.PixelBytes) == 0 { return nil } salt := make([]byte, saltSize) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return err } key := deriveKey(passphrase, salt, DefaultPBKDF2Iter) ciphertext, err := gcmEncrypt(f.PixelBytes, key) if err != nil { return err } f.PixelBytes = append(salt, ciphertext...) return nil } // Decrypt decrypts PixelBytes using AES-256-GCM with a key derived from passphrase. func (f *Frame) Decrypt(passphrase string) error { return f.DecryptWithIter(passphrase, DefaultPBKDF2Iter) } // DecryptWithIter is Decrypt with an explicit iteration count, used by the // legacy per-frame salted layout so a clip can honor the count its origin // used. func (f *Frame) DecryptWithIter(passphrase string, iterations int) error { if len(f.PixelBytes) == 0 { return nil } if len(f.PixelBytes) < saltSize+nonceSize+1 { return errors.New("encrypted data too short") } salt := f.PixelBytes[:saltSize] key := cachedDeriveKey(passphrase, salt, iterations) plaintext, err := gcmDecrypt(f.PixelBytes[saltSize:], key) if err != nil { return err } f.PixelBytes = plaintext return nil } // Encrypt seals every frame in the clip with a single PBKDF2 key derivation. // The shared salt is stored on the clip; each frame gets its own random nonce. // The iteration count used is recorded on the clip so any decrypting node can // match it even when its local default differs. Frames are left untouched when // their pixel buffer is empty. func (c *Clip) Encrypt(passphrase string) error { if len(c.PixelMats) == 0 { return nil } c.PBKDF2Iter = DefaultPBKDF2Iter salt := make([]byte, saltSize) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return err } key := deriveKey(passphrase, salt, c.PBKDF2Iter) var errs []error for i := range c.PixelMats { if len(c.PixelMats[i]) == 0 { continue } ct, err := gcmEncrypt(c.PixelMats[i], key) if err != nil { errs = append(errs, fmt.Errorf("frame %d: %w", i, err)) continue } c.PixelMats[i] = ct } c.Salt = salt return errors.Join(errs...) } // Decrypt opens every frame previously sealed by Clip.Encrypt, deriving the // key from the clip's stored salt. Clips without a clip-level salt // (per-frame salted layout from before the single-KDF change) are handled via // the per-frame fallback. func (c *Clip) Decrypt(passphrase string) error { if len(c.PixelMats) == 0 { return nil } if len(c.Salt) == saltSize { iterations := c.PBKDF2Iter if iterations < 1 || iterations > MaxPBKDF2Iter { iterations = DefaultPBKDF2Iter } key := cachedDeriveKey(passphrase, c.Salt, iterations) var errs []error for i := range c.PixelMats { if len(c.PixelMats[i]) == 0 { continue } plaintext, err := gcmDecrypt(c.PixelMats[i], key) if err != nil { errs = append(errs, fmt.Errorf("frame %d: %w", i, err)) continue } c.PixelMats[i] = plaintext } return errors.Join(errs...) } iterations := DefaultPBKDF2Iter if c.PBKDF2Iter >= 1 && c.PBKDF2Iter <= MaxPBKDF2Iter { iterations = c.PBKDF2Iter } var errs []error for i := range c.PixelMats { f := Frame{PixelBytes: c.PixelMats[i]} if err := f.DecryptWithIter(passphrase, iterations); err != nil { errs = append(errs, fmt.Errorf("frame %d: %w", i, err)) continue } c.PixelMats[i] = f.PixelBytes } return errors.Join(errs...) }