1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace ProtoneMedia\LaravelFFMpeg\Exporters; |
4
|
|
|
|
5
|
|
|
use Closure; |
6
|
|
|
use Illuminate\Filesystem\Filesystem; |
7
|
|
|
use Illuminate\Support\Collection; |
8
|
|
|
use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener; |
9
|
|
|
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk; |
10
|
|
|
use ProtoneMedia\LaravelFFMpeg\Filesystem\TemporaryDirectories; |
11
|
|
|
|
12
|
|
|
trait EncryptsHLSSegments |
13
|
|
|
{ |
14
|
|
|
/** |
15
|
|
|
* The encryption key. |
16
|
|
|
* |
17
|
|
|
* @var string |
18
|
|
|
*/ |
19
|
|
|
private $encryptionKey; |
20
|
|
|
|
21
|
|
|
/** |
22
|
|
|
* The encryption key filename. |
23
|
|
|
* |
24
|
|
|
* @var string |
25
|
|
|
*/ |
26
|
|
|
private $encryptionKeyFilename; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* Gets called whenever a new encryption key is set. |
30
|
|
|
* |
31
|
|
|
* @var callable |
32
|
|
|
*/ |
33
|
|
|
private $onNewEncryptionKey; |
34
|
|
|
|
35
|
|
|
/** |
36
|
|
|
* Disk to store the secrets. |
37
|
|
|
*/ |
38
|
|
|
private $encryptionSecretsRoot; |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* Encryption IV |
42
|
|
|
* |
43
|
|
|
* @var string |
44
|
|
|
*/ |
45
|
|
|
private $encryptionIV; |
46
|
|
|
|
47
|
|
|
/** |
48
|
|
|
* Wether to rotate the key on every segment. |
49
|
|
|
* |
50
|
|
|
* @var boolean |
51
|
|
|
*/ |
52
|
|
|
private $rotateEncryptiongKey = false; |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* Number of opened segments. |
56
|
|
|
* |
57
|
|
|
* @var integer |
58
|
|
|
*/ |
59
|
|
|
private $segmentsOpened = 0; |
60
|
|
|
|
61
|
|
|
/** |
62
|
|
|
* Number of segments that can use the same key. |
63
|
|
|
* |
64
|
|
|
* @var integer |
65
|
|
|
*/ |
66
|
|
|
private $segmentsPerKey = 1; |
67
|
|
|
|
68
|
|
|
/** |
69
|
|
|
* Listener that will rotate the key. |
70
|
|
|
* |
71
|
|
|
* @var \ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener |
72
|
|
|
*/ |
73
|
|
|
private $listener; |
74
|
|
|
|
75
|
|
|
/** |
76
|
|
|
* A fresh filename and encryption key for the next round. |
77
|
|
|
* |
78
|
|
|
* @var array |
79
|
|
|
*/ |
80
|
|
|
private $nextEncryptionFilenameAndKey; |
81
|
|
|
|
82
|
|
|
/** |
83
|
|
|
* Creates a new encryption key. |
84
|
|
|
* |
85
|
|
|
* @return string |
86
|
|
|
*/ |
87
|
|
|
public static function generateEncryptionKey(): string |
88
|
|
|
{ |
89
|
|
|
return random_bytes(16); |
90
|
|
|
} |
91
|
|
|
/** |
92
|
|
|
* Creates a new encryption key filename. |
93
|
|
|
* |
94
|
|
|
* @return string |
95
|
|
|
*/ |
96
|
|
|
public static function generateEncryptionKeyFilename(): string |
97
|
|
|
{ |
98
|
|
|
return bin2hex(random_bytes(8)) . '.key'; |
99
|
|
|
} |
100
|
|
|
|
101
|
|
|
/** |
102
|
|
|
* Initialises the disk, info and IV for encryption and sets the key. |
103
|
|
|
* |
104
|
|
|
* @param string $key |
105
|
|
|
* @param string $filename |
106
|
|
|
* @return self |
107
|
|
|
*/ |
108
|
|
|
public function withEncryptionKey($key, $filename = 'secret.key'): self |
109
|
|
|
{ |
110
|
|
|
$this->encryptionKey = $key; |
111
|
|
|
$this->encryptionIV = bin2hex(static::generateEncryptionKey()); |
112
|
|
|
|
113
|
|
|
$this->encryptionKeyFilename = $filename; |
114
|
|
|
$this->encryptionSecretsRoot = app(TemporaryDirectories::class)->create(); |
115
|
|
|
|
116
|
|
|
return $this; |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
/** |
120
|
|
|
* Enables encryption with rotating keys. The callable will receive every new |
121
|
|
|
* key and the integer sets the number of segments that can |
122
|
|
|
* use the same key. |
123
|
|
|
* |
124
|
|
|
* @param Closure $callback |
125
|
|
|
* @param int $segmentsPerKey |
126
|
|
|
* @return self |
127
|
|
|
*/ |
128
|
|
|
public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self |
129
|
|
|
{ |
130
|
|
|
$this->rotateEncryptiongKey = true; |
131
|
|
|
$this->onNewEncryptionKey = $callback; |
132
|
|
|
$this->segmentsPerKey = $segmentsPerKey; |
133
|
|
|
|
134
|
|
|
return $this->withEncryptionKey(null, null); |
135
|
|
|
} |
136
|
|
|
|
137
|
|
|
/** |
138
|
|
|
* Rotates the key and returns the absolute path to the info file. This method |
139
|
|
|
* should be executed as fast as possible, or we might be too late for FFmpeg |
140
|
|
|
* opening the next segment. That's why we don't use the Disk-class magic. |
141
|
|
|
* |
142
|
|
|
* @return string |
143
|
|
|
*/ |
144
|
|
|
private function rotateEncryptionKey(): string |
145
|
|
|
{ |
146
|
|
|
if ($this->nextEncryptionFilenameAndKey) { |
|
|
|
|
147
|
|
|
[$keyFilename, $encryptionKey] = $this->nextEncryptionFilenameAndKey; |
148
|
|
|
} else { |
149
|
|
|
$keyFilename = $this->encryptionKeyFilename ?: static::generateEncryptionKeyFilename(); |
150
|
|
|
$encryptionKey = $this->encryptionKey ?: static::generateEncryptionKey(); |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
// get the absolute path to the info file and encryption key |
154
|
|
|
$hlsKeyInfoPath = $this->encryptionSecretsRoot . '/' . HLSExporter::HLS_KEY_INFO_FILENAME; |
155
|
|
|
$keyPath = $this->encryptionSecretsRoot . '/' . $keyFilename; |
156
|
|
|
|
157
|
|
|
$normalizedKeyPath = Disk::normalizePath($keyPath); |
158
|
|
|
|
159
|
|
|
// store the encryption key |
160
|
|
|
file_put_contents($keyPath, $encryptionKey); |
161
|
|
|
|
162
|
|
|
// store an info file with a reference to the encryption key and IV |
163
|
|
|
file_put_contents( |
164
|
|
|
$hlsKeyInfoPath, |
165
|
|
|
$normalizedKeyPath . PHP_EOL . $normalizedKeyPath . PHP_EOL . $this->encryptionIV |
166
|
|
|
); |
167
|
|
|
|
168
|
|
|
// prepare for the next round |
169
|
|
|
if ($this->rotateEncryptiongKey) { |
170
|
|
|
$this->nextEncryptionFilenameAndKey = [ |
171
|
|
|
static::generateEncryptionKeyFilename(), |
172
|
|
|
static::generateEncryptionKey(), |
173
|
|
|
]; |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
// call the callback |
177
|
|
|
if ($this->onNewEncryptionKey) { |
178
|
|
|
call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey, $this->listener); |
179
|
|
|
} |
180
|
|
|
|
181
|
|
|
// return the absolute path to the info file |
182
|
|
|
return Disk::normalizePath($hlsKeyInfoPath); |
183
|
|
|
} |
184
|
|
|
|
185
|
|
|
/** |
186
|
|
|
* Returns an array with the encryption parameters. |
187
|
|
|
* |
188
|
|
|
* @return array |
189
|
|
|
*/ |
190
|
|
|
private function getEncrypedHLSParameters(): array |
191
|
|
|
{ |
192
|
|
|
if (!$this->encryptionIV) { |
193
|
|
|
return []; |
194
|
|
|
} |
195
|
|
|
|
196
|
|
|
$keyInfoPath = $this->rotateEncryptionKey(); |
197
|
|
|
$parameters = ['-hls_key_info_file', $keyInfoPath]; |
198
|
|
|
|
199
|
|
|
if ($this->rotateEncryptiongKey) { |
200
|
|
|
$parameters[] = '-hls_flags'; |
201
|
|
|
$parameters[] = 'periodic_rekey'; |
202
|
|
|
} |
203
|
|
|
|
204
|
|
|
return $parameters; |
205
|
|
|
} |
206
|
|
|
|
207
|
|
|
/** |
208
|
|
|
* Adds a listener and handler to rotate the key on |
209
|
|
|
* every new HLS segment. |
210
|
|
|
* |
211
|
|
|
* @return void |
212
|
|
|
*/ |
213
|
|
|
private function addHandlerToRotateEncryptionKey() |
214
|
|
|
{ |
215
|
|
|
if (!$this->rotateEncryptiongKey) { |
216
|
|
|
return; |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
$this->listener = new StdListener(HLSExporter::ENCRYPTION_LISTENER); |
220
|
|
|
|
221
|
|
|
$this->addListener($this->listener) |
|
|
|
|
222
|
|
|
->onEvent(HLSExporter::ENCRYPTION_LISTENER, function ($line) { |
223
|
|
|
if (!strpos($line, ".keyinfo' for reading")) { |
224
|
|
|
return; |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
$this->segmentsOpened++; |
228
|
|
|
|
229
|
|
|
if ($this->segmentsOpened % $this->segmentsPerKey === 0) { |
230
|
|
|
$this->rotateEncryptionKey(); |
231
|
|
|
} |
232
|
|
|
}); |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
/** |
236
|
|
|
* Remove the listener at the end of the export to |
237
|
|
|
* prevent duplicate event handlers. |
238
|
|
|
* |
239
|
|
|
* @return self |
240
|
|
|
*/ |
241
|
|
|
private function removeHandlerThatRotatesEncryptionKey(): self |
242
|
|
|
{ |
243
|
|
|
if ($this->listener) { |
244
|
|
|
$this->listener->removeAllListeners(); |
245
|
|
|
$this->removeListener($this->listener); |
|
|
|
|
246
|
|
|
$this->listener = null; |
247
|
|
|
|
248
|
|
|
$this->getFFMpegDriver()->removeAllListeners(HLSExporter::ENCRYPTION_LISTENER); |
|
|
|
|
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
return $this; |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
/** |
255
|
|
|
* While encoding, the encryption keys are saved to a temporary directory. |
256
|
|
|
* With this method, we loop through all segment playlists and replace |
257
|
|
|
* the absolute path to the keys to a relative ones. |
258
|
|
|
* |
259
|
|
|
* @param \Illuminate\Support\Collection $playlistMedia |
260
|
|
|
* @return self |
261
|
|
|
*/ |
262
|
|
|
private function replaceAbsolutePathsHLSEncryption(Collection $playlistMedia): self |
263
|
|
|
{ |
264
|
|
|
if (!$this->encryptionSecretsRoot) { |
265
|
|
|
return $this; |
266
|
|
|
} |
267
|
|
|
|
268
|
|
|
$playlistMedia->each(function ($playlistMedia) { |
269
|
|
|
$disk = $playlistMedia->getDisk(); |
270
|
|
|
$path = $playlistMedia->getPath(); |
271
|
|
|
|
272
|
|
|
$prefix = '#EXT-X-KEY:METHOD=AES-128,URI="'; |
273
|
|
|
|
274
|
|
|
$content = str_replace( |
275
|
|
|
$prefix . Disk::normalizePath($this->encryptionSecretsRoot) . '/', |
276
|
|
|
$prefix, |
277
|
|
|
$disk->get($path) |
278
|
|
|
); |
279
|
|
|
|
280
|
|
|
$disk->put($path, $content); |
281
|
|
|
}); |
282
|
|
|
|
283
|
|
|
return $this; |
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
/** |
287
|
|
|
* Removes the encryption keys from the temporary disk. |
288
|
|
|
* |
289
|
|
|
* @return self |
290
|
|
|
*/ |
291
|
|
|
private function cleanupHLSEncryption(): self |
292
|
|
|
{ |
293
|
|
|
if ($this->encryptionSecretsRoot) { |
294
|
|
|
(new Filesystem)->deleteDirectory($this->encryptionSecretsRoot); |
295
|
|
|
} |
296
|
|
|
|
297
|
|
|
return $this; |
298
|
|
|
} |
299
|
|
|
} |
300
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.