Skip to content

Commit f5853f2

Browse files
committed
Improved SECV format
removed the need for the index table
1 parent a077fd7 commit f5853f2

7 files changed

Lines changed: 1103 additions & 152 deletions

File tree

app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingDecryptor.kt

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import javax.crypto.spec.SecretKeySpec
1313

1414
/**
1515
* Implementation of StreamingDecryptor that decrypts SECV files chunk by chunk.
16-
* Supports random access for video seeking by using the chunk index table.
17-
* Reads the trailer format with trailer and index at the end of the file.
16+
* Supports random access for video seeking by calculating chunk offsets arithmetically.
17+
* Reads the header format with metadata at the start of the file.
1818
*
1919
* Thread-safe via mutex for all read operations.
2020
*/
@@ -27,48 +27,31 @@ class ChunkedStreamingDecryptor(
2727
private val randomAccessFile: RandomAccessFile
2828
private var isClosed = false
2929

30-
private val trailer: SecvFileFormat.SecvTrailer
31-
private val chunkIndex: List<SecvFileFormat.ChunkIndexEntry>
30+
private val header: SecvFileFormat.SecvHeader
3231

3332
// Cache for the most recently decrypted chunk to avoid re-decryption on sequential reads
3433
private var cachedChunkIndex: Long = -1
3534
private var cachedChunkData: ByteArray? = null
3635

3736
override val totalSize: Long
38-
get() = trailer.originalSize
37+
get() = header.originalSize
3938

4039
override val chunkSize: Int
41-
get() = trailer.chunkSize
40+
get() = header.chunkSize
4241

4342
init {
4443
require(encryptedFile.exists()) { "Encrypted file does not exist: ${encryptedFile.absolutePath}" }
4544

4645
randomAccessFile = RandomAccessFile(encryptedFile, "r") ?: error("Failed to open file for reading")
47-
val fileLength = randomAccessFile.length()
4846

49-
// Read trailer from end of file
50-
val trailerPosition = SecvFileFormat.calculateTrailerPosition(fileLength)
51-
randomAccessFile.seek(trailerPosition)
52-
val trailerBytes = ByteArray(SecvFileFormat.TRAILER_SIZE)
53-
randomAccessFile.readFully(trailerBytes)
54-
trailer = SecvFileFormat.SecvTrailer.fromByteArray(trailerBytes)
47+
// Read header from start of file
48+
randomAccessFile.seek(0)
49+
val headerBytes = ByteArray(SecvFileFormat.HEADER_SIZE)
50+
randomAccessFile.readFully(headerBytes)
51+
header = SecvFileFormat.SecvHeader.fromByteArray(headerBytes)
5552

56-
require(trailer.version == SecvFileFormat.VERSION) {
57-
"Unsupported SECV version: ${trailer.version}"
58-
}
59-
60-
// Read chunk index table (just before trailer)
61-
val indexPosition = SecvFileFormat.calculateIndexTablePosition(fileLength, trailer.totalChunks)
62-
randomAccessFile.seek(indexPosition)
63-
val indexTableSize = trailer.totalChunks * SecvFileFormat.CHUNK_INDEX_ENTRY_SIZE
64-
val indexBytes = ByteArray(indexTableSize.toInt())
65-
randomAccessFile.readFully(indexBytes)
66-
67-
chunkIndex = (0 until trailer.totalChunks).map { i ->
68-
SecvFileFormat.ChunkIndexEntry.fromByteArray(
69-
indexBytes,
70-
(i * SecvFileFormat.CHUNK_INDEX_ENTRY_SIZE).toInt()
71-
)
53+
require(header.version == SecvFileFormat.VERSION) {
54+
"Unsupported SECV version: ${header.version}"
7255
}
7356
}
7457

@@ -78,6 +61,11 @@ class ChunkedStreamingDecryptor(
7861
require(length >= 0) { "Length must be non-negative" }
7962
require(offset + length <= buffer.size) { "Offset + length exceeds buffer size" }
8063

64+
// Reading 0 bytes always succeeds and returns 0
65+
if (length == 0) {
66+
return 0
67+
}
68+
8169
if (position >= totalSize) {
8270
return -1 // EOF
8371
}
@@ -138,13 +126,22 @@ class ChunkedStreamingDecryptor(
138126
private suspend fun decryptChunk(chunkIdx: Long): ByteArray = withContext(Dispatchers.IO) {
139127
val raf = randomAccessFile
140128

141-
require(chunkIdx < chunkIndex.size) { "Chunk index out of bounds: $chunkIdx" }
129+
require(chunkIdx < header.totalChunks) { "Chunk index out of bounds: $chunkIdx" }
130+
131+
// Calculate chunk offset arithmetically
132+
val chunkOffset = SecvFileFormat.calculateChunkOffset(chunkIdx, header.chunkSize)
142133

143-
val entry = chunkIndex[chunkIdx.toInt()]
134+
// Determine encrypted size based on whether this is the final chunk
135+
val isFinalChunk = (chunkIdx == header.totalChunks - 1)
136+
val encryptedSize = if (isFinalChunk) {
137+
SecvFileFormat.calculateEncryptedChunkSize(header.finalChunkPlaintextSize)
138+
} else {
139+
SecvFileFormat.calculateFullEncryptedChunkSize(header.chunkSize)
140+
}
144141

145142
// Read the encrypted chunk data (IV + ciphertext with auth tag)
146-
val encryptedData = ByteArray(entry.encryptedSize)
147-
raf.seek(entry.offset)
143+
val encryptedData = ByteArray(encryptedSize)
144+
raf.seek(chunkOffset)
148145
raf.readFully(encryptedData)
149146

150147
val iv = encryptedData.copyOfRange(0, SecvFileFormat.IV_SIZE)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/ChunkedStreamingEncryptor.kt

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import javax.crypto.spec.SecretKeySpec
1616
/**
1717
* Implementation of StreamingEncryptor that encrypts data in chunks using AES-GCM.
1818
*
19-
* This encryptor writes data in the SECV trailer format:
20-
* 1. Encrypted chunks are written sequentially as data arrives
21-
* 2. On close, the index table and trailer are appended to the end
22-
* 3. No file rewriting needed, preventing memory spikes on large videos
19+
* This encryptor writes data in the SECV header format:
20+
* 1. Writes 64-byte placeholder header at start
21+
* 2. Encrypted chunks are written sequentially as data arrives
22+
* 3. On close, seeks to position 0 and writes final header with metadata
2323
*
2424
* Thread-safe via mutex for all write operations.
2525
*/
@@ -36,7 +36,8 @@ class ChunkedStreamingEncryptor(
3636
private var currentBuffer = ByteArray(chunkSize)
3737
private var bufferPosition = 0
3838
private var totalBytesWritten = 0L
39-
private val chunkIndexEntries = mutableListOf<SecvFileFormat.ChunkIndexEntry>()
39+
private var totalChunks = 0L
40+
private var finalChunkPlaintextSize = 0
4041
private var isClosed = false
4142
private var isFlushed = false
4243

@@ -48,9 +49,13 @@ class ChunkedStreamingEncryptor(
4849

4950
randomAccessFile = RandomAccessFile(outputFile, "rw")
5051

51-
// Chunks are written starting at offset 0
52-
// Index table and trailer will be appended at the end
53-
currentChunkDataOffset = 0L
52+
// Write placeholder header (64 zero bytes) at start
53+
// Will be filled in with actual metadata on close()
54+
val placeholderHeader = ByteArray(SecvFileFormat.HEADER_SIZE)
55+
randomAccessFile?.write(placeholderHeader)
56+
57+
// Chunks are written starting after the header
58+
currentChunkDataOffset = SecvFileFormat.HEADER_SIZE.toLong()
5459
}
5560

5661
override suspend fun write(data: ByteArray, offset: Int, length: Int) {
@@ -101,7 +106,7 @@ class ChunkedStreamingEncryptor(
101106
}
102107

103108
/**
104-
* Encrypts a chunk of data and writes it to the temporary storage.
109+
* Encrypts a chunk of data and writes it to the file.
105110
* Must be called while holding the mutex.
106111
*/
107112
private suspend fun writeChunk(data: ByteArray, length: Int) = withContext(Dispatchers.IO) {
@@ -122,20 +127,16 @@ class ChunkedStreamingEncryptor(
122127
val plaintext = if (length == data.size) data else data.copyOf(length)
123128
val ciphertext = cipher.doFinal(plaintext)
124129

125-
// Record the chunk index entry
126-
val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size
127-
chunkIndexEntries.add(
128-
SecvFileFormat.ChunkIndexEntry(
129-
offset = currentChunkDataOffset,
130-
encryptedSize = encryptedSize
131-
)
132-
)
130+
// Track chunk count and final chunk plaintext size
131+
totalChunks++
132+
finalChunkPlaintextSize = length
133133

134134
// Write IV + ciphertext (which includes auth tag)
135135
raf.seek(currentChunkDataOffset)
136136
raf.write(iv)
137137
raf.write(ciphertext)
138138

139+
val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size
139140
currentChunkDataOffset += encryptedSize
140141
} finally {
141142
// Immediately zero key bytes after use
@@ -164,17 +165,15 @@ class ChunkedStreamingEncryptor(
164165
val plaintext = currentBuffer.copyOf(bufferPosition)
165166
val ciphertext = cipher.doFinal(plaintext)
166167

167-
val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size
168-
chunkIndexEntries.add(
169-
SecvFileFormat.ChunkIndexEntry(
170-
offset = currentChunkDataOffset,
171-
encryptedSize = encryptedSize
172-
)
173-
)
168+
// Track chunk count and final chunk plaintext size
169+
totalChunks++
170+
finalChunkPlaintextSize = bufferPosition
174171

175172
raf.seek(currentChunkDataOffset)
176173
raf.write(iv)
177174
raf.write(ciphertext)
175+
176+
val encryptedSize = SecvFileFormat.IV_SIZE + ciphertext.size
178177
currentChunkDataOffset += encryptedSize
179178
bufferPosition = 0
180179
} finally {
@@ -183,19 +182,16 @@ class ChunkedStreamingEncryptor(
183182
}
184183
}
185184

186-
// Append index table at current position
187-
for (entry in chunkIndexEntries) {
188-
raf.write(entry.toByteArray())
189-
}
190-
191-
// Append trailer at end (now we know totalChunks and originalSize)
192-
val trailer = SecvFileFormat.SecvTrailer(
185+
// Seek to beginning and write header with final metadata
186+
raf.seek(0)
187+
val header = SecvFileFormat.SecvHeader(
193188
version = SecvFileFormat.VERSION,
194189
chunkSize = chunkSize,
195-
totalChunks = chunkIndexEntries.size.toLong(),
196-
originalSize = totalBytesWritten
190+
totalChunks = totalChunks,
191+
originalSize = totalBytesWritten,
192+
finalChunkPlaintextSize = finalChunkPlaintextSize
197193
)
198-
raf.write(trailer.toByteArray())
194+
raf.write(header.toByteArray())
199195

200196
randomAccessFile?.close()
201197
randomAccessFile = null

app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/streaming/SecvFileFormat.kt

Lines changed: 33 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,52 @@ import java.nio.ByteOrder
66
/**
77
* Constants and utilities for the SECV (Secure Encrypted Camera Video) file format.
88
*
9-
* File Format:
10-
* [Encrypted Chunks]
11-
* - Per chunk: [12-byte IV][ciphertext][16-byte auth tag]
12-
*
13-
* [Chunk Index Table: 12 bytes per chunk]
14-
* - Chunk offset: uint64 (8 bytes)
15-
* - Encrypted size: uint32 (4 bytes)
16-
*
17-
* [Trailer: 64 bytes] - Located at end of file
9+
* File Format (Version 1):
10+
* [Header: 64 bytes] - Located at start of file
1811
* - Magic: "SECV" (4 bytes)
1912
* - Version: uint16 (2 bytes)
2013
* - Chunk size: uint32 (4 bytes)
2114
* - Total chunks: uint64 (8 bytes)
2215
* - Original size: uint64 (8 bytes)
23-
* - Reserved: padding to 64 bytes (38 bytes)
16+
* - Final chunk plaintext size: uint32 (4 bytes)
17+
* - Reserved: padding to 64 bytes (34 bytes)
2418
*
25-
* The trailer format (chunks first, metadata at end) eliminates the need
26-
* to rewrite the entire file when encryption completes, preventing memory
27-
* spikes from loading large videos into RAM.
19+
* [Encrypted Chunks]
20+
* - Per chunk: [12-byte IV][ciphertext][16-byte auth tag]
21+
*
22+
* Design Rationale:
23+
* - Chunk offsets calculated arithmetically: offset = 64 + (chunkIndex * (chunkSize + 28))
2824
*/
2925
object SecvFileFormat {
3026
const val MAGIC = "SECV"
3127
const val VERSION: Short = 1
32-
const val TRAILER_SIZE = 64
33-
const val CHUNK_INDEX_ENTRY_SIZE = 12
28+
const val HEADER_SIZE = 64
3429
const val IV_SIZE = 12
3530
const val AUTH_TAG_SIZE = 16
3631
const val DEFAULT_CHUNK_SIZE = 1_048_576 // 1MB
3732

3833
const val FILE_EXTENSION = "secv"
3934

40-
// Trailer field offsets
35+
// Header field offsets
4136
private const val OFFSET_MAGIC = 0
4237
private const val OFFSET_VERSION = 4
4338
private const val OFFSET_CHUNK_SIZE = 6
4439
private const val OFFSET_TOTAL_CHUNKS = 10
4540
private const val OFFSET_ORIGINAL_SIZE = 18
41+
private const val OFFSET_FINAL_CHUNK_SIZE = 26
4642

4743
/**
48-
* Represents the trailer of a SECV file (metadata at end of file).
44+
* Represents the header of a SECV file (metadata at start of file).
4945
*/
50-
data class SecvTrailer(
46+
data class SecvHeader(
5147
val version: Short,
5248
val chunkSize: Int,
5349
val totalChunks: Long,
54-
val originalSize: Long
50+
val originalSize: Long,
51+
val finalChunkPlaintextSize: Int
5552
) {
5653
fun toByteArray(): ByteArray {
57-
val buffer = ByteBuffer.allocate(TRAILER_SIZE)
54+
val buffer = ByteBuffer.allocate(HEADER_SIZE)
5855
buffer.order(ByteOrder.LITTLE_ENDIAN)
5956

6057
// Magic
@@ -67,14 +64,16 @@ object SecvFileFormat {
6764
buffer.putLong(totalChunks)
6865
// Original size
6966
buffer.putLong(originalSize)
67+
// Final chunk plaintext size
68+
buffer.putInt(finalChunkPlaintextSize)
7069
// Reserved (remaining bytes are zero by default)
7170

7271
return buffer.array()
7372
}
7473

7574
companion object {
76-
fun fromByteArray(bytes: ByteArray): SecvTrailer {
77-
require(bytes.size >= TRAILER_SIZE) { "Trailer too small" }
75+
fun fromByteArray(bytes: ByteArray): SecvHeader {
76+
require(bytes.size >= HEADER_SIZE) { "Header too small" }
7877

7978
val buffer = ByteBuffer.wrap(bytes)
8079
buffer.order(ByteOrder.LITTLE_ENDIAN)
@@ -88,39 +87,14 @@ object SecvFileFormat {
8887
val chunkSize = buffer.int
8988
val totalChunks = buffer.long
9089
val originalSize = buffer.long
90+
val finalChunkPlaintextSize = buffer.int
9191

92-
return SecvTrailer(
92+
return SecvHeader(
9393
version = version,
9494
chunkSize = chunkSize,
9595
totalChunks = totalChunks,
96-
originalSize = originalSize
97-
)
98-
}
99-
}
100-
}
101-
102-
/**
103-
* Represents an entry in the chunk index table.
104-
*/
105-
data class ChunkIndexEntry(
106-
val offset: Long,
107-
val encryptedSize: Int
108-
) {
109-
fun toByteArray(): ByteArray {
110-
val buffer = ByteBuffer.allocate(CHUNK_INDEX_ENTRY_SIZE)
111-
buffer.order(ByteOrder.LITTLE_ENDIAN)
112-
buffer.putLong(offset)
113-
buffer.putInt(encryptedSize)
114-
return buffer.array()
115-
}
116-
117-
companion object {
118-
fun fromByteArray(bytes: ByteArray, offset: Int = 0): ChunkIndexEntry {
119-
val buffer = ByteBuffer.wrap(bytes, offset, CHUNK_INDEX_ENTRY_SIZE)
120-
buffer.order(ByteOrder.LITTLE_ENDIAN)
121-
return ChunkIndexEntry(
122-
offset = buffer.long,
123-
encryptedSize = buffer.int
96+
originalSize = originalSize,
97+
finalChunkPlaintextSize = finalChunkPlaintextSize
12498
)
12599
}
126100
}
@@ -135,19 +109,19 @@ object SecvFileFormat {
135109
}
136110

137111
/**
138-
* Calculate the position of the trailer in the file (last 64 bytes).
139-
* For trailer format, trailer is at: fileLength - TRAILER_SIZE
112+
* Calculate the size of a full encrypted chunk.
113+
* Full chunk size = IV (12 bytes) + chunkSize + auth tag (16 bytes) = chunkSize + 28
140114
*/
141-
fun calculateTrailerPosition(fileLength: Long): Long {
142-
return fileLength - TRAILER_SIZE
115+
fun calculateFullEncryptedChunkSize(chunkSize: Int): Int {
116+
return chunkSize + IV_SIZE + AUTH_TAG_SIZE
143117
}
144118

145119
/**
146-
* Calculate the position of the index table in the file.
147-
* For trailer format, index is at: fileLength - TRAILER_SIZE - (totalChunks * CHUNK_INDEX_ENTRY_SIZE)
120+
* Calculate the file offset for a given chunk index.
121+
* Offset = header (64 bytes) + (chunkIndex * full encrypted chunk size)
148122
*/
149-
fun calculateIndexTablePosition(fileLength: Long, totalChunks: Long): Long {
150-
return fileLength - TRAILER_SIZE - (totalChunks * CHUNK_INDEX_ENTRY_SIZE)
123+
fun calculateChunkOffset(chunkIndex: Long, chunkSize: Int): Long {
124+
return HEADER_SIZE + (chunkIndex * calculateFullEncryptedChunkSize(chunkSize))
151125
}
152126

153127
/**

0 commit comments

Comments
 (0)