Passed
Pull Request — master (#265)
by Pascal
02:28
created

EncryptsHLSSegments::rotateEncryptionKey()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 32
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 4
eloc 14
c 7
b 0
f 0
nc 4
nop 0
dl 0
loc 32
rs 9.7998
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
    private $nextEncryptionKey;
69
70
    /**
71
     * Creates a new encryption key.
72
     *
73
     * @return string
74
     */
75
    public static function generateEncryptionKey(): string
76
    {
77
        return random_bytes(16);
78
    }
79
    /**
80
     * Creates a new encryption key filename.
81
     *
82
     * @return string
83
     */
84
    public static function generateEncryptionKeyFilename(): string
85
    {
86
        return bin2hex(random_bytes(8)) . '.key';
87
    }
88
89
    /**
90
     * Sets the encryption key with the given value or generates a new one.
91
     *
92
     * @param string $key
93
     * @return string
94
     */
95
    private function setEncryptionKey($key = null): string
96
    {
97
        return $this->encryptionKey = $key ?: static::generateEncryptionKey();
98
    }
99
100
    /**
101
     * Initialises the disk, info and IV for encryption and sets the key.
102
     *
103
     * @param string $key
104
     * @return self
105
     */
106
    public function withEncryptionKey($key): self
107
    {
108
        $this->encryptionSecretsRoot = app(TemporaryDirectories::class)->create();
109
110
        $this->encryptionIV = bin2hex(static::generateEncryptionKey());
111
112
        $this->setEncryptionKey($key);
113
114
        return $this;
115
    }
116
117
    /**
118
     * Enables encryption with rotating keys. The callable will receive every new
119
     * key and the integer sets the number of segments that can
120
     * use the same key.
121
     *
122
     * @param Closure $callback
123
     * @param int $segmentsPerKey
124
     * @return self
125
     */
126
    public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self
127
    {
128
        $this->rotateEncryptiongKey = true;
129
        $this->onNewEncryptionKey   = $callback;
130
        $this->segmentsPerKey       = $segmentsPerKey;
131
132
        return $this->withEncryptionKey(static::generateEncryptionKey());
133
    }
134
135
    /**
136
     * Rotates the key and returns the absolute path to the info file. This method
137
     * should be executed as fast as possible, or we might be too late for FFmpeg
138
     * opening the next segment. That's why we don't use the Disk-class magic.
139
     *
140
     * @return string
141
     */
142
    private function rotateEncryptionKey(): string
143
    {
144
        $hlsKeyInfoPath = $this->encryptionSecretsRoot . '/' . HLSExporter::HLS_KEY_INFO_FILENAME;
145
146
        // get the absolute path to the encryption key
147
        $keyFilename = $this->nextEncryptionKey ? $this->nextEncryptionKey[0] : static::generateEncryptionKeyFilename();
148
        $keyPath     = $this->encryptionSecretsRoot . '/' . $keyFilename;
149
150
        $encryptionKey = $this->setEncryptionKey($this->nextEncryptionKey ? $this->nextEncryptionKey[1] : null);
151
152
        $this->nextEncryptionKey = null;
153
154
        $normalizedKeyPath = Disk::normalizePath($keyPath);
155
156
        // randomize the encryption key
157
        file_put_contents($keyPath, $encryptionKey);
158
159
        // generate an info file with a reference to the encryption key and IV
160
        file_put_contents(
161
            $hlsKeyInfoPath,
162
            $normalizedKeyPath . PHP_EOL . $normalizedKeyPath . PHP_EOL . $this->encryptionIV
163
        );
164
165
        $this->nextEncryptionKey = [static::generateEncryptionKeyFilename(), static::generateEncryptionKey()];
166
167
        // call the callback
168
        if ($this->onNewEncryptionKey) {
169
            call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey, $this->listener);
170
        }
171
172
        // return the absolute path to the info file
173
        return Disk::normalizePath($hlsKeyInfoPath);
174
    }
175
176
    /**
177
     * Returns an array with the encryption parameters.
178
     *
179
     * @return array
180
     */
181
    private function getEncrypedHLSParameters(): array
182
    {
183
        if (!$this->encryptionKey) {
184
            return [];
185
        }
186
187
        $keyInfoPath = $this->rotateEncryptionKey();
188
        $parameters  = ['-hls_key_info_file', $keyInfoPath];
189
190
        if ($this->rotateEncryptiongKey) {
191
            $parameters[] = '-hls_flags';
192
            $parameters[] = 'periodic_rekey';
193
        }
194
195
        return $parameters;
196
    }
197
198
    /**
199
     * Adds a listener and handler to rotate the key on
200
     * every new HLS segment.
201
     *
202
     * @return void
203
     */
204
    private function addHandlerToRotateEncryptionKey()
205
    {
206
        if (!$this->rotateEncryptiongKey) {
207
            return;
208
        }
209
210
        $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

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