Test Failed
Push — master ( fb5eec...70b4d1 )
by Sebastian
04:40 queued 02:12
created

EncryptionTokenProvider::getKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 14
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of the Linna Csrf Guard.
7
 *
8
 * @author Sebastian Rapetti <[email protected]>
9
 * @copyright (c) 2020, Sebastian Rapetti
10
 * @license http://opensource.org/licenses/MIT MIT License
11
 */
12
13
namespace Linna\CsrfGuard\Provider;
14
15
use Linna\CsrfGuard\Exception\BadExpireException;
16
use Linna\CsrfGuard\Exception\BadStorageSizeException;
17
use Linna\CsrfGuard\Exception\BadExpireTrait;
18
use Linna\CsrfGuard\Exception\BadStorageSizeTrait;
19
use Linna\CsrfGuard\Exception\SessionNotStartedException;
20
use Linna\CsrfGuard\Exception\SessionNotStartedTrait;
21
22
/**
23
 * Csrf Encryption Based Token Pattern Provider.
24
 *
25
 * <p>It use sodium_crypto_aead_xchacha20poly1305_ietf_encrypt fuction to encrypt
26
 * the token.</p>
27
 *
28
 * <p>This token works storing a different key for session and a different nonce for every token in session, store the
29
 * complete encrypted token isn't stored because the token is valid only if the server is able to decrypt it.</p>
30
 *
31
 * <p>An attacker should know the key and the nonce and the time to craft a valid token for the specific session.</p>
32
 *
33
 * <p>The space needed is token-length indipendent, 32 bytes for the key and 24 bytes for the nonce. Neet to consider
34
 * that the key is stored once in session, nonce is stored for every token.</p>
35
 *
36
 */
37
class EncryptionTokenProvider implements TokenProviderInterface
38
{
39
    use BadExpireTrait;
40
    use BadStorageSizeTrait;
41
    use SessionNotStartedTrait;
42
43
    /** @var string CSRF_ENCRYPTION_KEY Encryption key name in session array. */
44
    private const CSRF_ENCRYPTION_KEY = 'csrf_encryption_key';
45
46
    /** @var string CSRF_ENCRYPTION_NONCE Encryption nonce name in session array. */
47
    private const CSRF_ENCRYPTION_NONCE = 'csrf_encryption_nonce';
48
49
    /** @var int CSRF_MESSAGE_LEN Message lenght in bytes. */
50
    private const CSRF_MESSAGE_LEN = 32;
51
52
    /** @var int $expire Token validity in seconds, default 600 -> 10 minutes. */
53
    private int $expire = 0;
54
55
    /** @var int $storageSize Maximum token nonces stored in session. */
56
    private int $storageSize = 0;
57
58
    /**
59
     * Class constructor.
60
     *
61
     * @param int $expire      Token validity in seconds, default 600 -> 10 minutes.
62
     * @param int $storageSize Maximum token nonces stored for a session.
63
     *
64
     * @throws BadExpireException         If <code>$expire</code> is less than 0 and greater than 86400.
65
     * @throws BadStorageSizeException    If <code>$storageSize</code> is less than 2 and greater than 64.
66
     * @throws SessionNotStartedException If sessions are disabled or no session is started.
67
     */
68
    public function __construct(int $expire = 600, int $storageSize = 10)
69
    {
70
        // from BadExpireTrait, BadStorageSizeTrait and SessionNotStartedTrait
71
        /** @throws BadExpireException */
72
        $this->checkBadExpire($expire);
73
        /** @throws BadStorageSizeException */
74
        $this->checkBadStorageSize($storageSize);
75
        /** @throws SessionNotStartedException */
76
        $this->checkSessionNotStarted();
77
78
        $this->expire = $expire;
79
        $this->storageSize = $storageSize;
80
81
        //if no nonce stored, initialize the session storage
82
        $_SESSION[self::CSRF_ENCRYPTION_NONCE] ??= [];
83
    }
84
85
    /**
86
     * Return new Encryption based Token.
87
     *
88
     * @return string The token in hex format.
89
     */
90
    public function getToken(): string
91
    {
92
        //get the key for encryption
93
        $key = $this->getKey();
94
        //get a new nonce for encryption as result of
95
        //random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
96
        $nonce = $this->getNonce();
97
        $additionlData = \sodium_bin2hex($nonce);
98
99
        //get current time
100
        $time = \base_convert((string) \time(), 10, 16);
101
        //build message
102
        $message = \sodium_bin2hex(\random_bytes(self::CSRF_MESSAGE_LEN)).$time;
103
104
        //create ciphertext
105
        //https://www.php.net/manual/en/function.sodium-crypto-aead-xchacha20poly1305-ietf-encrypt.php
106
        //https://libsodium.gitbook.io/doc/secret-key_cryptography/aead/chacha20-poly1305/ietf_chacha20-poly1305_construction
107
        $ciphertext = \sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($message, $additionlData, $nonce, $key);
108
109
        return \sodium_bin2hex($ciphertext);
110
    }
111
112
    /**
113
     * Validate Encryption based Token.
114
     *
115
     * @param string $token Token must be validated, hex format.
116
     *
117
     * @return bool True if the token is valid, false otherwise.
118
     */
119
    public function validate(string $token): bool
120
    {
121
        //convert hex token to raw bytes
122
        $hex_token = \sodium_hex2bin($token);
123
124
        // plain text returned from check encryption
125
        $plainText = '';
126
127
        //plainText variable is passed as reference,
128
        //if checkEncrption method end without errors then checkTime method
129
        //receive a filled plainText variable as argument else
130
        //short circuiting make the if block skipped
131
        if ($this->checkEncryption($hex_token, $plainText) && $this->checkTime($plainText)) {
132
            return true;
133
        }
134
135
        return false;
136
    }
137
138
    /**
139
     * Check if token is expired.
140
     *
141
     * @param string $token Token after decryption.
142
     *
143
     * @return bool True if token isn't expired, false otherwise.
144
     */
145
    private function checkTime(string $token): bool
146
    {
147
        $time = \substr($token, self::CSRF_MESSAGE_LEN * 2);
148
149
        //timestamp from token time
150
        $timestamp = (int) \base_convert($time, 16, 10);
151
152
        //token expiration check
153
        if ($timestamp + $this->expire < \time()) {
154
            return false;
155
        }
156
157
        return true;
158
    }
159
160
    /**
161
     * Try to decrypt an encrypted token.
162
     *
163
     * @param string $encryptedToken Encrypted token.
164
     * @param string $plainText      Variable passed as reference to store the result.
165
     *
166
     * @return bool True in the encrypted token decrypt successfully, false otherwise.
167
     */
168
    private function checkEncryption(string $encryptedToken, string &$plainText): bool
169
    {
170
        //get the key for encryption
171
        $key = $this->getKey();
172
        //reference to nonce storage in session
173
        $nonces = &$_SESSION[self::CSRF_ENCRYPTION_NONCE];
174
        //get current size of nonce storage in session
175
        $size = \count($nonces);
176
177
        //try to decrypt starting from last stored nonce
178
        for ($i = $size -1; $i > -1; $i--) {
179
            //get nonce
180
            $nonce = $nonces[$i];
181
            //generate addition data
182
            $additionlData = \sodium_bin2hex($nonce);
183
184
            //for successful decryption return true
185
            if (($tmpPlainText = \sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encryptedToken, $additionlData, $nonce, $key))) {
186
                //plainText will remain string if sodium_crypto return false
187
                //todo, check in php source code if sodium_crypto
188
                //return false if fail
189
                $plainText = $tmpPlainText;
190
                //no need to check if the plaintext is the same because if the token is tampered decryption doesn't
191
                //work
192
                return true;
193
            }
194
        }
195
196
        return false;
197
    }
198
199
    /**
200
     * Return the encryption key for the currente session.
201
     *
202
     * <p>Generate a different key for every session.</p>
203
     *
204
     * @return string The encryption key.
205
     */
206
    private function getKey(): string
207
    {
208
        //if key is already stored, return it
209
        if (isset($_SESSION[self::CSRF_ENCRYPTION_KEY])) {
210
            return $_SESSION[self::CSRF_ENCRYPTION_KEY];
211
        }
212
213
        //generate new key
214
        $key = \sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
215
216
        //store new key
217
        $_SESSION[self::CSRF_ENCRYPTION_KEY] = $key;
218
219
        return $key;
220
    }
221
222
    /**
223
     * Generate a new nonce to encrypt the token.
224
     *
225
     * @return string The new nonce.
226
     */
227
    private function getNonce(): string
228
    {
229
        //generate new nonce
230
        $nonce = \random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
231
232
        //store new nonce
233
        $_SESSION[self::CSRF_ENCRYPTION_NONCE][] = $nonce;
234
235
        //check if the storage is growt beyond the maximun size
236
        if (\count($_SESSION[self::CSRF_ENCRYPTION_NONCE]) > $this->storageSize) {
237
            //remove the oldest stores nonce
238
            \array_shift($_SESSION[self::CSRF_ENCRYPTION_NONCE]);
239
        }
240
241
        return $nonce;
242
    }
243
}
244