source: protocols/jabber/sasl.c @ af09afd

Last change on this file since af09afd was 1bdc669, checked in by GitHub <noreply@…>, at 2023-02-23T23:48:10Z

Migrate internal users of md5.h to using GChecksum directly (#169)

  • Use GChecksum directly rather than md5 wrapper
  • Mark md5 functions as deprecated.
  • Migrate more users of md5.h to GChecksum
  • Property mode set to 100644
File size: 15.8 KB
RevLine 
[5997488]1/***************************************************************************\
2*                                                                           *
3*  BitlBee - An IRC to IM gateway                                           *
4*  Jabber module - SASL authentication                                      *
5*                                                                           *
[0e788f5]6*  Copyright 2006-2012 Wilmer van der Gaast <wilmer@gaast.net>              *
[5997488]7*                                                                           *
8*  This program is free software; you can redistribute it and/or modify     *
9*  it under the terms of the GNU General Public License as published by     *
10*  the Free Software Foundation; either version 2 of the License, or        *
11*  (at your option) any later version.                                      *
12*                                                                           *
13*  This program is distributed in the hope that it will be useful,          *
14*  but WITHOUT ANY WARRANTY; without even the implied warranty of           *
15*  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the            *
16*  GNU General Public License for more details.                             *
17*                                                                           *
18*  You should have received a copy of the GNU General Public License along  *
19*  with this program; if not, write to the Free Software Foundation, Inc.,  *
20*  51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.              *
21*                                                                           *
22\***************************************************************************/
23
[af97b23]24#include <ctype.h>
25
[5997488]26#include "jabber.h"
27#include "base64.h"
[57b4525]28#include "oauth2.h"
[e1c926f]29#include "oauth.h"
[5997488]30
[18c6d36]31const struct oauth2_service oauth2_service_google =
32{
33        "https://accounts.google.com/o/oauth2/auth",
34        "https://accounts.google.com/o/oauth2/token",
35        "urn:ietf:wg:oauth:2.0:oob",
36        "https://www.googleapis.com/auth/googletalk",
37        "783993391592.apps.googleusercontent.com",
38        "6C-Zgf7Tr7gEQTPlBhMUgo7R",
39};
40
[67ea361]41/* """"""""""""""""""""""""""""""oauth"""""""""""""""""""""""""""""" */
42#define HIPCHAT_SO_CALLED_OAUTH_URL "https://hipchat.com/account/api"
43
[5ebff60]44xt_status sasl_pkt_mechanisms(struct xt_node *node, gpointer data)
[5997488]45{
[0da65d5]46        struct im_connection *ic = data;
47        struct jabber_data *jd = ic->proto_data;
[5997488]48        struct xt_node *c, *reply;
49        char *s;
[67ea361]50        int sup_plain = 0, sup_digest = 0, sup_gtalk = 0, sup_anonymous = 0, sup_hipchat_oauth = 0;
[73dd021]51        int want_oauth = FALSE, want_hipchat = FALSE, want_anonymous = FALSE;
[4be0e34]52        GString *mechs;
[5ebff60]53
54        if (!sasl_supported(ic)) {
[8d74291]55                /* Should abort this now, since we should already be doing
56                   IQ authentication. Strange things happen when you try
57                   to do both... */
[5ebff60]58                imcb_log(ic,
59                         "XMPP 1.0 non-compliant server seems to support SASL, please report this as a BitlBee bug!");
[8d74291]60                return XT_HANDLED;
61        }
[5ebff60]62
63        s = xt_find_attr(node, "xmlns");
64        if (!s || strcmp(s, XMLNS_SASL) != 0) {
65                imcb_log(ic, "Stream error while authenticating");
66                imc_logout(ic, FALSE);
[5997488]67                return XT_ABORT;
68        }
[5ebff60]69
[73dd021]70        want_anonymous = set_getbool(&ic->acc->set, "anonymous");
[5ebff60]71        want_oauth = set_getbool(&ic->acc->set, "oauth");
[40cfbc5]72        want_hipchat = (jd->flags & JFLAG_HIPCHAT);
[5ebff60]73
74        mechs = g_string_new("");
[5997488]75        c = node->children;
[5ebff60]76        while ((c = xt_find_node(c, "mechanism"))) {
77                if (c->text && g_strcasecmp(c->text, "PLAIN") == 0) {
[5997488]78                        sup_plain = 1;
[5ebff60]79                } else if (c->text && g_strcasecmp(c->text, "DIGEST-MD5") == 0) {
[5997488]80                        sup_digest = 1;
[73dd021]81                } else if (c->text && g_strcasecmp(c->text, "ANONYMOUS") == 0) {
82                        sup_anonymous = 1;
[5ebff60]83                } else if (c->text && g_strcasecmp(c->text, "X-OAUTH2") == 0) {
[18c6d36]84                        sup_gtalk = 1;
[67ea361]85                } else if (c->text && g_strcasecmp(c->text, "X-HIPCHAT-OAUTH2") == 0) {
86                        sup_hipchat_oauth = 1;
[5ebff60]87                }
88
89                if (c->text) {
90                        g_string_append_printf(mechs, " %s", c->text);
91                }
92
[5997488]93                c = c->next;
94        }
[5ebff60]95
96        if (!want_oauth && !sup_plain && !sup_digest) {
[67ea361]97                if (sup_gtalk || sup_hipchat_oauth) {
[5ebff60]98                        imcb_error(ic, "This server requires OAuth "
99                                   "(supported schemes:%s)", mechs->str);
100                } else {
101                        imcb_error(ic, "BitlBee does not support any of the offered SASL "
102                                   "authentication schemes:%s", mechs->str);
103                }
104                imc_logout(ic, FALSE);
105                g_string_free(mechs, TRUE);
[5997488]106                return XT_ABORT;
107        }
[5ebff60]108        g_string_free(mechs, TRUE);
109
110        reply = xt_new_node("auth", NULL, NULL);
[40cfbc5]111        if (!want_hipchat) {
112                xt_add_attr(reply, "xmlns", XMLNS_SASL);
113        } else {
114                xt_add_attr(reply, "xmlns", XMLNS_HIPCHAT);
115        }
[5ebff60]116
[67ea361]117        if ((sup_gtalk || sup_hipchat_oauth) && want_oauth) {
118                GString *gs;
119
120                gs = g_string_sized_new(128);
121
122                g_string_append_c(gs, '\0');
123
124                if (sup_gtalk) {
125                        /* X-OAUTH2 is not *the* standard OAuth2 SASL/XMPP implementation.
126                           It's currently used by GTalk and vaguely documented on
127                           http://code.google.com/apis/cloudprint/docs/rawxmpp.html */
128                        xt_add_attr(reply, "mechanism", "X-OAUTH2");
[5ebff60]129
[67ea361]130                        g_string_append(gs, jd->username);
131                        g_string_append_c(gs, '\0');
132                        g_string_append(gs, jd->oauth2_access_token);
133                } else if (sup_hipchat_oauth) {
134                        /* Hipchat's variant, not standard either, is documented here:
135                           https://docs.atlassian.com/hipchat.xmpp/latest/xmpp/auth.html */
136                        xt_add_attr(reply, "mechanism", "oauth2");
137
138                        g_string_append(gs, jd->oauth2_access_token);
139                        g_string_append_c(gs, '\0');
140                        g_string_append(gs, set_getstr(&ic->acc->set, "resource"));
141                }
[5ebff60]142
[67ea361]143                reply->text = base64_encode((unsigned char *) gs->str, gs->len);
[5ebff60]144                reply->text_len = strlen(reply->text);
[67ea361]145                g_string_free(gs, TRUE);
146
[5ebff60]147        } else if (want_oauth) {
148                imcb_error(ic, "OAuth requested, but not supported by server");
149                imc_logout(ic, FALSE);
150                xt_free_node(reply);
[18c6d36]151                return XT_ABORT;
[73dd021]152        } else if (want_anonymous && sup_anonymous) {
153                xt_add_attr(reply, "mechanism", "ANONYMOUS");
154
155                /* Well, that was easy. */
156        } else if (want_anonymous) {
157                imcb_error(ic, "Anonymous login requested, but not supported by server");
158                imc_logout(ic, FALSE);
159                xt_free_node(reply);
160                return XT_ABORT;
[91dd19c]161        } else if (sup_digest && !(jd->ssl && sup_plain)) {
162                /* Only try DIGEST-MD5 if there's no SSL/TLS or if PLAIN isn't supported.
163                 * Which in practice means "don't bother with DIGEST-MD5 most of the time".
164                 * It's weak, pointless over TLS, and often breaks with some servers (hi openfire) */
165
[5ebff60]166                xt_add_attr(reply, "mechanism", "DIGEST-MD5");
167
[fe7a554]168                /* The rest will be done later, when we receive a <challenge/>. */
[5ebff60]169        } else if (sup_plain) {
[40cfbc5]170                GString *gs;
171                char *username;
[5ebff60]172
[40cfbc5]173                if (!want_hipchat) {
174                        xt_add_attr(reply, "mechanism", "PLAIN");
175                        username = jd->username;
176                } else {
177                        username = jd->me;
178                }
179
180                /* set an arbitrary initial size to avoid reallocations */
181                gs = g_string_sized_new(128);
[5ebff60]182
[5997488]183                /* With SASL PLAIN in XMPP, the text should be b64(\0user\0pass) */
[40cfbc5]184                g_string_append_c(gs, '\0');
185                g_string_append(gs, username);
186                g_string_append_c(gs, '\0');
187                g_string_append(gs, ic->acc->pass);
188                if (want_hipchat) {
189                        /* Hipchat's variation adds \0resource at the end */
190                        g_string_append_c(gs, '\0');
191                        g_string_append(gs, set_getstr(&ic->acc->set, "resource"));
192                }
193
[67ea361]194                reply->text = base64_encode((unsigned char *) gs->str, gs->len);
[5ebff60]195                reply->text_len = strlen(reply->text);
[67ea361]196                g_string_free(gs, TRUE);
[5997488]197        }
[5ebff60]198
199        if (reply && !jabber_write_packet(ic, reply)) {
200                xt_free_node(reply);
[5997488]201                return XT_ABORT;
202        }
[5ebff60]203        xt_free_node(reply);
204
[5997488]205        /* To prevent classic authentication from happening. */
206        jd->flags |= JFLAG_STREAM_STARTED;
[5ebff60]207
[5997488]208        return XT_HANDLED;
209}
210
[af97b23]211/* Non-static function, but not mentioned in jabber.h because it's for internal
212   use, just that the unittest should be able to reach it... */
[5ebff60]213char *sasl_get_part(char *data, char *field)
[d8e0484]214{
215        int i, len;
[5ebff60]216
217        len = strlen(field);
218
219        while (g_ascii_isspace(*data) || *data == ',') {
220                data++;
[d8e0484]221        }
[5ebff60]222
223        if (g_strncasecmp(data, field, len) == 0 && data[len] == '=') {
224                i = strlen(field) + 1;
225        } else {
226                for (i = 0; data[i]; i++) {
[d8e0484]227                        /* If we have a ", skip until it's closed again. */
[5ebff60]228                        if (data[i] == '"') {
229                                i++;
230                                while (data[i] != '"' || data[i - 1] == '\\') {
231                                        i++;
232                                }
[d8e0484]233                        }
[5ebff60]234
[af97b23]235                        /* If we got a comma, we got a new field. Check it,
236                           find the next key after it. */
[5ebff60]237                        if (data[i] == ',') {
238                                while (g_ascii_isspace(data[i]) || data[i] == ',') {
239                                        i++;
240                                }
241
242                                if (g_strncasecmp(data + i, field, len) == 0 &&
243                                    data[i + len] == '=') {
[af97b23]244                                        i += len + 1;
245                                        break;
246                                }
[d8e0484]247                        }
248                }
249        }
[5ebff60]250
251        if (data[i] == '"') {
[d8e0484]252                int j;
253                char *ret;
[5ebff60]254
255                i++;
[d8e0484]256                len = 0;
[5ebff60]257                while (data[i + len] != '"' || data[i + len - 1] == '\\') {
258                        len++;
259                }
260
261                ret = g_strndup(data + i, len);
262                for (i = j = 0; ret[i]; i++) {
263                        if (ret[i] == '\\') {
[d8e0484]264                                ret[j++] = ret[++i];
[5ebff60]265                        } else {
[d8e0484]266                                ret[j++] = ret[i];
267                        }
268                }
269                ret[j] = 0;
[5ebff60]270
[d8e0484]271                return ret;
[5ebff60]272        } else if (data[i]) {
[d8e0484]273                len = 0;
[5ebff60]274                while (data[i + len] && data[i + len] != ',') {
275                        len++;
276                }
277
278                return g_strndup(data + i, len);
279        } else {
[d8e0484]280                return NULL;
281        }
282}
283
[5ebff60]284xt_status sasl_pkt_challenge(struct xt_node *node, gpointer data)
[5997488]285{
[0da65d5]286        struct im_connection *ic = data;
287        struct jabber_data *jd = ic->proto_data;
[f138bd2]288        struct xt_node *reply_pkt = NULL;
[3b6eadc]289        char *nonce = NULL, *realm = NULL, *cnonce = NULL;
290        unsigned char cnonce_bin[30];
[d8e0484]291        char *digest_uri = NULL;
292        char *dec = NULL;
[f138bd2]293        char *s = NULL, *reply = NULL;
[d8e0484]294        xt_status ret = XT_ABORT;
[5ebff60]295
296        if (node->text_len == 0) {
[d8e0484]297                goto error;
[5ebff60]298        }
299
300        dec = frombase64(node->text);
301
[9b02bab]302        if (!(s = sasl_get_part(dec, "rspauth"))) {
[d8e0484]303                /* See RFC 2831 for for information. */
[1bdc669]304                GChecksum *A1, *A2, *H;
305                gsize digest_len = MD5_HASH_SIZE;
306                guint8 A1r[16], A2r[16], Hr[16];
[d8e0484]307                char A1h[33], A2h[33], Hh[33];
308                int i;
[5ebff60]309
310                nonce = sasl_get_part(dec, "nonce");
311                realm = sasl_get_part(dec, "realm");
312
313                if (!nonce) {
[d8e0484]314                        goto error;
[5ebff60]315                }
316
[d9282b4]317                /* Jabber.Org considers the realm part optional and doesn't
318                   specify one. Oh well, actually they're right, but still,
319                   don't know if this is right... */
[5ebff60]320                if (!realm) {
321                        realm = g_strdup(jd->server);
322                }
323
324                random_bytes(cnonce_bin, sizeof(cnonce_bin));
325                cnonce = base64_encode(cnonce_bin, sizeof(cnonce_bin));
326                digest_uri = g_strdup_printf("%s/%s", "xmpp", jd->server);
327
[d8e0484]328                /* Generate the MD5 hash of username:realm:password,
329                   I decided to call it H. */
[1bdc669]330                H = g_checksum_new(G_CHECKSUM_MD5);
[5ebff60]331                s = g_strdup_printf("%s:%s:%s", jd->username, realm, ic->acc->pass);
[1bdc669]332                g_checksum_update(H, (guint8 *)s, strlen(s));
[5ebff60]333                g_free(s);
[1bdc669]334
335                g_checksum_get_digest(H, Hr, &digest_len);
336                g_checksum_free(H);
[5ebff60]337
[d8e0484]338                /* Now generate the hex. MD5 hash of H:nonce:cnonce, called A1. */
[1bdc669]339                A1 = g_checksum_new(G_CHECKSUM_MD5);
[5ebff60]340                s = g_strdup_printf(":%s:%s", nonce, cnonce);
[1bdc669]341                g_checksum_update(A1, Hr, 16);
342                g_checksum_update(A1, (guint8 *)s, strlen(s));
[5ebff60]343                g_free(s);
[1bdc669]344                g_checksum_get_digest(A1, A1r, &digest_len);
345                g_checksum_free(A1);
[5ebff60]346                for (i = 0; i < 16; i++) {
347                        sprintf(A1h + i * 2, "%02x", A1r[i]);
348                }
349
[d8e0484]350                /* A2... */
[1bdc669]351                A2 = g_checksum_new(G_CHECKSUM_MD5);
[5ebff60]352                s = g_strdup_printf("%s:%s", "AUTHENTICATE", digest_uri);
[1bdc669]353                g_checksum_update(A2, (guint8 *)s, strlen(s));
[5ebff60]354                g_free(s);
[1bdc669]355                g_checksum_get_digest(A2, A2r, &digest_len);
356                g_checksum_free(A2);
[5ebff60]357                for (i = 0; i < 16; i++) {
358                        sprintf(A2h + i * 2, "%02x", A2r[i]);
359                }
360
[d8e0484]361                /* Final result: A1:nonce:00000001:cnonce:auth:A2. Let's reuse H for it. */
[1bdc669]362                H = g_checksum_new(G_CHECKSUM_MD5);
[5ebff60]363                s = g_strdup_printf("%s:%s:%s:%s:%s:%s", A1h, nonce, "00000001", cnonce, "auth", A2h);
[1bdc669]364                g_checksum_update(H, (guint8 *)s, strlen(s));
[5ebff60]365                g_free(s);
[1bdc669]366                g_checksum_get_digest(H, Hr, &digest_len);
367                g_checksum_free(H);
368
[5ebff60]369                for (i = 0; i < 16; i++) {
370                        sprintf(Hh + i * 2, "%02x", Hr[i]);
371                }
372
[d8e0484]373                /* Now build the SASL response string: */
[5ebff60]374                reply = g_strdup_printf("username=\"%s\",realm=\"%s\",nonce=\"%s\",cnonce=\"%s\","
375                                        "nc=%08x,qop=auth,digest-uri=\"%s\",response=%s,charset=%s",
376                                        jd->username, realm, nonce, cnonce, 1, digest_uri, Hh, "utf-8");
377        } else {
[d8e0484]378                /* We found rspauth, but don't really care... */
[5ebff60]379                g_free(s);
[d8e0484]380        }
[5ebff60]381
382        s = reply ? tobase64(reply) : NULL;
383        reply_pkt = xt_new_node("response", s, NULL);
384        xt_add_attr(reply_pkt, "xmlns", XMLNS_SASL);
385
386        if (!jabber_write_packet(ic, reply_pkt)) {
[d8e0484]387                goto silent_error;
[5ebff60]388        }
389
[d8e0484]390        ret = XT_HANDLED;
391        goto silent_error;
392
393error:
[5ebff60]394        imcb_error(ic, "Incorrect SASL challenge received");
395        imc_logout(ic, FALSE);
[d8e0484]396
397silent_error:
[5ebff60]398        g_free(digest_uri);
399        g_free(cnonce);
400        g_free(nonce);
401        g_free(reply);
402        g_free(realm);
403        g_free(dec);
404        g_free(s);
405        xt_free_node(reply_pkt);
406
[d8e0484]407        return ret;
[5997488]408}
409
[5ebff60]410xt_status sasl_pkt_result(struct xt_node *node, gpointer data)
[5997488]411{
[0da65d5]412        struct im_connection *ic = data;
413        struct jabber_data *jd = ic->proto_data;
[5997488]414        char *s;
[5ebff60]415
416        s = xt_find_attr(node, "xmlns");
417        if (!s || strcmp(s, XMLNS_SASL) != 0) {
418                imcb_log(ic, "Stream error while authenticating");
419                imc_logout(ic, FALSE);
[5997488]420                return XT_ABORT;
421        }
[5ebff60]422
423        if (strcmp(node->name, "success") == 0) {
424                imcb_log(ic, "Authentication finished");
[5997488]425                jd->flags |= JFLAG_AUTHENTICATED | JFLAG_STREAM_RESTART;
[40cfbc5]426
427                if (jd->flags & JFLAG_HIPCHAT) {
428                        return hipchat_handle_success(ic, node);
429                }
[5ebff60]430        } else if (strcmp(node->name, "failure") == 0) {
431                imcb_error(ic, "Authentication failure");
432                imc_logout(ic, FALSE);
[5997488]433                return XT_ABORT;
434        }
[5ebff60]435
[5997488]436        return XT_HANDLED;
437}
[8d74291]438
439/* This one is needed to judge if we'll do authentication using IQ or SASL.
440   It's done by checking if the <stream:stream> from the server has a
441   version attribute. I don't know if this is the right way though... */
[5ebff60]442gboolean sasl_supported(struct im_connection *ic)
[8d74291]443{
[0da65d5]444        struct jabber_data *jd = ic->proto_data;
[5ebff60]445
446        return (jd->xt && jd->xt->root && xt_find_attr(jd->xt->root, "version")) != 0;
[8d74291]447}
[4a5d885]448
[5ebff60]449void sasl_oauth2_init(struct im_connection *ic)
[4a5d885]450{
[18c6d36]451        struct jabber_data *jd = ic->proto_data;
[5ebff60]452
453        imcb_log(ic, "Starting OAuth authentication");
454
[4a5d885]455        /* Temporary contact, just used to receive the OAuth response. */
[5ebff60]456        imcb_add_buddy(ic, JABBER_OAUTH_HANDLE, NULL);
[67ea361]457
458        if (jd->flags & JFLAG_HIPCHAT) {
459                imcb_buddy_msg(ic, JABBER_OAUTH_HANDLE,
460                        "Open this URL and generate a token with 'View Group' and 'Send Message' scopes: "
461                        HIPCHAT_SO_CALLED_OAUTH_URL, 0, 0);
462        } else {
463                char *msg, *url;
464
465                url = oauth2_url(jd->oauth2_service);
466                msg = g_strdup_printf("Open this URL in your browser to authenticate: %s", url);
467                imcb_buddy_msg(ic, JABBER_OAUTH_HANDLE, msg, 0, 0);
468
469                g_free(msg);
470                g_free(url);
471        }
[5ebff60]472        imcb_buddy_msg(ic, JABBER_OAUTH_HANDLE, "Respond to this message with the returned "
473                       "authorization token.", 0, 0);
474
[4a5d885]475}
476
[5ebff60]477static gboolean sasl_oauth2_remove_contact(gpointer data, gint fd, b_input_condition cond)
[4a5d885]478{
479        struct im_connection *ic = data;
[5ebff60]480
481        if (g_slist_find(jabber_connections, ic)) {
482                imcb_remove_buddy(ic, JABBER_OAUTH_HANDLE, NULL);
483        }
[4a5d885]484        return FALSE;
485}
486
[5ebff60]487int sasl_oauth2_get_refresh_token(struct im_connection *ic, const char *msg)
[4a5d885]488{
[18c6d36]489        struct jabber_data *jd = ic->proto_data;
[4a5d885]490        char *code;
491        int ret;
[5ebff60]492
493        imcb_log(ic, "Requesting OAuth access token");
494
[4a5d885]495        /* Don't do it here because the caller may get confused if the contact
496           we're currently sending a message to is deleted. */
[5ebff60]497        b_timeout_add(1, sasl_oauth2_remove_contact, ic);
498
499        code = g_strdup(msg);
500        g_strstrip(code);
501        ret = oauth2_access_token(jd->oauth2_service, OAUTH2_AUTH_CODE,
502                                  code, sasl_oauth2_got_token, ic);
503
504        g_free(code);
[4a5d885]505        return ret;
506}
507
[5ebff60]508int sasl_oauth2_refresh(struct im_connection *ic, const char *refresh_token)
[4a5d885]509{
[18c6d36]510        struct jabber_data *jd = ic->proto_data;
[5ebff60]511
512        return oauth2_access_token(jd->oauth2_service, OAUTH2_AUTH_REFRESH,
513                                   refresh_token, sasl_oauth2_got_token, ic);
[4a5d885]514}
515
[67ea361]516void sasl_oauth2_got_token(gpointer data, const char *access_token, const char *refresh_token, const char *error)
[4a5d885]517{
518        struct im_connection *ic = data;
519        struct jabber_data *jd;
[36533bf]520        GSList *auth = NULL;
[5ebff60]521
522        if (g_slist_find(jabber_connections, ic) == NULL) {
[4a5d885]523                return;
[5ebff60]524        }
525
[4a5d885]526        jd = ic->proto_data;
[5ebff60]527
528        if (access_token == NULL) {
529                imcb_error(ic, "OAuth failure (%s)", error);
530                imc_logout(ic, TRUE);
[e1c926f]531                return;
[4a5d885]532        }
[5ebff60]533
534        oauth_params_parse(&auth, ic->acc->pass);
535        if (refresh_token) {
536                oauth_params_set(&auth, "refresh_token", refresh_token);
537        }
538        if (access_token) {
539                oauth_params_set(&auth, "access_token", access_token);
540        }
541
542        g_free(ic->acc->pass);
543        ic->acc->pass = oauth_params_string(auth);
544        oauth_params_free(&auth);
545
546        g_free(jd->oauth2_access_token);
547        jd->oauth2_access_token = g_strdup(access_token);
548
549        jabber_connect(ic);
[4a5d885]550}
Note: See TracBrowser for help on using the repository browser.