Passed
Push — master ( 7e62d3...3c4375 )
by Pascal
02:25
created

removeHandlerThatRotatesEncryptionKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 0
dl 0
loc 11
rs 10
c 0
b 0
f 0
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->nextEncryptionFilenameAndKey of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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.

Loading history...
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)
0 ignored issues
show
Bug introduced by
It seems like addListener() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

221
        $this->/** @scrutinizer ignore-call */ 
222
               addListener($this->listener)
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like removeListener() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

245
            $this->/** @scrutinizer ignore-call */ 
246
                   removeListener($this->listener);
Loading history...
246
            $this->listener = null;
247
248
            $this->getFFMpegDriver()->removeAllListeners(HLSExporter::ENCRYPTION_LISTENER);
0 ignored issues
show
Bug introduced by
It seems like getFFMpegDriver() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

248
            $this->/** @scrutinizer ignore-call */ 
249
                   getFFMpegDriver()->removeAllListeners(HLSExporter::ENCRYPTION_LISTENER);
Loading history...
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