Passed
Pull Request — master (#265)
by Pascal
02:28
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 Illuminate\Support\Str;
9
use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener;
10
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk;
11
use ProtoneMedia\LaravelFFMpeg\Filesystem\TemporaryDirectories;
12
use Symfony\Component\Process\Process;
13
14
trait EncryptsHLSSegments
15
{
16
    /**
17
     * The encryption key.
18
     *
19
     * @var string
20
     */
21
    private $encryptionKey;
22
23
    /**
24
     * Gets called whenever a new encryption key is set.
25
     *
26
     * @var callable
27
     */
28
    private $onNewEncryptionKey;
29
30
    /**
31
     * Disk to store the secrets.
32
     */
33
    private $encryptionSecretsRoot;
34
35
    /**
36
     * Encryption IV
37
     *
38
     * @var string
39
     */
40
    private $encryptionIV;
41
42
    /**
43
     * Wether to rotate the key on every segment.
44
     *
45
     * @var boolean
46
     */
47
    private $rotateEncryptiongKey = false;
48
49
    /**
50
     * Number of opened segments.
51
     *
52
     * @var integer
53
     */
54
    private $segmentsOpened = 0;
55
56
    /**
57
     * Number of segments that can use the same key.
58
     *
59
     * @var integer
60
     */
61
    private $segmentsPerKey = 1;
62
63
    /**
64
     * Listener that will rotate the key.
65
     *
66
     * @var \ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener
67
     */
68
    private $listener;
69
70
    private $nextEncryptionKey;
71
72
    /**
73
     * Creates a new encryption key.
74
     *
75
     * @return string
76
     */
77
    public static function generateEncryptionKey(): string
78
    {
79
        return random_bytes(16);
80
    }
81
    /**
82
     * Creates a new encryption key filename.
83
     *
84
     * @return string
85
     */
86
    public static function generateEncryptionKeyFilename(): string
87
    {
88
        return bin2hex(random_bytes(8)) . '.key';
89
    }
90
91
    /**
92
     * Sets the encryption key with the given value or generates a new one.
93
     *
94
     * @param string $key
95
     * @return string
96
     */
97
    private function setEncryptionKey($key = null): string
98
    {
99
        return $this->encryptionKey = $key ?: static::generateEncryptionKey();
100
    }
101
102
    /**
103
     * Initialises the disk, info and IV for encryption and sets the key.
104
     *
105
     * @param string $key
106
     * @return self
107
     */
108
    public function withEncryptionKey($key): self
109
    {
110
        $this->encryptionSecretsRoot = app(TemporaryDirectories::class)->create();
111
112
        $this->encryptionIV = bin2hex(static::generateEncryptionKey());
113
114
        $this->setEncryptionKey($key);
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(static::generateEncryptionKey());
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
        $hlsKeyInfoPath = $this->encryptionSecretsRoot . '/' . HLSExporter::HLS_KEY_INFO_FILENAME;
147
148
        // get the absolute path to the encryption key
149
        $keyFilename = $this->nextEncryptionKey ? $this->nextEncryptionKey[0] : static::generateEncryptionKeyFilename();
150
        $keyPath     = $this->encryptionSecretsRoot . '/' . $keyFilename;
151
152
        $encryptionKey = $this->setEncryptionKey($this->nextEncryptionKey ? $this->nextEncryptionKey[1] : null);
153
154
        $normalizedKeyPath = Disk::normalizePath($keyPath);
155
156
        // generate an info file with a reference to the encryption key and IV
157
        file_put_contents(
158
            $hlsKeyInfoPath,
159
            $normalizedKeyPath . PHP_EOL . $normalizedKeyPath . PHP_EOL . $this->encryptionIV,
160
            LOCK_EX
161
        );
162
163
        // randomize the encryption key
164
        file_put_contents($keyPath, $encryptionKey, LOCK_EX);
165
166
        // call the callback
167
        if ($this->onNewEncryptionKey) {
168
            call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey, $this->listener);
169
        }
170
171
        if ($this->listener) {
172
            $this->listener->handle(Process::OUT, "Generated new key with filename: {$keyFilename}");
173
        }
174
175
        $this->nextEncryptionKey = [static::generateEncryptionKeyFilename(), static::generateEncryptionKey()];
176
177
        // return the absolute path to the info file
178
        return Disk::normalizePath($hlsKeyInfoPath);
179
    }
180
181
    /**
182
     * Returns an array with the encryption parameters.
183
     *
184
     * @return array
185
     */
186
    private function getEncrypedHLSParameters(): array
187
    {
188
        if (!$this->encryptionKey) {
189
            return [];
190
        }
191
192
        $keyInfoPath = $this->rotateEncryptionKey();
193
        $parameters  = ['-hls_key_info_file', $keyInfoPath];
194
195
        if ($this->rotateEncryptiongKey) {
196
            $parameters[] = '-hls_flags';
197
            $parameters[] = 'periodic_rekey';
198
        }
199
200
        return $parameters;
201
    }
202
203
    /**
204
     * Adds a listener and handler to rotate the key on
205
     * every new HLS segment.
206
     *
207
     * @return void
208
     */
209
    private function addHandlerToRotateEncryptionKey()
210
    {
211
        if (!$this->rotateEncryptiongKey) {
212
            return;
213
        }
214
215
        $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

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