Completed
Pull Request — master (#15)
by Helpful
02:08
created

HybridSessionStore_Crypto::decrypt()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 24
rs 8.9714
cc 2
eloc 16
nc 2
nop 1
1
<?php
2
3
/**
4
 * PHP 5.4 defines SessionHandlerInterface, but PHP 5.3 doesn't. For backwards compatibility, if it doesn't exist
5
 * (and no other fallback exists in other libraries) then define it.
6
 *
7
 * Then, either way, add a new function "register_sessionhandler" which takes a SessionHandlerInterface and
8
 * registers it (including registering session_write_close as a shutdown function)
9
 */
10
if (!interface_exists('SessionHandlerInterface')) {
11
    interface SessionHandlerInterface
0 ignored issues
show
Coding Style Compatibility introduced by
Each interface must be in a namespace of at least one level (a top-level vendor name)

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
12
    {
13
        /* Methods */
14
        public function close();
15
        public function destroy($session_id);
16
        public function gc($maxlifetime);
17
        public function open($save_path, $name);
18
        public function read($session_id);
19
        public function write($session_id, $session_data);
20
    }
21
}
22
23
if (version_compare(PHP_VERSION, '5.4.0', '<')) {
24
    function register_sessionhandler($handler)
25
    {
26
        session_set_save_handler(
27
            array($handler, 'open'),
28
            array($handler, 'close'),
29
            array($handler, 'read'),
30
            array($handler, 'write'),
31
            array($handler, 'destroy'),
32
            array($handler, 'gc')
33
        );
34
35
        register_shutdown_function('session_write_close');
36
    }
37
} else {
38
    function register_sessionhandler($handler)
0 ignored issues
show
Best Practice introduced by
The function register_sessionhandler() has been defined more than once; this definition is ignored, only the first definition in this file (L24-36) is considered.

This check looks for functions that have already been defined in the same file.

Some Codebases, like WordPress, make a practice of defining functions multiple times. This may lead to problems with the detection of function parameters and types. If you really need to do this, you can mark the duplicate definition with the @ignore annotation.

/**
 * @ignore
 */
function getUser() {

}

function getUser($id, $realm) {

}

See also the PhpDoc documentation for @ignore.

Loading history...
39
    {
40
        session_set_save_handler($handler, true);
41
    }
42
}
43
44
/**
45
 * Class HybridSessionStore_Crypto
46
 * Some cryptography used for Session cookie encryption. Requires the mcrypt extension.
47
 *
48
 */
49
class HybridSessionStore_Crypto
0 ignored issues
show
Coding Style Compatibility introduced by
Each interface must be in a file by itself

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
50
{
51
52
    private $key;
53
    private $ivSize;
54
    private $keySize;
55
56
    public $salt;
57
    private $saltedKey;
58
59
    /**
60
     * @param $key a per-site secret string which is used as the base encryption key.
61
     * @param $salt a per-session random string which is used as a salt to generate a per-session key
62
     *
63
     * The base encryption key needs to stay secret. If an attacker ever gets it, they can read their session,
64
     * and even modify & re-sign it.
65
     *
66
     * The salt is a random per-session string that is used with the base encryption key to create a per-session key.
67
     * This (amongst other things) makes sure an attacker can't use a known-plaintext attack to guess the key.
68
     *
69
     * Normally we could create a salt on encryption, send it to the client as part of the session (it doesn't
70
     * need to remain secret), then use the returned salt to decrypt. But we already have the Session ID which makes
71
     * a great salt, so no need to generate & handle another one.
72
     */
73
    public function __construct($key, $salt)
74
    {
75
        $this->ivSize = mcrypt_get_iv_size(MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CBC);
76
        $this->keySize = mcrypt_get_key_size(MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CBC);
77
78
        $this->key = $key;
79
        $this->salt = $salt;
80
        $this->saltedKey = hash_pbkdf2('sha256', $this->key, $this->salt, 1000, $this->keySize, true);
81
    }
82
83
    /**
84
     * Encrypt and then sign some cleartext
85
     *
86
     * @param $cleartext - The cleartext to encrypt and sign
87
     * @return string - The encrypted-and-signed message as base64 ASCII.
88
     */
89
    public function encrypt($cleartext)
90
    {
91
        $iv = mcrypt_create_iv($this->ivSize, MCRYPT_DEV_URANDOM);
92
93
        $enc = mcrypt_encrypt(
94
            MCRYPT_RIJNDAEL_256,
95
            $this->saltedKey,
96
            $cleartext,
97
            MCRYPT_MODE_CBC,
98
            $iv
99
        );
100
101
        $hash = hash_hmac('sha256', $enc, $this->saltedKey);
102
103
        return base64_encode($iv.$hash.$enc);
104
    }
105
106
    /**
107
     * Check the signature on an encrypted-and-signed message, and if valid decrypt the content
108
     *
109
     * @param $data - The encrypted-and-signed message as base64 ASCII
110
     * @return bool|string - The decrypted cleartext or false if signature failed
111
     */
112
    public function decrypt($data)
113
    {
114
        $data = base64_decode($data);
115
116
        $iv   = substr($data, 0, $this->ivSize);
117
        $hash = substr($data, $this->ivSize, 64);
118
        $enc  = substr($data, $this->ivSize + 64);
119
120
        $cleartext = rtrim(mcrypt_decrypt(
121
            MCRYPT_RIJNDAEL_256,
122
            $this->saltedKey,
123
            $enc,
124
            MCRYPT_MODE_CBC,
125
            $iv
126
        ), "\x00");
127
128
        // Needs to be after decrypt so it always runs, to avoid timing attack
129
        $gen_hash = hash_hmac('sha256', $enc, $this->saltedKey);
130
131
        if ($gen_hash == $hash) {
132
            return $cleartext;
133
        }
134
        return false;
135
    }
136
}
137
138
abstract class HybridSessionStore_Base implements SessionHandlerInterface
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
139
{
140
141
    /**
142
     * Session secret key
143
     *
144
     * @var string
145
     */
146
    protected $key = null;
147
148
    /**
149
     * Assign a new session secret key
150
     *
151
     * @param string $key
152
     */
153
    public function setKey($key)
154
    {
155
        $this->key = $key;
156
    }
157
158
    /**
159
     * Get the session secret key
160
     *
161
     * @return string
162
     */
163
    protected function getKey()
164
    {
165
        return $this->key;
166
    }
167
168
    /**
169
     * Get lifetime in number of seconds
170
     *
171
     * @return int
172
     */
173
    protected function getLifetime()
174
    {
175
        $params = session_get_cookie_params();
176
        $cookieLifetime = (int)$params['lifetime'];
177
        $gcLifetime = (int)ini_get('session.gc_maxlifetime');
178
        return $cookieLifetime ? min($cookieLifetime, $gcLifetime) : $gcLifetime;
179
    }
180
181
    /**
182
     * Gets the current unix timestamp
183
     *
184
     * @return int
185
     */
186
    protected function getNow()
187
    {
188
        return (int)SS_Datetime::now()->Format('U');
189
    }
190
}
191
192
/**
193
 * Class HybridSessionStore_Cookie
194
 *
195
 * A session store which stores the session data in an encrypted & signed cookie.
196
 *
197
 * This way the server doesn't need to open a database connection or have a shared filesystem for reading
198
 * the session from - the client passes through the session with every request.
199
 *
200
 * This approach does have some limitations - cookies can only be quite small (4K total, but we limit to 1K)
201
 * and can only be set _before_ the server starts sending a response.
202
 *
203
 * So we clear the cookie on Session startup (which should always be before the headers get sent), but just
204
 * fail on Session write if we can't use cookies, assuming there's something watching for that & providing a fallback
205
 */
206
class HybridSessionStore_Cookie extends HybridSessionStore_Base
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
207
{
208
209
    /**
210
     * Maximum length of a cookie value in characters
211
     *
212
     * @var int
213
     * @config
214
     */
215
    private static $max_length = 1024;
0 ignored issues
show
Unused Code introduced by
The property $max_length is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
216
217
    /**
218
     * Encryption service
219
     *
220
     * @var HybridSessionStore_Crypto
221
     */
222
    protected $crypto;
223
224
    /**
225
     * Name of cookie
226
     *
227
     * @var string
228
     */
229
    protected $cookie;
230
231
    /**
232
     * Known unmodified value of this cookie. If the cookie backend has been read into the application,
233
     * then the backend is unable to verify the modification state of this value internally within the
234
     * system, so this will be left null unless written back.
235
     *
236
     * If the content exceeds max_length then the backend can also not maintain this cookie, also
237
     * setting this variable to null.
238
     *
239
     * @var string
240
     */
241
    protected $currentCookieData;
242
243
    public function open($save_path, $name)
244
    {
245
        $this->cookie = $name.'_2';
246
        // Read the incoming value, then clear the cookie - we might not be able
247
        // to do so later if write() is called after headers are sent
248
        // This is intended to force a failover to the database store if the
249
        // modified session cannot be emitted.
250
        $this->currentCookieData = Cookie::get($this->cookie);
251
        if ($this->currentCookieData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->currentCookieData of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
252
            Cookie::set($this->cookie, '');
253
        }
254
    }
255
256
    public function close()
257
    {
258
    }
259
260
    /**
261
     * Get the cryptography store for the specified session
262
     *
263
     * @param string $session_id
264
     * @return HybridSessionStore_Crypto
265
     */
266
    protected function getCrypto($session_id)
267
    {
268
        $key = $this->getKey();
269
        if (!$key) {
270
            return null;
271
        }
272
        if (!$this->crypto || $this->crypto->salt != $session_id) {
273
            $this->crypto = new HybridSessionStore_Crypto($key, $session_id);
274
        }
275
        return $this->crypto;
276
    }
277
278
    public function read($session_id)
279
    {
280
        // Check ability to safely decrypt content
281
        if (!$this->currentCookieData
282
            || !($crypto = $this->getCrypto($session_id))
283
        ) {
284
            return;
285
        }
286
287
        // Decrypt and invalidate old data
288
        $cookieData = $crypto->decrypt($this->currentCookieData);
289
        $this->currentCookieData = null;
290
291
        // Verify expiration
292
        if ($cookieData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cookieData of type string|false is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
293
            $expiry = (int)substr($cookieData, 0, 10);
294
            $data = substr($cookieData, 10);
295
296
            if ($expiry > $this->getNow()) {
297
                return $data;
298
            }
299
        }
300
    }
301
302
    /**
303
     * Determine if the session could be verifably written to cookie storage
304
     *
305
     * @return bool
306
     */
307
    protected function canWrite()
308
    {
309
        return !headers_sent();
310
    }
311
312
    public function write($session_id, $session_data)
313
    {
314
        // Check ability to safely encrypt and write content
315
        if (!$this->canWrite()
316
            || (strlen($session_data) > Config::inst()->get(__CLASS__, 'max_length'))
317
            || !($crypto = $this->getCrypto($session_id))
318
        ) {
319
            return false;
320
        }
321
322
        // Prepare content for write
323
        $params = session_get_cookie_params();
324
        // Total max lifetime, stored internally
325
        $lifetime = $this->getLifetime();
326
        $expiry = $this->getNow() + $lifetime;
327
328
        // Restore the known good cookie value
329
        $this->currentCookieData = $this->crypto->encrypt(
330
            sprintf('%010u', $expiry) . $session_data
331
        );
332
333
        // Respect auto-expire on browser close for the session cookie (in case the cookie lifetime is zero)
334
        $cookieLifetime = min((int)$params['lifetime'], $lifetime);
335
        Cookie::set(
336
            $this->cookie,
337
            $this->currentCookieData,
338
            $cookieLifetime / 86400,
339
            $params['path'],
340
            $params['domain'],
341
            $params['secure'],
342
            $params['httponly']
343
        );
344
345
        return true;
346
    }
347
348
    public function destroy($session_id)
349
    {
350
        $this->currentCookieData = null;
351
        Cookie::force_expiry($this->cookie);
352
    }
353
354
    public function gc($maxlifetime)
355
    {
356
        // NOP
357
    }
358
}
359
360
class HybridSessionStore_Database extends HybridSessionStore_Base
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
361
{
362
363
    /**
364
     * Determine if the DB is ready to use.
365
     *
366
     * @return bool
367
     * @throws Exception
368
     */
369
    protected function isDatabaseReady()
370
    {
371
        // Such as during setup of testsession prior to DB connection.
372
        if (!DB::isActive()) {
0 ignored issues
show
Deprecated Code introduced by
The method DB::isActive() has been deprecated with message: since version 4.0 Use DB::is_active instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
373
            return false;
374
        }
375
376
        // If we have a DB of the wrong type then complain
377
        if (!(DB::getConn() instanceof MySQLDatabase)) {
0 ignored issues
show
Deprecated Code introduced by
The method DB::getConn() has been deprecated with message: since version 4.0 Use DB::get_conn instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
378
            throw new Exception('HybridSessionStore currently only works with MySQL databases');
379
        }
380
381
        // Prevent freakout during dev/build
382
        return ClassInfo::hasTable('HybridSessionDataObject');
383
    }
384
385
    public function open($save_path, $name)
386
    {
387
    }
388
389
    public function close()
390
    {
391
    }
392
393
    public function read($session_id)
394
    {
395
        if (!$this->isDatabaseReady()) {
396
            return null;
397
        }
398
399
        $result = DB::query(sprintf(
400
            'SELECT "Data" FROM "HybridSessionDataObject"
401
			WHERE "SessionID" = \'%s\' AND "Expiry" >= %u',
402
            Convert::raw2sql($session_id),
403
            $this->getNow()
404
        ));
405
406
        if ($result && $result->numRecords()) {
407
            $data = $result->first();
408
            return $data['Data'];
409
        }
410
    }
411
412
    public function write($session_id, $session_data)
413
    {
414
        if (!$this->isDatabaseReady()) {
415
            return false;
416
        }
417
418
        $expiry = $this->getNow() + $this->getLifetime();
419
        DB::query($str = sprintf(
420
            'INSERT INTO "HybridSessionDataObject" ("SessionID", "Expiry", "Data")
421
			VALUES (\'%1$s\', %2$u, \'%3$s\')
422
			ON DUPLICATE KEY UPDATE "Expiry" = %2$u, "Data" = \'%3$s\'',
423
            Convert::raw2sql($session_id),
424
            $expiry,
425
            Convert::raw2sql($session_data)
426
        ));
427
428
        return true;
429
    }
430
431
    public function destroy($session_id)
432
    {
433
        // NOP
434
    }
435
436
    public function gc($maxlifetime)
437
    {
438
        if (!$this->isDatabaseReady()) {
439
            return;
440
        }
441
        DB::query(sprintf(
442
            'DELETE FROM "HybridSessionDataObject" WHERE "Expiry" < %u',
443
            $this->getNow()
444
        ));
445
    }
446
}
447
448
449
class HybridSessionStore extends HybridSessionStore_Base
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
450
{
451
452
    /**
453
     * List of session handlers
454
     *
455
     * @var array[HybridSessionStore_Base]
456
     */
457
    protected $handlers = array();
458
459
    /**
460
     * True if this session store has been initialised
461
     *
462
     * @var bool
463
     */
464
    protected static $enabled = false;
465
466
    /**
467
     * @param array[HybridSessionStore_Base]
468
     */
469
    public function setHandlers($handlers)
470
    {
471
        $this->handlers = $handlers;
472
        $this->setKey($this->getKey());
473
    }
474
475
    public function setKey($key)
476
    {
477
        parent::setKey($key);
478
        foreach ($this->handlers as $handler) {
479
            $handler->setKey($key);
480
        }
481
    }
482
483
    /**
484
     * @return array[SessionHandlerInterface]
0 ignored issues
show
Documentation introduced by
The doc-type array[SessionHandlerInterface] could not be parsed: Expected "]" at position 2, but found "SessionHandlerInterface". (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
485
     */
486
    public function getHandlers()
487
    {
488
        return $this->handlers;
489
    }
490
491
    public function open($save_path, $name)
492
    {
493
        foreach ($this->handlers as $handler) {
494
            $handler->open($save_path, $name);
495
        }
496
497
        return true;
498
    }
499
500
    public function close()
501
    {
502
        foreach ($this->handlers as $handler) {
503
            $handler->close();
504
        }
505
506
        return true;
507
    }
508
509
    public function read($session_id)
510
    {
511
        foreach ($this->handlers as $handler) {
512
            if ($data = $handler->read($session_id)) {
513
                return $data;
514
            }
515
        }
516
517
        return '';
518
    }
519
520
    public function write($session_id, $session_data)
521
    {
522
        foreach ($this->handlers as $handler) {
523
            if ($handler->write($session_id, $session_data)) {
524
                return;
525
            }
526
        }
527
    }
528
529
    public function destroy($session_id)
530
    {
531
        foreach ($this->handlers as $handler) {
532
            $handler->destroy($session_id);
533
        }
534
    }
535
536
    public function gc($maxlifetime)
537
    {
538
        foreach ($this->handlers as $handler) {
539
            $handler->gc($maxlifetime);
540
        }
541
    }
542
543
    /**
544
     * Register the session handler as the default
545
     *
546
     * @param string $key Desired session key
547
     */
548
    public static function init($key = null)
549
    {
550
        $instance = Injector::inst()->get(__CLASS__);
551
        if (empty($key)) {
552
            user_error(
553
                'HybridSessionStore::init() was not given a $key. Disabling cookie-based storage',
554
                E_USER_WARNING
555
            );
556
        } else {
557
            $instance->setKey($key);
558
        }
559
        register_sessionhandler($instance);
560
        self::$enabled = true;
561
    }
562
563
    public static function is_enabled()
564
    {
565
        return self::$enabled;
566
    }
567
}
568
569
class HybridSessionStore_RequestFilter implements RequestFilter
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
570
{
571
    public function preRequest(SS_HTTPRequest $request, Session $session, DataModel $model)
572
    {
573
        // NOP
574
    }
575
576
    public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model)
577
    {
578
        if (HybridSessionStore::is_enabled()) {
579
            session_write_close();
580
        }
581
    }
582
}
583