Passed
Pull Request — master (#262)
by Pascal
02:44
created

EncryptsHLSSegments::rotateEncryptionKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 28
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 13
c 2
b 0
f 0
nc 2
nop 0
dl 0
loc 28
rs 9.8333
1
<?php
2
3
namespace ProtoneMedia\LaravelFFMpeg\Exporters;
4
5
use Closure;
6
use Illuminate\Support\Collection;
7
use Illuminate\Support\Str;
8
use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener;
9
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk;
10
11
trait EncryptsHLSSegments
12
{
13
    /**
14
     * The encryption key.
15
     *
16
     * @var string
17
     */
18
    private $encryptionKey;
19
20
    /**
21
     * Gets called whenever a new encryption key is set.
22
     *
23
     * @var callable
24
     */
25
    private $onNewEncryptionKey;
26
27
    /**
28
     * Disk to store the secrets.
29
     *
30
     * @var \ProtoneMedia\LaravelFFMpeg\Filesystem\Disk
31
     */
32
    private $encryptionSecretsDisk;
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
     * Creates a new encryption key.
64
     *
65
     * @return string
66
     */
67
    public static function generateEncryptionKey(): string
68
    {
69
        return random_bytes(16);
70
    }
71
72
    /**
73
     * Sets the encryption key with the given value or generates a new one.
74
     *
75
     * @param string $key
76
     * @return string
77
     */
78
    private function setEncryptionKey($key = null): string
79
    {
80
        return $this->encryptionKey = $key ?: static::generateEncryptionKey();
81
    }
82
83
    /**
84
     * Initialises the disk, info and IV for encryption and sets the key.
85
     *
86
     * @param string $key
87
     * @return self
88
     */
89
    public function withEncryptionKey($key): self
90
    {
91
        $this->encryptionSecretsDisk = Disk::makeTemporaryDisk();
92
        $this->encryptionIV          = bin2hex(static::generateEncryptionKey());
93
94
        $this->setEncryptionKey($key);
95
96
        return $this;
97
    }
98
99
    /**
100
     * Enables encryption with rotating keys. The callable will receive every new
101
     * key and the integer sets the number of segments that can
102
     * use the same key.
103
     *
104
     * @param Closure $callback
105
     * @param int $segmentsPerKey
106
     * @return self
107
     */
108
    public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self
109
    {
110
        $this->rotateEncryptiongKey = true;
111
        $this->onNewEncryptionKey   = $callback;
112
        $this->segmentsPerKey       = $segmentsPerKey;
113
114
        return $this->withEncryptionKey(static::generateEncryptionKey());
115
    }
116
117
    /**
118
     * Rotates the key and returns the absolute path to the info file.
119
     *
120
     * @return string
121
     */
122
    private function rotateEncryptionKey(): string
123
    {
124
        // randomize the encryption key
125
        $this->encryptionSecretsDisk->put(
0 ignored issues
show
Bug introduced by
The method put() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

125
        $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ 
126
                                      put(
Loading history...
126
            $keyFilename = uniqid() . '.key',
127
            $encryptionKey = $this->setEncryptionKey()
128
        );
129
130
        // get the absolute path to the encryption key
131
        $keyPath = $this->encryptionSecretsDisk->makeMedia($keyFilename)->getLocalPath();
132
133
        // generate an info file with a reference to the encryption key and IV
134
        $this->encryptionSecretsDisk->put(
135
            HLSExporter::HLS_KEY_INFO_FILENAME,
136
            implode(PHP_EOL, [
137
                $keyPath, $keyPath, $this->encryptionIV,
138
            ])
139
        );
140
141
        // call the callback
142
        if ($this->onNewEncryptionKey) {
143
            call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey);
144
        }
145
146
        // return the absolute path to the info file
147
        return $this->encryptionSecretsDisk
148
            ->makeMedia(HLSExporter::HLS_KEY_INFO_FILENAME)
149
            ->getLocalPath();
150
    }
151
152
    /**
153
     * Returns an array with the encryption parameters.
154
     *
155
     * @return array
156
     */
157
    private function getEncrypedHLSParameters(): array
158
    {
159
        if (!$this->encryptionKey) {
160
            return [];
161
        }
162
163
        $keyInfoPath = $this->rotateEncryptionKey();
164
        $parameters  = ['-hls_key_info_file', $keyInfoPath];
165
166
        if ($this->rotateEncryptiongKey) {
167
            $parameters[] = '-hls_flags';
168
            $parameters[] = 'periodic_rekey';
169
        }
170
171
        return $parameters;
172
    }
173
174
    /**
175
     * Adds a listener and handler to rotate the key on
176
     * every new HLS segment.
177
     *
178
     * @return void
179
     */
180
    private function addHandlerToRotateEncryptionKey()
181
    {
182
        if (!$this->rotateEncryptiongKey) {
183
            return;
184
        }
185
186
        $this->addListener(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

186
        $this->/** @scrutinizer ignore-call */ 
187
               addListener(new StdListener)->onEvent('listen', function ($line) {
Loading history...
187
            $opensEncryptedSegment = Str::contains($line, "Opening 'crypto:/")
188
                && Str::contains($line, ".ts' for writing");
189
190
            if (!$opensEncryptedSegment) {
191
                return;
192
            }
193
194
            $this->segmentsOpened++;
195
196
            if ($this->segmentsOpened % $this->segmentsPerKey === 0) {
197
                $this->rotateEncryptionKey();
198
            }
199
        });
200
    }
201
202
    /**
203
     * While encoding, the encryption keys are saved to a temporary directory.
204
     * With this method, we loop through all segment playlists and replace
205
     * the absolute path to the keys to a relative ones.
206
     *
207
     * @param \Illuminate\Support\Collection $playlistMedia
208
     * @return void
209
     */
210
    private function replaceAbsolutePathsHLSEncryption(Collection $playlistMedia)
211
    {
212
        if (!$this->encryptionSecretsDisk) {
213
            return;
214
        }
215
216
        $playlistMedia->each(function ($playlistMedia) {
217
            $disk = $playlistMedia->getDisk();
218
            $path = $playlistMedia->getPath();
219
220
            $prefix = '#EXT-X-KEY:METHOD=AES-128,URI="';
221
222
            $content = str_replace(
223
                $prefix . $this->encryptionSecretsDisk->path(''),
0 ignored issues
show
Bug introduced by
The method path() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

223
                $prefix . $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ path(''),
Loading history...
224
                $prefix,
225
                $disk->get($path)
226
            );
227
228
            $disk->put($path, $content);
229
        });
230
    }
231
232
    /**
233
     * Removes the encryption keys from the temporary disk.
234
     *
235
     * @return void
236
     */
237
    private function cleanupHLSEncryption()
238
    {
239
        if (!$this->encryptionSecretsDisk) {
240
            return;
241
        }
242
243
        $paths = $this->encryptionSecretsDisk->allFiles();
0 ignored issues
show
Bug introduced by
The method allFiles() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

243
        /** @scrutinizer ignore-call */ 
244
        $paths = $this->encryptionSecretsDisk->allFiles();
Loading history...
244
245
        foreach ($paths as $path) {
246
            $this->encryptionSecretsDisk->delete($path);
0 ignored issues
show
Bug introduced by
The method delete() does not exist on ProtoneMedia\LaravelFFMpeg\Filesystem\Disk. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

246
            $this->encryptionSecretsDisk->/** @scrutinizer ignore-call */ 
247
                                          delete($path);
Loading history...
247
        }
248
    }
249
}
250