diff --git a/SentryDeck.Data/Playback/EncryptedClipDetector.cs b/SentryDeck.Data/Playback/EncryptedClipDetector.cs new file mode 100644 index 0000000..34965bd --- /dev/null +++ b/SentryDeck.Data/Playback/EncryptedClipDetector.cs @@ -0,0 +1,88 @@ +using System.Text; +using Serilog; + +namespace SentryDeck; + +/// +/// Heuristics for Tesla's encrypted dashcam recordings. +/// Software update 2026.20 turns on "Encrypt Dashcam Recordings" by default (Controls > Safety), writing AES-encrypted containers to the USB drive instead of plain MP4s, so the files no longer begin with an ISO-BMFF box header. +/// Decryption keys are only obtainable from Tesla's servers via the owner's account (dashcam.tesla.com), so the app can detect the state but not play it. +/// +public static class EncryptedClipDetector +{ + /// + /// Top-level box types an unencrypted recording can plausibly start with. + /// Tesla files start with ftyp; the rest keep the sniff from misreporting other muxers' output — a file starting with any of these is ordinary video (playable or merely corrupt), not encrypted. + /// + private static readonly string[] KnownLeadingBoxTypes = + [ + "ftyp", + "styp", + "moov", + "mdat", + "free", + "skip", + "wide", + "pdin", + "sidx", + "uuid", + ]; + + /// + /// True when the clip's front-camera files are all present with content but none starts like an MP4 — the signature of a drive written with encryption enabled. + /// A merely corrupt or truncated clip still has a valid ftyp header on at least some chunks, so it stays on the ordinary unreadable-file path. + /// + public static bool LooksEncrypted(CamClip clip) + { + if (clip is null || clip.Chunks.Count == 0) + { + return false; + } + + var sawFrontFile = false; + + foreach (var chunk in clip.Chunks) + { + if (!chunk.Files.TryGetValue(CameraNames.Front, out var frontFile)) + { + continue; + } + + sawFrontFile = true; + + if (!LooksEncrypted(frontFile.FullPath)) + { + return false; + } + } + + return sawFrontFile; + } + + /// + /// True when the file has content but does not start with a recognizable MP4 box. + /// + public static bool LooksEncrypted(string path) + { + try + { + using var stream = File.OpenRead(path); + + Span header = stackalloc byte[8]; + if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length) + { + // Shorter than one box header: a truncated write, not an encrypted container (those carry a fixed header plus at least one 4 KiB payload chunk). + return false; + } + + var leadingBoxType = Encoding.ASCII.GetString(header[4..]); + return !KnownLeadingBoxTypes.Contains(leadingBoxType); + } + catch (Exception ex) + { + // Unreadable at the filesystem level (missing, locked, ...) is not an encryption signal; let the ordinary unreadable-file handling describe it. + Log.Debug(ex, "Could not sniff file header for encryption. File={File}", path); + return false; + } + } +} diff --git a/SentryDeck.Tests/EncryptedClipDetectorTests.cs b/SentryDeck.Tests/EncryptedClipDetectorTests.cs new file mode 100644 index 0000000..f1ac222 --- /dev/null +++ b/SentryDeck.Tests/EncryptedClipDetectorTests.cs @@ -0,0 +1,100 @@ +using System.IO; + +namespace SentryDeck.Tests; + +public sealed class EncryptedClipDetectorTests : IDisposable +{ + private readonly string _root = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}")).FullName; + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private string WriteFile(string name, byte[] bytes) + { + var path = Path.Combine(_root, name); + File.WriteAllBytes(path, bytes); + return path; + } + + [Fact] + public void ValidMp4_IsNotEncrypted() + { + var path = WriteFile("valid.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + + [Fact] + public void EncryptedLookingFile_IsEncrypted() + { + var path = WriteFile("encrypted.mp4", TestMp4.EncryptedLookingBytes); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeTrue(); + } + + [Fact] + public void TruncatedTinyFile_IsNotEncrypted() + { + // Shorter than one box header: a power-loss truncation, not an encrypted container. + var path = WriteFile("truncated.mp4", [0x00, 0x00, 0x01]); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + + [Fact] + public void TruncatedButValidHeader_IsNotEncrypted() + { + // A recording cut off mid-write still starts with its ftyp box; that's the corrupt path, not the encrypted one. + var valid = TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60)); + var path = WriteFile("cutoff.mp4", valid[..12]); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + + [Fact] + public void MissingFile_IsNotEncrypted() + { + EncryptedClipDetector.LooksEncrypted(Path.Combine(_root, "nope.mp4")).ShouldBeFalse(); + } + + private CamClip ClipWithFrontFiles(params byte[][] frontFileContents) + { + var start = new DateTime(2026, 7, 9, 10, 0, 0); + var chunks = frontFileContents.Select((bytes, index) => + { + var timestamp = start.AddMinutes(index); + var path = WriteFile($"{timestamp:yyyy-MM-dd_HH-mm-ss}-front.mp4", bytes); + return new CamChunk(timestamp, [new CamFile(path, timestamp, CameraNames.Front)]); + }).ToList(); + + return new CamClip(_root, "Clip", start, chunks, camEvent: null); + } + + [Fact] + public void Clip_WithAllFrontFilesEncrypted_IsEncrypted() + { + var clip = ClipWithFrontFiles(TestMp4.EncryptedLookingBytes, TestMp4.EncryptedLookingBytes); + + EncryptedClipDetector.LooksEncrypted(clip).ShouldBeTrue(); + } + + [Fact] + public void Clip_WithOnePlayableChunk_IsNotEncrypted() + { + // One valid header among the chunks means ordinary video with some corruption — the recovery path should keep handling it. + var clip = ClipWithFrontFiles( + TestMp4.EncryptedLookingBytes, + TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse(); + } + + [Fact] + public void Clip_WithNoChunks_IsNotEncrypted() + { + var clip = new CamClip(_root, "Empty", new DateTime(2026, 7, 9), [], camEvent: null); + + EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse(); + EncryptedClipDetector.LooksEncrypted((CamClip)null).ShouldBeFalse(); + } +} diff --git a/SentryDeck.Tests/Fixtures/TestMp4.cs b/SentryDeck.Tests/Fixtures/TestMp4.cs index 780b646..087755d 100644 --- a/SentryDeck.Tests/Fixtures/TestMp4.cs +++ b/SentryDeck.Tests/Fixtures/TestMp4.cs @@ -15,6 +15,23 @@ internal static class TestMp4 /// public static byte[] GarbageBytes => [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09]; + /// + /// Bytes shaped like Tesla's 2026.20 encrypted recordings: a 20-byte header (with an embedded UUID) followed by an IV-prefixed 4 KiB ciphertext chunk — no MP4 box structure anywhere. + /// + public static byte[] EncryptedLookingBytes + { + get + { + var bytes = new byte[20 + 16 + 4096]; + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = (byte)(i * 197 + 13); // deterministic pseudo-noise, never ASCII "ftyp" + } + + return bytes; + } + } + /// /// A minimal valid mp4 whose mvhd encodes the given duration (version 0, millisecond timescale). /// diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 285c3e6..813f942 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -94,6 +94,57 @@ public async Task SelectingClip_WhenFrontFileMissing_ReportsOpenFailure() front.OpenedPaths.ShouldBeEmpty(); } + [Fact] + public async Task SelectingClip_WhenAllFilesAreEncrypted_ExplainsTheEncryptionToggle() + { + // A drive written by Tesla software 2026.20+ with "Encrypt Dashcam Recordings" on: every file exists but none is a playable MP4. + // The real builder probes and excludes every chunk, and the error must point at the encryption toggle, not claim missing footage. + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + foreach (var chunk in clipFiles.Clip.Chunks) + { + foreach (var file in chunk.Files.Values) + { + File.WriteAllBytes(file.FullPath, TestMp4.EncryptedLookingBytes); + } + } + + var front = new FakeCameraPlayer(); + using var controller = CreateController(front, mediaSourceBuilder: new FfconcatMediaSourceBuilder()); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + + await WaitUntilAsync(() => controller.ErrorMessage is not null); + + controller.ErrorMessage.ShouldBe(VideoPlayerController.EncryptedClipMessage); + controller.ErrorMessage.ShouldContain("Encrypt Dashcam Recordings"); + controller.IsPlaying.ShouldBeFalse(); + front.OpenedPaths.ShouldBeEmpty(); + } + + [Fact] + public async Task SelectingClip_WhenAllFilesAreGarbage_ButNotEncrypted_KeepsTheCorruptMessage() + { + // Same all-unreadable shape, but the files still carry MP4 headers (truncated writes): that's ordinary corruption and must NOT be blamed on encryption. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var truncated = TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))[..12]; + foreach (var file in clipFiles.Clip.Chunks[0].Files.Values) + { + File.WriteAllBytes(file.FullPath, truncated); + } + + var front = new FakeCameraPlayer(); + using var controller = CreateController(front, mediaSourceBuilder: new FfconcatMediaSourceBuilder()); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + + await WaitUntilAsync(() => controller.ErrorMessage is not null); + + controller.ErrorMessage.ShouldBe("No front camera footage found."); + controller.IsPlaying.ShouldBeFalse(); + } + [Fact] public async Task PauseSeekAndStop_ControlOpenPlayers() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index c68c127..4e11483 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -22,6 +22,13 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private const int MaxRecoveryAttemptsPerClip = 3; + /// + /// Shown when a clip's files carry Tesla's 2026.20+ dashcam encryption instead of plain MP4s. + /// The keys live behind the owner's Tesla account, so pointing at the in-car toggle and Tesla's own viewer is the most useful thing the app can do. + /// + internal const string EncryptedClipMessage = + "This clip appears to be encrypted by the vehicle (Tesla software 2026.20 and later encrypts dashcam recordings by default). To record playable clips, turn off Controls > Safety > Encrypt Dashcam Recordings. Already-encrypted clips can be viewed at dashcam.tesla.com."; + /// /// One-shot guard for the reopen/seek race after a recovery: how long to wait after issuing /// the resume seek before verifying the front player actually landed near the target, and how @@ -614,7 +621,11 @@ private async Task OpenClipInternalAsync( clip.FullPath, mediaSource.CameraPlaylistPaths.Keys.Order().ToArray(), requestId); - ErrorMessage = $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found."; + + // A fully encrypted clip lands here too: the builder probes every chunk's front file, finds no readable moov in any of them, and excludes them all — indistinguishable from "no footage" without sniffing the files themselves. + ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip) + ? EncryptedClipMessage + : $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found."; return; } @@ -1165,7 +1176,9 @@ private void GiveUpOnClip(CamClip clip, int badChunkIndex) clip.FullPath, badChunkIndex, _recoveryAttempts); - ErrorMessage = "Playback stopped: too many unreadable video files."; + ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip) + ? EncryptedClipMessage + : "Playback stopped: too many unreadable video files."; IsMediaOpen = false; }