1<?php
2/**
3 * Copyright 2015-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file COPYING for license information (LGPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @category  Horde
9 * @copyright 2015-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Imap_Client
12 */
13
14/**
15 * Provides authentication via the SCRAM SASL mechanism (RFC 5802 [3]).
16 *
17 * @author    Michael Slusarz <slusarz@horde.org>
18 * @category  Horde
19 * @copyright 2015-2017 Horde LLC
20 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
21 * @package   Imap_Client
22 * @since     2.29.0
23 */
24class Horde_Imap_Client_Auth_Scram
25{
26    /**
27     * AuthMessage (RFC 5802 [3]).
28     *
29     * @var string
30     */
31    protected $_authmsg;
32
33    /**
34     * Hash name.
35     *
36     * @var string
37     */
38    protected $_hash;
39
40    /**
41     * Number of Hi iterations (RFC 5802 [2]).
42     *
43     * @var integer
44     */
45    protected $_iterations;
46
47    /**
48     * Nonce.
49     *
50     * @var string
51     */
52    protected $_nonce;
53
54    /**
55     * Password.
56     *
57     * @var string
58     */
59    protected $_pass;
60
61    /**
62     * Server salt.
63     *
64     * @var string
65     */
66    protected $_salt;
67
68    /**
69     * Calculated server signature value.
70     *
71     * @var string
72     */
73    protected $_serversig;
74
75    /**
76     * Username.
77     *
78     * @var string
79     */
80    protected $_user;
81
82    /**
83     * Constructor.
84     *
85     * @param string $user  Username.
86     * @param string $pass  Password.
87     * @param string $hash  Hash name.
88     *
89     * @throws Horde_Imap_Client_Exception
90     */
91    public function __construct($user, $pass, $hash = 'SHA1')
92    {
93        $error = false;
94
95        $this->_hash = $hash;
96
97        try {
98            if (!class_exists('Horde_Stringprep') ||
99                !class_exists('Horde_Crypt_Blowfish_Pbkdf2')) {
100                throw new Exception();
101            }
102
103            Horde_Stringprep::autoload();
104            $saslprep = new Znerol\Component\Stringprep\Profile\SASLprep();
105
106            $this->_user = $saslprep->apply(
107                $user,
108                'UTF-8',
109                Znerol\Component\Stringprep\Profile::MODE_QUERY
110            );
111            $this->_pass = $saslprep->apply(
112                $pass,
113                'UTF-8',
114                Znerol\Component\Stringprep\Profile::MODE_STORE
115            );
116        } catch (Znerol\Component\Stringprep\ProfileException $e) {
117            $error = true;
118        } catch (Exception $e) {
119            $error = true;
120        }
121
122        if ($error) {
123            throw new Horde_Imap_Client_Exception(
124                Horde_Imap_Client_Translation::r("Authentication failure."),
125                Horde_Imap_Client_Exception::LOGIN_AUTHORIZATIONFAILED
126            );
127        }
128
129        /* Generate nonce. (Done here so this can be overwritten for
130         * testing purposes.) */
131        $this->_nonce = strval(new Horde_Support_Randomid());
132    }
133
134    /**
135     * Return the initial client message.
136     *
137     * @return string  Initial client message.
138     */
139    public function getClientFirstMessage()
140    {
141        /* n: client doesn't support channel binding,
142         * <empty>,
143         * n=<user>: SASLprepped username with "," and "=" escaped,
144         * r=<nonce>: Random nonce */
145        $this->_authmsg = 'n=' . str_replace(
146            array(',', '='),
147            array('=2C', '=3D'),
148            $this->_user
149        ) . ',r=' . $this->_nonce;
150
151        return 'n,,' . $this->_authmsg;
152    }
153
154    /**
155     * Process the initial server message response.
156     *
157     * @param string $msg  Initial server response.
158     *
159     * @return boolean  False if authentication failed at this stage.
160     */
161    public function parseServerFirstMessage($msg)
162    {
163        $i = $r = $s = false;
164
165        foreach (explode(',', $msg) as $val) {
166            list($attr, $aval) = array_map('trim', explode('=', $val, 2));
167
168            switch ($attr) {
169            case 'i':
170                $this->_iterations = intval($aval);
171                $i = true;
172                break;
173
174            case 'r':
175                /* Beginning of server-provided nonce MUST be the same as the
176                 * nonce we provided. */
177                if (strpos($aval, $this->_nonce) !== 0) {
178                    return false;
179                }
180                $this->_nonce = $aval;
181                $r = true;
182                break;
183
184            case 's':
185                $this->_salt = base64_decode($aval);
186                $s = true;
187                break;
188            }
189        }
190
191        if ($i && $r && $s) {
192            $this->_authmsg .= ',' . $msg;
193            return true;
194        }
195
196        return false;
197    }
198
199    /**
200     * Return the final client message.
201     *
202     * @return string  Final client message.
203     */
204    public function getClientFinalMessage()
205    {
206        $final_msg = 'c=biws,r=' . $this->_nonce;
207
208        /* Salted password. */
209        $s_pass = strval(new Horde_Crypt_Blowfish_Pbkdf2(
210            $this->_pass,
211            strlen(hash($this->_hash, '', true)),
212            array(
213                'algo' => $this->_hash,
214                'i_count' => $this->_iterations,
215                'salt' => $this->_salt
216            )
217        ));
218
219        /* Client key. */
220        $c_key = hash_hmac($this->_hash, 'Client Key', $s_pass, true);
221
222        /* Stored key. */
223        $s_key = hash($this->_hash, $c_key, true);
224
225        /* Client signature. */
226        $auth_msg = $this->_authmsg . ',' . $final_msg;
227        $c_sig = hash_hmac($this->_hash, $auth_msg, $s_key, true);
228
229        /* Proof. */
230        $proof = $c_key ^ $c_sig;
231
232        /* Server signature. */
233        $this->_serversig = hash_hmac(
234            $this->_hash,
235            $auth_msg,
236            hash_hmac($this->_hash, 'Server Key', $s_pass, true),
237            true
238        );
239
240        /* c=biws: channel-binding ("biws" = base64('n,,')),
241         * p=<proof>: base64 encoded ClientProof,
242         * r=<nonce>: Nonce as returned from the server. */
243        return $final_msg . ',p=' . base64_encode($proof);
244    }
245
246    /**
247     * Process the final server message response.
248     *
249     * @param string $msg  Final server response.
250     *
251     * @return boolean  False if authentication failed.
252     */
253    public function parseServerFinalMessage($msg)
254    {
255        foreach (explode(',', $msg) as $val) {
256            list($attr, $aval) = array_map('trim', explode('=', $val, 2));
257
258            switch ($attr) {
259            case 'e':
260                return false;
261
262            case 'v':
263                return (base64_decode($aval) === $this->_serversig);
264            }
265        }
266
267        return false;
268    }
269
270}
271