Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions SentryDeck.Data/Playback/EncryptedClipDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Text;
using Serilog;

namespace SentryDeck;

/// <summary>
/// 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.
/// </summary>
public static class EncryptedClipDetector
{
/// <summary>
/// Top-level box types an unencrypted recording can plausibly start with.
/// Tesla files start with <c>ftyp</c>; 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.
/// </summary>
private static readonly string[] KnownLeadingBoxTypes =
[
"ftyp",
"styp",
"moov",
"mdat",
"free",
"skip",
"wide",
"pdin",
"sidx",
"uuid",
];

/// <summary>
/// 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 <c>ftyp</c> header on at least some chunks, so it stays on the ordinary unreadable-file path.
/// </summary>
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;
}

/// <summary>
/// True when the file has content but does not start with a recognizable MP4 box.
/// </summary>
public static bool LooksEncrypted(string path)
{
try
{
using var stream = File.OpenRead(path);

Span<byte> 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;
}
}
}
100 changes: 100 additions & 0 deletions SentryDeck.Tests/EncryptedClipDetectorTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
17 changes: 17 additions & 0 deletions SentryDeck.Tests/Fixtures/TestMp4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ internal static class TestMp4
/// </summary>
public static byte[] GarbageBytes => [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09];

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// A minimal valid mp4 whose mvhd encodes the given duration (version 0, millisecond timescale).
/// </summary>
Expand Down
51 changes: 51 additions & 0 deletions SentryDeck.Tests/VideoPlayerControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
17 changes: 15 additions & 2 deletions SentryDeck/Playback/VideoPlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl

private const int MaxRecoveryAttemptsPerClip = 3;

/// <summary>
/// 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.
/// </summary>
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.";

/// <summary>
/// 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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down