Passed
Pull Request — master (#265)
by Pascal
05:56
created

generateEncryptionKeyFilename()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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