feature/encrypt-fmp4#266
Conversation
- Implemented ClearKeyVideo component for playing encrypted MP4 videos using Media Source Extensions. - Created ClearKeyPlayer page to demonstrate the ClearKeyVideo component with a sample video. - Added styles for ClearKeyVideo and ClearKeyPlayer for better layout and appearance. - Included utility functions for parsing MP4 boxes and splitting segments. - Integrated ClearKey license handling for video playback.
…y material handling
… packet processing
There was a problem hiding this comment.
Pull request overview
This PR introduces encrypted fMP4 recordings (CENC/ClearKey) end-to-end: the Go recorder writes encrypted fragments and the React UI adds ClearKey-based playback (plus a demo page) to view those recordings.
Changes:
- Add CENC encryption support to the MP4 muxer and wire it into the recording pipeline (disabling AAC audio when encryption is enabled).
- Replace Media page playback with a ClearKey-enabled player component and add a dedicated ClearKey demo route/page.
- Reduce WebRTC AAC transcoder logging verbosity and adjust local dev port/debug config.
Reviewed changes
Copilot reviewed 15 out of 20 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/src/pages/Media/Media.jsx | Switches recordings UI to use ClearKeyVideo and fetches config to obtain the symmetric key. |
| ui/src/pages/ClearKeyPlayer/ClearKeyPlayer.jsx | Adds a ClearKey demo player page using MSE+EME. |
| ui/src/pages/ClearKeyPlayer/ClearKeyPlayer.scss | Styles for the ClearKey demo page. |
| ui/src/components/ClearKeyVideo/ClearKeyVideo.jsx | Adds a reusable ClearKey-capable video component (MSE+EME + key derivation). |
| ui/src/components/ClearKeyVideo/ClearKeyVideo.scss | Styles for the ClearKey video component. |
| ui/src/index.jsx | Registers the /clearkey route. |
| ui/src/App.jsx | Adds a sidebar navigation entry for the ClearKey demo. |
| ui/src/config.js | Changes dev API/WS base URLs from port 8080 to 8089. |
| ui/package.json | Adds video.js and videojs-contrib-eme dependencies. |
| machinery/src/video/mp4.go | Adds segment/init encryption (CENC) and encryption configuration helpers. |
| machinery/src/capture/main.go | Enables MP4 encryption for recordings and skips AAC audio when encrypting. |
| machinery/src/routers/http/Server.go | Removes legacy AES decrypt-on-serve and adds a debug endpoint. |
| machinery/src/webrtc/main.go | Lowers AAC-related logs from Info to Debug. |
| machinery/src/webrtc/aac_transcoder_stub.go | Lowers ffmpeg/AAC transcoder logs from Info to Debug. |
| .vscode/launch.json | Updates debug args (config dir + port 8089). |
| machinery/.DS_Store | Adds a macOS metadata file to the repo. |
Comments suppressed due to low confidence (1)
machinery/src/capture/main.go:293
- When encryptRecordings is true, AAC audio is skipped entirely (no audio track and no audio samples). This is a user-visible behavior change (recordings lose audio) and should be made explicit (UI warning / config validation) or handled by encrypting audio too instead of dropping it.
if audioCodec == "AAC" && !encryptRecordings {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| r.Handle("GET", "/debug/recordings", func(c *gin.Context) { | ||
| c.JSON(200, gin.H{ | ||
| "recordings_path": configDirectory + "/data/recordings", | ||
| }) | ||
| }) |
There was a problem hiding this comment.
The new /debug/recordings endpoint discloses the server’s filesystem layout (recordings path) without any authentication/authorization checks. Please remove this endpoint or gate it behind admin auth and a debug/dev-only flag/build tag.
| r.Handle("GET", "/debug/recordings", func(c *gin.Context) { | |
| c.JSON(200, gin.H{ | |
| "recordings_path": configDirectory + "/data/recordings", | |
| }) | |
| }) | |
| // Only expose debug routes when explicitly enabled, and protect them with authentication. | |
| if os.Getenv("ENABLE_DEBUG_ENDPOINTS") == "true" { | |
| debug := r.Group("/debug") | |
| debug.Use(authMiddleware.MiddlewareFunc()) | |
| debug.GET("/recordings", func(c *gin.Context) { | |
| c.JSON(200, gin.H{ | |
| "recordings_path": configDirectory + "/data/recordings", | |
| }) | |
| }) | |
| } |
| contents, err := os.ReadFile(filePath) | ||
| if err == nil { | ||
|
|
||
| // Get symmetric key | ||
| symmetricKey := configuration.Config.Encryption.SymmetricKey | ||
| encryptedRecordings := configuration.Config.Encryption.Recordings | ||
| // Decrypt file | ||
| if encryptedRecordings == "true" && symmetricKey != "" { | ||
|
|
||
| // Read file | ||
| if err != nil { | ||
| c.JSON(404, gin.H{"error": "File not found"}) | ||
| return | ||
| } | ||
|
|
||
| // Decrypt file | ||
| contents, err = encryption.AesDecrypt(contents, symmetricKey) | ||
| if err != nil { | ||
| c.JSON(404, gin.H{"error": "File not found"}) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // Get fileSize from contents |
There was a problem hiding this comment.
This change removes server-side AES decryption when serving /file/*filepath. If there are existing recordings encrypted with the previous AES-at-rest mechanism, they will now be served as ciphertext and become unplayable/downloadable. Consider supporting both formats during a transition (e.g., detect legacy format/header and decrypt) or clearly version/migrate recordings.
| psshBoxes, err := buildPsshBoxes(kidUUID) | ||
| if err != nil { | ||
| log.Log.Error("mp4.ConfigureEncryption(): invalid PSSH system id: " + err.Error()) | ||
| } | ||
|
|
||
| mp4.EncryptRecordings = true | ||
| mp4.EncryptionKey = key | ||
| mp4.EncryptionIV = iv | ||
| mp4.EncryptionKID = kidUUID | ||
| mp4.EncryptionPssh = psshBoxes |
There was a problem hiding this comment.
If buildPsshBoxes fails, encryption is still enabled and mp4.EncryptionPssh may be nil. That can lead to EncryptFragment/InitProtect failures later or produce encrypted output missing required init data. Please return early on this error and do not enable mp4.EncryptRecordings unless all encryption material (KID, key, IV, PSSH) is successfully initialized.
| usedFallback = true | ||
| sum := sha256.Sum256([]byte(sym)) | ||
| key = append([]byte(nil), sum[:16]...) | ||
| kid = append([]byte(nil), sum[16:32]...) | ||
| ivSum := sha256.Sum256(append([]byte("iv:"), []byte(sym)...)) | ||
| iv = append([]byte(nil), ivSum[:16]...) | ||
| return key, kid, iv, usedFallback, nil | ||
| } | ||
|
|
||
| sum := sha256.Sum256(key) | ||
| kid = append([]byte(nil), sum[:16]...) | ||
| ivSeed := make([]byte, 0, 3+len(key)) | ||
| ivSeed = append(ivSeed, []byte("iv:")...) | ||
| ivSeed = append(ivSeed, key...) | ||
| ivSum := sha256.Sum256(ivSeed) | ||
| iv = append([]byte(nil), ivSum[:16]...) | ||
| return key, kid, iv, false, nil |
There was a problem hiding this comment.
deriveCencMaterial deterministically derives the CENC IV from the symmetric key. With AES-CTR (cenc), reusing IVs with the same key across recordings can leak information. Prefer generating a cryptographically-random per-recording IV (crypto/rand) and letting the IVs be carried in the file’s senc data, rather than deriving a constant IV from the key.
| if mp4.EncryptRecordings { | ||
| if encErr := mp4.encryptSegment(mp4.Segment); encErr != nil { | ||
| log.Log.Error("mp4.AddSampleToTrack(): encrypt segment failed: " + encErr.Error()) | ||
| } | ||
| } | ||
| if segErr := mp4.Segment.Encode(mp4.Writer); segErr != nil { | ||
| log.Log.Error("mp4.AddSampleToTrack(): error encoding segment: " + segErr.Error()) | ||
| } | ||
| mp4.Segments = append(mp4.Segments, mp4.Segment) |
There was a problem hiding this comment.
When encryption is enabled, an encryptSegment failure is logged but the segment is still encoded and written. That can produce a file with mixed encrypted/clear fragments and/or a file that the player cannot decode. Please fail the recording (return error to caller) or disable writing the segment when encryption is expected but encryption fails.
| if mp4.EncryptRecordings { | |
| if encErr := mp4.encryptSegment(mp4.Segment); encErr != nil { | |
| log.Log.Error("mp4.AddSampleToTrack(): encrypt segment failed: " + encErr.Error()) | |
| } | |
| } | |
| if segErr := mp4.Segment.Encode(mp4.Writer); segErr != nil { | |
| log.Log.Error("mp4.AddSampleToTrack(): error encoding segment: " + segErr.Error()) | |
| } | |
| mp4.Segments = append(mp4.Segments, mp4.Segment) | |
| encryptionSucceeded := true | |
| if mp4.EncryptRecordings { | |
| if encErr := mp4.encryptSegment(mp4.Segment); encErr != nil { | |
| encryptionSucceeded = false | |
| log.Log.Error("mp4.AddSampleToTrack(): encrypt segment failed: " + encErr.Error()) | |
| } | |
| } | |
| if encryptionSucceeded { | |
| if segErr := mp4.Segment.Encode(mp4.Writer); segErr != nil { | |
| log.Log.Error("mp4.AddSampleToTrack(): error encoding segment: " + segErr.Error()) | |
| } else { | |
| mp4.Segments = append(mp4.Segments, mp4.Segment) | |
| } | |
| } |
| const handleEncrypted = async (event) => { | ||
| await mediaKeysReady; | ||
| if (!video.mediaKeys) { | ||
| return; | ||
| } | ||
| const resolved = await ensureKeyMaterial(); | ||
| if (!resolved.kid || !resolved.key) { | ||
| return; | ||
| } | ||
| const session = video.mediaKeys.createSession(); | ||
| session.addEventListener('message', async () => { | ||
| const resolved = await ensureKeyMaterial(); | ||
| if (!resolved.kid || !resolved.key) { | ||
| return; | ||
| } | ||
| const license = JSON.stringify({ | ||
| keys: [{ kty: 'oct', kid: resolved.kid, k: resolved.key }], | ||
| type: 'temporary', | ||
| }); | ||
| const licenseBytes = new TextEncoder().encode(license); | ||
| await session.update(licenseBytes); | ||
| }); | ||
| await session.generateRequest(event.initDataType, event.initData); | ||
| }; |
There was a problem hiding this comment.
The async EME flow doesn’t catch errors from session.generateRequest() / session.update() in ClearKeyVideo. If these reject (unsupported initData, bad license JSON, etc.), it can create unhandled promise rejections and leave the player stuck. Wrap these awaits in try/catch and surface a user-visible error state.
| mediaSource.addEventListener('sourceopen', async () => { | ||
| try { | ||
| await setupMediaKeys(); | ||
| const response = await fetch(src); | ||
| const arrayBuffer = await response.arrayBuffer(); | ||
| const { initSegment, segments } = splitFmp4(arrayBuffer); | ||
|
|
There was a problem hiding this comment.
Fetch/MSE setup loads the entire src into an ArrayBuffer and appends all segments. This is memory/bandwidth heavy for real recordings and cannot be cancelled if the component unmounts mid-download. Consider using an AbortController for fetch, check response.ok, and/or implement incremental range/streaming appends rather than downloading the full file up front.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ance key material handling
…nto feature/encrypt-fmp4
No description provided.