Passed
Push — master ( d7ad3b...186424 )
by Pascal
02:42
created

generateEncryptionKeyFilename()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
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
     * Gets called whenever a new encryption key is set.
23
     *
24
     * @var callable
25
     */
26
    private $onNewEncryptionKey;
27
28
    /**
29
     * Disk to store the secrets.
30
     */
31
    private $encryptionSecretsRoot;
32
33
    /**
34
     * Encryption IV
35
     *
36
     * @var string
37
     */
38
    private $encryptionIV;
39
40
    /**
41
     * Wether to rotate the key on every segment.
42
     *
43
     * @var boolean
44
     */
45
    private $rotateEncryptiongKey = false;
46
47
    /**
48
     * Number of opened segments.
49
     *
50
     * @var integer
51
     */
52
    private $segmentsOpened = 0;
53
54
    /**
55
     * Number of segments that can use the same key.
56
     *
57
     * @var integer
58
     */
59
    private $segmentsPerKey = 1;
60
61
    /**
62
     * Listener that will rotate the key.
63
     *
64
     * @var \ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener
65
     */
66
    private $listener;
67
68
    /**
69
     * A fresh filename and encryption key for the next round.
70
     *
71
     * @var array
72
     */
73
    private $nextEncryptionFilenameAndKey;
74
75
    /**
76
     * Creates a new encryption key.
77
     *
78
     * @return string
79
     */
80
    public static function generateEncryptionKey(): string
81
    {
82
        return random_bytes(16);
83
    }
84
    /**
85
     * Creates a new encryption key filename.
86
     *
87
     * @return string
88
     */
89
    public static function generateEncryptionKeyFilename(): string
90
    {
91
        return bin2hex(random_bytes(8)) . '.key';
92
    }
93
94
    /**
95
     * Sets the encryption key with the given value or generates a new one.
96
     *
97
     * @param string $key
98
     * @return string
99
     */
100
    private function setEncryptionKey($key = null): string
101
    {
102
        return $this->encryptionKey = $key ?: static::generateEncryptionKey();
103
    }
104
105
    /**
106
     * Initialises the disk, info and IV for encryption and sets the key.
107
     *
108
     * @param string $key
109
     * @return self
110
     */
111
    public function withEncryptionKey($key): self
112
    {
113
        $this->encryptionSecretsRoot = app(TemporaryDirectories::class)->create();
114
115
        $this->encryptionIV = bin2hex(static::generateEncryptionKey());
116
117
        $this->setEncryptionKey($key);
118
119
        return $this;
120
    }
121
122
    /**
123
     * Enables encryption with rotating keys. The callable will receive every new
124
     * key and the integer sets the number of segments that can
125
     * use the same key.
126
     *
127
     * @param Closure $callback
128
     * @param int $segmentsPerKey
129
     * @return self
130
     */
131
    public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self
132
    {
133
        $this->rotateEncryptiongKey = true;
134
        $this->onNewEncryptionKey   = $callback;
135
        $this->segmentsPerKey       = $segmentsPerKey;
136
137
        return $this->withEncryptionKey(static::generateEncryptionKey());
138
    }
139
140
    /**
141
     * Rotates the key and returns the absolute path to the info file. This method
142
     * should be executed as fast as possible, or we might be too late for FFmpeg
143
     * opening the next segment. That's why we don't use the Disk-class magic.
144
     *
145
     * @return string
146
     */
147
    private function rotateEncryptionKey(): string
148
    {
149
        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...
150
            [$keyFilename, $encryptionKey] = $this->nextEncryptionFilenameAndKey;
151
        } else {
152
            $keyFilename   = static::generateEncryptionKeyFilename();
153
            $encryptionKey = static::generateEncryptionKey();
154
        }
155
156
        // get the absolute path to the info file and encryption key
157
        $hlsKeyInfoPath = $this->encryptionSecretsRoot . '/' . HLSExporter::HLS_KEY_INFO_FILENAME;
158
        $keyPath        = $this->encryptionSecretsRoot . '/' . $keyFilename;
159
160
        $normalizedKeyPath = Disk::normalizePath($keyPath);
161
162
        // store the encryption key
163
        file_put_contents($keyPath, $encryptionKey);
164
165
        // store an info file with a reference to the encryption key and IV
166
        file_put_contents(
167
            $hlsKeyInfoPath,
168
            $normalizedKeyPath . PHP_EOL . $normalizedKeyPath . PHP_EOL . $this->encryptionIV
169
        );
170
171
        // prepare for the next round
172
        $this->nextEncryptionFilenameAndKey = [
173
            static::generateEncryptionKeyFilename(),
174
            static::generateEncryptionKey(),
175
        ];
176
177
        // call the callback
178
        if ($this->onNewEncryptionKey) {
179
            call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey, $this->listener);
180
        }
181
182
        // return the absolute path to the info file
183
        return Disk::normalizePath($hlsKeyInfoPath);
184
    }
185
186
    /**
187
     * Returns an array with the encryption parameters.
188
     *
189
     * @return array
190
     */
191
    private function getEncrypedHLSParameters(): array
192
    {
193
        if (!$this->encryptionKey) {
194
            return [];
195
        }
196
197
        $keyInfoPath = $this->rotateEncryptionKey();
198
        $parameters  = ['-hls_key_info_file', $keyInfoPath];
199
200
        if ($this->rotateEncryptiongKey) {
201
            $parameters[] = '-hls_flags';
202
            $parameters[] = 'periodic_rekey';
203
        }
204
205
        return $parameters;
206
    }
207
208
    /**
209
     * Adds a listener and handler to rotate the key on
210
     * every new HLS segment.
211
     *
212
     * @return void
213
     */
214
    private function addHandlerToRotateEncryptionKey()
215
    {
216
        if (!$this->rotateEncryptiongKey) {
217
            return;
218
        }
219
220
        $this->addListener($this->listener = new StdListener)->onEvent('listen', function ($line) {
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

220
        $this->/** @scrutinizer ignore-call */ 
221
               addListener($this->listener = new StdListener)->onEvent('listen', function ($line) {
Loading history...
221
            if (!strpos($line, ".keyinfo' for reading")) {
222
                return;
223
            }
224
225
            $this->segmentsOpened++;
226
227
            if ($this->segmentsOpened % $this->segmentsPerKey === 0) {
228
                $this->rotateEncryptionKey();
229
            }
230
        });
231
    }
232
233
    /**
234
     * While encoding, the encryption keys are saved to a temporary directory.
235
     * With this method, we loop through all segment playlists and replace
236
     * the absolute path to the keys to a relative ones.
237
     *
238
     * @param \Illuminate\Support\Collection $playlistMedia
239
     * @return void
240
     */
241
    private function replaceAbsolutePathsHLSEncryption(Collection $playlistMedia)
242
    {
243
        if (!$this->encryptionSecretsRoot) {
244
            return;
245
        }
246
247
        $playlistMedia->each(function ($playlistMedia) {
248
            $disk = $playlistMedia->getDisk();
249
            $path = $playlistMedia->getPath();
250
251
            $prefix = '#EXT-X-KEY:METHOD=AES-128,URI="';
252
253
            $content = str_replace(
0 ignored issues
show
Unused Code introduced by
The assignment to $content is dead and can be removed.
Loading history...
254
                $prefix . $this->encryptionSecretsRoot . '/',
255
                $prefix,
256
                $disk->get($path)
257
            );
258
259
            $content = str_replace(
260
                $prefix . Disk::normalizePath($this->encryptionSecretsRoot) . '/',
261
                $prefix,
262
                $disk->get($path)
263
            );
264
265
            $disk->put($path, $content);
266
        });
267
    }
268
269
    /**
270
     * Removes the encryption keys from the temporary disk.
271
     *
272
     * @return void
273
     */
274
    private function cleanupHLSEncryption()
275
    {
276
        if (!$this->encryptionSecretsRoot) {
277
            return;
278
        }
279
280
        (new Filesystem)->deleteDirectory($this->encryptionSecretsRoot);
281
    }
282
}
283