lib.php 34.5 KB
Newer Older
1
2
<?php
/**
Francois Marier's avatar
Francois Marier committed
3
 * Mahara: Electronic portfolio, weblog, resume builder and social networking
4
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
5
 *
Francois Marier's avatar
Francois Marier committed
6
7
8
9
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
10
 *
Francois Marier's avatar
Francois Marier committed
11
12
13
14
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
15
 *
Francois Marier's avatar
Francois Marier committed
16
17
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19
20
 *
 * @package    mahara
 * @subpackage auth
21
 * @author     Catalyst IT Ltd
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
23
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
24
25
26
 *
 */

27
28
defined('INTERNAL') || die();

29
function xmlrpc_exception (Exception $e) {
Donal McMullan's avatar
Donal McMullan committed
30
31
32
33
34
35
    if (($e instanceof XmlrpcServerException) && get_class($e) == 'XmlrpcServerException') {
        $e->handle_exception();
        return;
    } elseif (($e instanceof MaharaException) && get_class($e) == 'MaharaException') {
        throw new XmlrpcServerException($e->getMessage(), $e->getCode());
        return;
36
    }
Donal McMullan's avatar
Donal McMullan committed
37
38
    xmlrpc_error('An unexpected error has occurred: '.$e->getMessage(), $e->getCode());
    log_message($e->getMessage(), LOG_LEVEL_WARN, true, true, $e->getFile(), $e->getLine(), $e->getTrace());
39
40
}

41
function get_hostname_from_uri($uri = null) {
42
43
44
45
    static $cache = array();
    if (array_key_exists($uri, $cache)) {
        return $cache[$uri];
    }
46
    $count = preg_match("@^(?:http[s]?://)?([A-Z0-9\-\.]+).*@i", $uri, $matches);
47
    $cache[$uri] = $matches[1];
48
49
50
51
52
53
54
55
56
57
58
    if ($count > 0) return $matches[1];
    return false;
}

function dropslash($wwwroot) {
    if (substr($wwwroot, -1, 1) == '/') {
        return substr($wwwroot, 0, -1);
    }
    return $wwwroot;
}

59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
function generate_token() {
    return sha1(str_shuffle('' . mt_rand(999999,99999999) . microtime(true)));
}

function start_jump_session($peer, $instanceid, $wantsurl="") {
    global $USER;

    $rpc_negotiation_timeout = 15;
    $providers = get_service_providers($USER->authinstance);

    $approved = false;
    foreach ($providers as $provider) {
        if ($provider['wwwroot'] == $peer->wwwroot) {
            $approved = true;
            break;
        }
    }

    if (false == $approved) {
Donal McMullan's avatar
Donal McMullan committed
78
79
80
        // This shouldn't happen: the user shouldn't have been presented with 
        // the link
        throw new SystemException('Host not approved for sso');
81
82
83
84
    }

    // set up the session
    $sso_session = get_record('sso_session',
85
                              'userid',     $USER->id);
86
87
88
89
90
91
92
93
94
95
96
    if ($sso_session == false) {
        $sso_session = new stdClass();
        $sso_session->instanceid = $instanceid;
        $sso_session->userid = $USER->id;
        $sso_session->username = $USER->username;
        $sso_session->useragent = sha1($_SERVER['HTTP_USER_AGENT']);
        $sso_session->token = generate_token();
        $sso_session->confirmtimeout = time() + $rpc_negotiation_timeout;
        $sso_session->expires = time() + (integer)ini_get('session.gc_maxlifetime');
        $sso_session->sessionid = session_id();
        if (! insert_record('sso_session', $sso_session)) {
Donal McMullan's avatar
Donal McMullan committed
97
            throw new SQLException("database error");
98
99
100
101
        }
    } else {
        $sso_session->useragent = sha1($_SERVER['HTTP_USER_AGENT']);
        $sso_session->token = generate_token();
102
        $sso_session->instanceid = $instanceid;
103
104
        $sso_session->confirmtimeout = time() + $rpc_negotiation_timeout;
        $sso_session->expires = time() + (integer)ini_get('session.gc_maxlifetime');
105
        $sso_session->useragent = sha1($_SERVER['HTTP_USER_AGENT']);
106
107
        $sso_session->sessionid = session_id();
        if (false == update_record('sso_session', $sso_session, array('userid' => $USER->id))) {
Donal McMullan's avatar
Donal McMullan committed
108
            throw new SQLException("database error");
109
110
111
        }
    }

112
    $wwwroot = dropslash(get_config('wwwroot'));
113
114
115
116
117
118
119

    // construct the redirection URL
    $url = "{$peer->wwwroot}{$peer->application->ssolandurl}?token={$sso_session->token}&idp={$wwwroot}&wantsurl={$wantsurl}";

    return $url;
}

120
function api_dummy_method($methodname, $argsarray, $functionname) {
121
122
123
    return call_user_func_array($functionname, $argsarray);
}

124
125
function find_remote_user($username, $wwwroot) {
    $institution = get_field('host', 'institution', 'wwwroot', $wwwroot);
126
127
128
129
130
131
132

    if (false == $institution) {
        // This should never happen, because if we don't know the host we'll
        // already have exited
        throw new XmlrpcServerException('Unknown error');
    }

133
134
    $authinstances = auth_get_auth_instances_for_institution($institution);
    $candidates    = array();
135
136
137
138

    $sql = 'SElECT
                ai.*
            FROM
139
140
141
                {auth_instance} ai,
                {auth_instance} ai2,
                {auth_instance_config} aic
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
            WHERE
                ai.id = ? AND
                ai.institution = ? AND
                ai2.institution = ai.institution AND
                ai.id = aic.value AND
                aic.field = \'parent\' AND
                aic.instance = ai2.id AND
                ai2.authname = \'xmlrpc\'';

    foreach ($authinstances as $authinstance) {
        if ($authinstance->authname != 'xmlrpc') {
            $records = get_records_sql_array($sql, array($authinstance->id, $institution));
            if (false == $records) {
                continue;
            }
        }
        try {
            $user = new User;
160
            $user->find_by_instanceid_username($authinstance->id, $username, true);
161
162
163
164
            $candidates[$user->id] = $user;
        } catch (Exception $e) {
            // we don't care
            continue;
165
        }
166
167
168
169
170
171
    }

    if (count($candidates) != 1) {
        return false;
    }

172
173
174
175
176
177
178
179
180
181
    return array_pop($candidates);
}

function fetch_user_image($username) {
    global $REMOTEWWWROOT;

    if (!$user = find_remote_user($user, $REMOTEWWWROOT)) {
        return false;
    }

182
183
184
185
186
187
188
189
190
191
192
193
194
    $ic = $user->profileicon;
    if (!empty($ic)) {
        $filename = get_config('dataroot') . 'artefact/internal/profileicons/' . ($user->profileicon % 256) . '/'.$user->profileicon;
        $return = array();
        try {
            $fi = file_get_contents($filename);
        } catch (Exception $e) {
            // meh
        }

        $return['f1'] = base64_encode($fi);

        require_once('file.php');
195
        $im = get_dataroot_image_path('artefact/internal/profileicons' , $user->profileicon, 100);
196
197
198
199
        $fi = file_get_contents($im);
        $return['f2'] = base64_encode($fi);
        return $return;
    } else {
200
        // no icon
201
202
203
    }
}

204
function user_authorise($token, $useragent) {
205
    global $USER;
206
207
208

    $sso_session = get_record('sso_session', 'token', $token, 'useragent', $useragent);
    if (empty($sso_session)) {
Donal McMullan's avatar
Donal McMullan committed
209
        throw new XmlrpcServerException('No such session exists');
210
211
212
213
    }

    // check session confirm timeout
    if ($sso_session->expires < time()) {
Donal McMullan's avatar
Donal McMullan committed
214
        throw new XmlrpcServerException('This session has timed out');
215
216
217
218
219
220
221
    }

    // session okay, try getting the user
    $user = new User();
    try {
        $user->find_by_id($sso_session->userid);
    } catch (Exception $e) {
Donal McMullan's avatar
Donal McMullan committed
222
        throw new XmlrpcServerException('Unable to get information for the specified user');
223
224
    }

225
226
    require(get_config('docroot') . 'artefact/lib.php');
    require(get_config('docroot') . 'artefact/internal/lib.php');
227
228
229
230
231
232
233

    $element_list = call_static_method('ArtefactTypeProfile', 'get_all_fields');
    $element_required = call_static_method('ArtefactTypeProfile', 'get_mandatory_fields');

    // load existing profile information
    $profilefields = array();
    $profile_data = get_records_select_assoc('artefact', "owner=? AND artefacttype IN (" . join(",",array_map(create_function('$a','return db_quote($a);'),array_keys($element_list))) . ")", array($USER->get('id')), '','artefacttype, title');
234
235
236
    if ($profile_data == false) {
        $profile_data = array();
    }
237
238

    $email = get_field('artefact_internal_profile_email', 'email', 'owner', $sso_session->userid, 'principal', 1);
239
    if (false == $email) {
Donal McMullan's avatar
Donal McMullan committed
240
        throw new XmlrpcServerException("No email adress for user");
241
242
243
244
    }

    $userdata = array();
    $userdata['username']                = $user->username;
245
    $userdata['email']                   = $email;
246
247
248
249
250
    $userdata['auth']                    = 'mnet';
    $userdata['confirmed']               = 1;
    $userdata['deleted']                 = 0;
    $userdata['firstname']               = $user->firstname;
    $userdata['lastname']                = $user->lastname;
251
252
    $userdata['city']                    = array_key_exists('city', $profile_data) ? $profile_data['city']->title : '';
    $userdata['country']                 = array_key_exists('country', $profile_data) ? $profile_data['country']->title : '';
253

254
255
    if (is_numeric($user->profileicon)) {
        $filename = get_config('dataroot') . 'artefact/internal/profileicons/' . ($user->profileicon % 256) . '/'.$user->profileicon;
256
        if (file_exists($filename) && is_readable($filename)) {
257
            $userdata['imagehash'] = sha1_file($filename);
258
259
260
261
262
263
264
265
266
267
268
        }
    }

    get_service_providers($USER->authinstance);

    // Todo: push application name to list of hosts... update Moodle block to display more info, maybe in 'Other' list
    $userdata['myhosts'] = array();

    return $userdata;
}

269
270
271
272
273
274
275
276
277
278
279
280
function send_content_intent($username) {
    global $REMOTEWWWROOT;

    if (!$user = find_remote_user($username, $REMOTEWWWROOT)) {
        // @todo return an error message we can understand
        return false;
    }

    // @todo penny check for zip libraries here

    // check whatever config values we have to check
    // generate a token, insert it into the queue table
281
    $usequeue = 1; // @todo change this (check whatever)
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307

    $queue = new StdClass;
    $queue->token = generate_token();
    $queue->host = $REMOTEWWWROOT;
    $queue->usr = $user->id;
    $queue->queue = $usequeue;
    $queue->ready = 0;
    $queue->expirytime = db_format_timestamp(time()+(60*60*24));

    insert_record('import_queue', $queue);

    return array(
        'sendtype' => (($usequeue) ? 'queue' : 'immediate'),
        'token' => $queue->token,
    );
}

function send_content_ready($token, $username, $format, $filesmanifest, $fetchnow=false) {
    global $REMOTEWWWROOT;

    if (!$user = find_remote_user($username, $REMOTEWWWROOT)) {
        throw new ImportException("Could not find user $username for $REMOTEWWWROOT");
    }

    // go verify the token
    if (!$queue = get_record('import_queue', 'token', $token, 'host', $REMOTEWWWROOT)) {
308
        throw new ImportException("Could not find queue record with given token for username $username for $REMOTEWWWROOT");
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
    }

    if (strtotime($queue->expirytime) < time()) {
        throw new ImportException("Queue record has expired");
    }

    // @todo penny verify format and filesmanifest

    $queue->format = $format;
    $queue->data = serialize(array('filesmanifest' => $filesmanifest));

    update_record('import_queue', $queue);

    $result = new StdClass;
    // @todo penny change whatever we need here to match
    // send_content_intent
    if ($fetchnow && true) {
        require_once('import.php');
        // either immediately spawn a curl request to go fetch the file
        $importer = Importer::create_importer($queue->id, $queue);
        $importer->prepare();
        $importer->process();
        $result->status = true;
        $result->type = 'complete';
    } else {
        // or set ready to 1 for the next cronjob to go fetch it.
        $result->status = set_field('import_queue', 'ready', 1, 'id', $queue->id);
        $result->type = 'queued';
    }
    log_debug($result);
    return $result;
}

342
343
344
345
function xmlrpc_not_implemented() {
    return true;
}

346
347
348
349
350
351
352
/**
 * Given a USER, get all Service Providers for that User, based on child auth
 * instances of its canonical auth instance
 */
function get_service_providers($instance) {
    static $cache = array();

353
354
355
356
    if (defined('INSTALLER')) {
        return array();
    }

357
358
359
360
361
362
363
364
365
366
367
    if (array_key_exists($instance, $cache)) {
        return $cache[$instance];
    }

    $query = '
        SELECT
            h.name,
            a.ssolandurl,
            h.wwwroot,
            aic.instance
        FROM
368
369
370
371
372
            {auth_instance_config} aic,
            {auth_instance_config} aic2,
            {auth_instance_config} aic3,
            {host} h,
            {application} a
373
        WHERE
374
          ((aic.value = 1 AND
375
            aic.field = \'theyautocreateusers\' ) OR
376
377
           (aic.value = ?  AND
            aic.field = \'parent\')) AND
378
379
380
381
382
383
384
385
386
387

            aic.instance = aic2.instance AND
            aic2.field = \'wwwroot\' AND
            aic2.value = h.wwwroot AND

            aic.instance = aic3.instance AND
            aic3.field = \'wessoout\' AND
            aic3.value = \'1\' AND

            a.name = h.appname';
388
389
390
391
392
393
    try {
        $results = get_records_sql_assoc($query, array('value' => $instance));
    } catch (SQLException $e) {
        // Table doesn't exist yet
        return array();
    }
394
395
396
397
398
399
400
401
402
403
404
405
406

    if (false == $results) {
        $results = array();
    }

    foreach($results as $key => $result) {
        $results[$key] = get_object_vars($result);
    }

    $cache[$instance] = $results;
    return $cache[$instance];
}

407
function get_public_key($uri, $application=null) {
408

409
410
411
412
413
414
415
    static $keyarray = array();
    if (isset($keyarray[$uri])) {
        return $keyarray[$uri];
    }

    $openssl = OpenSslRepo::singleton();

416
    if (empty($application)) {
417
        $application = 'moodle';
418
    }
419

420
    $xmlrpcserverurl = get_field('application', 'xmlrpcserverurl', 'name', $application);
421
422
423
    if (empty($xmlrpcserverurl)) {
        throw new XmlrpcClientException('Unknown application');
    } 
424
    $wwwroot = dropslash(get_config('wwwroot'));
425

426
    $rq = xmlrpc_encode_request('system/keyswap', array($wwwroot, $openssl->certificate), array("encoding" => "utf-8"));
427
    $ch = curl_init($uri . $xmlrpcserverurl);
428

429
430
431
432
433
434
435
    curl_setopt($ch, CURLOPT_TIMEOUT, 60);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle');
    curl_setopt($ch, CURLOPT_POSTFIELDS, $rq);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array("Content-Type: text/xml charset=UTF-8"));

436
437
438
439
    $raw = curl_exec($ch);
    if (empty($raw)) {
        throw new XmlrpcClientException('CURL connection failed');
    }
440
441
442
443
444
445
446

    $response_code        = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $response_code_prefix = substr($response_code, 0, 1);

    if ('2' != $response_code_prefix) {
        if ('4' == $response_code_prefix) {
            throw new XmlrpcClientException('Client error code: ', $response_code);
447
        } elseif ('5' == $response_code_prefix) {
448
449
450
451
            throw new XmlrpcClientException('An error occurred at the remote server. Code: ', $response_code);
        }
    }

452
    $res = xmlrpc_decode($raw);
453
454
    curl_close($ch);

455
456
457
    // XMLRPC error messages are returned as an array
    // We are expecting a string
    if (!is_array($res)) {
458
459
460
461
462
463
464
465
466
        $keyarray[$uri] = $res;
        $credentials=array();
        if (strlen(trim($keyarray[$uri]))) {
            $credentials = openssl_x509_parse($keyarray[$uri]);
            $host = $credentials['subject']['CN'];
            if (strpos($uri, $host) !== false) {
                return $keyarray[$uri];
            }
        }
467
468
    } else {
        throw new XmlrpcClientException($res['faultString'], $res['faultCode']);
469
470
471
472
    }
    return false;
}

473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
/**
 * Output a valid XML-RPC error message.
 *
 * @param  string   $message              The error message
 * @param  int      $code                 Unique identifying integer
 * @return string                         An XMLRPC error doc
 */
function xmlrpc_error($message, $code) {
    echo <<<EOF
<?xml version="1.0"?>
<methodResponse>
   <fault>
      <value>
         <struct>
            <member>
               <name>faultCode</name>
               <value><int>$code</int></value>
            </member>
            <member>
               <name>faultString</name>
               <value><string>$message</string></value>
            </member>
         </struct>
      </value>
   </fault>
</methodResponse>
EOF;
}

502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
function xmlenc_envelope_strip(&$xml) {
    $openssl           = OpenSslRepo::singleton();
    $payload_encrypted = true;
    $data              = base64_decode($xml->EncryptedData->CipherData->CipherValue);
    $key               = base64_decode($xml->EncryptedKey->CipherData->CipherValue);
    $payload           = '';    // Initialize payload var
    $payload           = $openssl->openssl_open($data, $key);
    $xml               = parse_payload($payload);
    return $payload;
}

function parse_payload($payload) {
    try {
        $xml = new SimpleXMLElement($payload);
        return $xml;
    } catch (Exception $e) {
Donal McMullan's avatar
Donal McMullan committed
518
        throw new MaharaException('Encrypted payload is not a valid XML document', 6002);
519
520
521
    }
}

522
function get_peer($wwwroot, $cache=true) {
523

524
525
    $wwwroot = (string)$wwwroot;
    static $peers = array();
526
527
528
    if ($cache) {
        if (isset($peers[$wwwroot])) return $peers[$wwwroot];
    }
529

530
    require_once(get_config('libroot') . 'peer.php');
531
    $peer = new Peer();
532

533
    if (!$peer->findByWwwroot($wwwroot)) {
534
        // Bootstrap unknown hosts?
535
        throw new MaharaException("We don't have a record for your webserver ($wwwroot) in our database", 6003);
536
537
538
539
540
    }
    $peers[$wwwroot] = $peer;
    return $peers[$wwwroot];
}

541
542
543
/**
 * Check that the signature has been signed by the remote host.
 */
544
function xmldsig_envelope_strip(&$xml) {
545

546
547
548
549
550
551
    $signature      = base64_decode($xml->Signature->SignatureValue);
    $payload        = base64_decode($xml->object);
    $wwwroot        = (string)$xml->wwwroot;
    $timestamp      = $xml->timestamp;
    $peer           = get_peer($wwwroot);

552

553
554
555
556
557
558
559
560
561
    // Does the signature match the data and the public cert?
    $signature_verified = openssl_verify($payload, $signature, $peer->certificate);

    if ($signature_verified == 1) {
        // Parse the XML
        try {
            $xml = new SimpleXMLElement($payload);
            return $payload;
        } catch (Exception $e) {
Donal McMullan's avatar
Donal McMullan committed
562
            throw new MaharaException('Signed payload is not a valid XML document', 6007);
563
564
        }
    } else {
Donal McMullan's avatar
Donal McMullan committed
565
        throw new MaharaException('An error occurred while trying to verify your message signature', 6004);
566
567
568
    }
}

569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
/**
 * Encrypt a message and return it in an XML-Encrypted document
 *
 * This function can encrypt any content, but it was written to provide a system
 * of encrypting XML-RPC request and response messages. The message does not 
 * need to be text - binary data should work.
 * 
 * Asymmetric keys can encrypt only small chunks of data. Usually 1023 or 2047 
 * characters, depending on the key size. So - we generate a symmetric key and 
 * use the asymmetric key to secure it for transport with the data.
 *
 * We generate a symmetric key
 * We encrypt the symmetric key with the public key of the remote host
 * We encrypt our content with the symmetric key
 * We base64 the key & message data.
 * We identify our wwwroot - this must match our certificate's CN
 *
 * Normally, the XML-RPC document will be parceled inside an XML-SIG envelope.
 * We parcel the XML-SIG document inside an XML-ENC envelope.
 *
 * See the {@Link http://www.w3.org/TR/xmlenc-core/ XML-ENC spec} at the W3c
 * site
 *
 * @param  string   $message              The data you want to sign
 * @param  string   $remote_certificate   Peer's certificate in PEM format
 * @return string                         An XML-ENC document
 */
function xmlenc_envelope($message, $remote_certificate) {

    // Generate a key resource from the remote_certificate text string
    $publickey = openssl_get_publickey($remote_certificate);

    if ( gettype($publickey) != 'resource' ) {
        // Remote certificate is faulty.
Donal McMullan's avatar
Donal McMullan committed
603
        throw new MaharaException('Could not generate public key resource from certificate', 1);
604
605
606
    }

    // Initialize vars
607
    $wwwroot = dropslash(get_config('wwwroot'));
608
609
610
611
612
613
614
    $encryptedstring = '';
    $symmetric_keys = array();

    //      passed by ref ->      &$encryptedstring &$symmetric_keys
    $bool = openssl_seal($message, $encryptedstring, $symmetric_keys, array($publickey));
    $message = base64_encode($encryptedstring);
    $symmetrickey = base64_encode(array_pop($symmetric_keys));
615
    $zed = 'nothing';
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642

    return <<<EOF
<?xml version="1.0" encoding="iso-8859-1"?>
    <encryptedMessage>
        <EncryptedData Id="ED" xmlns="http://www.w3.org/2001/04/xmlenc#">
            <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#arcfour"/>
            <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                <ds:RetrievalMethod URI="#EK" Type="http://www.w3.org/2001/04/xmlenc#EncryptedKey"/>
                <ds:KeyName>XMLENC</ds:KeyName>
            </ds:KeyInfo>
            <CipherData>
                <CipherValue>$message</CipherValue>
            </CipherData>
        </EncryptedData>
        <EncryptedKey Id="EK" xmlns="http://www.w3.org/2001/04/xmlenc#">
            <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
            <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
                <ds:KeyName>SSLKEY</ds:KeyName>
            </ds:KeyInfo>
            <CipherData>
                <CipherValue>$symmetrickey</CipherValue>
            </CipherData>
            <ReferenceList>
                <DataReference URI="#ED"/>
            </ReferenceList>
            <CarriedKeyName>XMLENC</CarriedKeyName>
        </EncryptedKey>
643
        <wwwroot>{$wwwroot}</wwwroot>
644
        <X1>$zed</X1>
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
    </encryptedMessage>
EOF;
}

/**
 * Sign a message and return it in an XML-Signature document
 *
 * This function can sign any content, but it was written to provide a system of
 * signing XML-RPC request and response messages. The message will be base64
 * encoded, so it does not need to be text.
 *
 * We compute the SHA1 digest of the message.
 * We compute a signature on that digest with our private key.
 * We link to the public key that can be used to verify our signature.
 * We base64 the message data.
 * We identify our wwwroot - this must match our certificate's CN
 *
 * The XML-RPC document will be parceled inside an XML-SIG document, which holds
 * the base64_encoded XML as an object, the SHA1 digest of that document, and a
 * signature of that document using the local private key. This signature will
 * uniquely identify the RPC document as having come from this server.
 *
 * See the {@Link http://www.w3.org/TR/xmldsig-core/ XML-DSig spec} at the W3c
 * site
 *
 * @param  string   $message              The data you want to sign
 * @return string                         An XML-DSig document
 */
function xmldsig_envelope($message) {
674

675
    $openssl = OpenSslRepo::singleton();
676
    $wwwroot = dropslash(get_config('wwwroot'));
677
    $digest = sha1($message);
678

679
    $sig = base64_encode($openssl->sign_message($message));
680
681
    $message = base64_encode($message);
    $time = time();
682
    // TODO: Provide RESTful access to our public key as per KeyInfo element
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697

return <<<EOF
<?xml version="1.0" encoding="iso-8859-1"?>
    <signedMessage>
        <Signature Id="MoodleSignature" xmlns="http://www.w3.org/2000/09/xmldsig#">
            <SignedInfo>
                <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
                <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#dsa-sha1"/>
                <Reference URI="#XMLRPC-MSG">
                    <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
                    <DigestValue>$digest</DigestValue>
                </Reference>
            </SignedInfo>
            <SignatureValue>$sig</SignatureValue>
            <KeyInfo>
698
                <RetrievalMethod URI="{$wwwroot}/api/xmlrpc/publickey.php"/>
699
700
701
            </KeyInfo>
        </Signature>
        <object ID="XMLRPC-MSG">$message</object>
702
        <wwwroot>{$wwwroot}</wwwroot>
703
704
705
706
707
708
        <timestamp>$time</timestamp>
    </signedMessage>
EOF;

}

709
710
711
/**
 * Good candidate to be a singleton
 */
712
class OpenSslRepo {
713
714
715

    private $keypair = array();

716
717
718
719
720
721
722
723
    /**
     * Sign a message with our private key so that peers can verify that it came
     * from us.
     *
     * @param  string   $message
     * @return string
     * @access public
     */
724
725
726
727
728
    public function sign_message($message) {
        $signature = '';
        $bool      = openssl_sign($message, $signature, $this->keypair['privatekey']);
        return $signature;
    }
729

730
731
732
733
734
735
736
737
738
739
740
741
    /**
     * Decrypt some data using our private key and an auxiliary symmetric key. 
     * The symmetric key encrypted the data, and then was itself encrypted with
     * our public key.
     * This is because asymmetric keys can only safely be used to encrypt 
     * relatively short messages.
     *
     * @param string   $data
     * @param string   $key
     * @return string
     * @access public
     */
742
743
744
745
746
747
748
749
    public function openssl_open($data, $key) {
        $payload = '';
        $isOpen = openssl_open($data, $payload, $key, $this->keypair['privatekey']);

        if (!empty($isOpen)) {
            return $payload;
        } else {
            // Decryption failed... let's try our archived keys
750
            $openssl_history = $this->get_history();
751
752
753
754
755
756
            foreach($openssl_history as $keyset) {
                $keyresource = openssl_pkey_get_private($keyset['keypair_PEM']);
                $isOpen      = openssl_open($data, $payload, $key, $keyresource);
                if ($isOpen) {
                    // It's an older code, sir, but it checks out
                    // We notify the remote host that the key has changed
757
                    throw new CryptException($this->keypair['certificate'], 7025);
758
759
760
                }
            }
        }
761
        throw new CryptException('Invalid certificate', 7025);
762
763
    }

764
    /**
765
     * Singleton function keeps us from generating multiple instances of this
766
767
768
769
770
     * class
     *
     * @return object   The class instance
     * @access public
     */
771
772
773
774
775
    public static function singleton() {
        //single instance
        static $instance;

        //if we don't have the single instance, create one
776
        if (!isset($instance)) {
777
778
779
780
781
782
783
784
785
786
787
788
789
            $instance = new OpenSslRepo();
        }
        return($instance);
    }

    /**
     * This is a singleton - don't try to create an instance by doing:
     * $openssl = new OpenSslRepo();
     * Instead, use:
     * $openssl = OpenSslRepo::singleton();
     * 
     */
    private function __construct() {
790
        if (empty($this->keypair)) {
791
            $this->get_keypair();
792
793
794
            $this->keypair['privatekey'] = openssl_pkey_get_private($this->keypair['keypair_PEM']);
            $this->keypair['publickey']  = openssl_pkey_get_public($this->keypair['certificate']);
        }
795
        return $this;
796
797
    }

798
799
800
801
802
803
804
    /**
     * Utility function to get old SSL keys from the config table, or create a 
     * blank record if none exists.
     *
     * @return array    Array of keypair hashes
     * @access private
     */
805
806
    private function get_history() {
        $openssl_history = get_field('config', 'value', 'field', 'openssl_history');
807
        if (empty($openssl_history)) {
808
809
810
811
812
813
814
815
816
817
818
            $openssl_history = array();
            $record = new stdClass();
            $record->field = 'openssl_history';
            $record->value = serialize($openssl_history);
            insert_record('config', $record);
        } else {
            $openssl_history = unserialize($openssl_history);
        }
        return $openssl_history;
    }

819
820
821
822
823
824
825
826
    /**
     * Utility function to stash old SSL keys in the config table. It will retain
     * a max of 'openssl_generations' which is itself a value in config.
     *
     * @param  array    Array of keypair hashes
     * @return bool
     * @access private
     */
827
828
    private function save_history($openssl_history) {
        $openssl_generations = get_field('config', 'value', 'field', 'openssl_generations');
829
        if (empty($openssl_generations)) {
830
831
832
            set_config('openssl_generations', 6);
            $openssl_generations = 6;
        }
833
        if (count($openssl_history) > $openssl_generations) {
834
835
836
837
838
            $openssl_history = array_slice($openssl_history, 0, $openssl_generations);
        }
        return set_config('openssl_history', serialize($openssl_history));
    }

839
840
841
842
843
844
845
846
847
    /**
     * The get Overloader will let you pull out the 'certificate' and 'expires'
     * values
     *
     * @param  string    Name of the value you want
     * @return mixed     The value of the thing you asked for or null (if it 
     *                   doesn't exist or is private)
     * @access public
     */
848
    public function __get($name) {
849
850
        if ('certificate' === $name) return $this->keypair['certificate'];
        if ('expires' === $name)     return $this->keypair['expires'];
851
852
        return null;
    }
853

854
855
856
857
858
859
860
861
    /**
     * Get the keypair. If it doesn't exist, create it. If it's out of date, 
     * archive it and create a fresh pair.
     *
     * @param  bool      True if you want to force fresh keys to be generated
     * @return bool     
     * @access private
     */
862
863
864
865
    private function get_keypair($regenerate = null) {
        $this->keypair = array();
        $records       = null;
        
866
        if (empty($regenerate)) {
867
            $records = get_records_select_menu('config', "field IN ('openssl_keypair', 'openssl_keypair_expires')", 'field', 'field, value');
868
            if (!empty($records)) {
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
                list($this->keypair['certificate'], $this->keypair['keypair_PEM']) = explode('@@@@@@@@', $records['openssl_keypair']);
                $this->keypair['expires'] = $records['openssl_keypair_expires'];
                if ($this->keypair['expires'] <= time()) {
                    $openssl_history = $this->get_history();
                    array_unshift($openssl_history, $this->keypair);
                    $this->save_history($openssl_history);
                } else {
                    return true;
                }
            }
        }

        // Initialize a new set of SSL keys
        $this->keypair = array();
        $this->generate_keypair();

        // A record for the keys
        $keyrecord = new stdClass();
        $keyrecord->field = 'openssl_keypair';
        $keyrecord->value = implode('@@@@@@@@', $this->keypair);

        // A convenience record for the keys' expire time (UNIX timestamp)
        $expiresrecord        = new stdClass();
        $expiresrecord->field = 'openssl_keypair_expires';

        // Getting the expire timestamp is convoluted, but required:
        $credentials = openssl_x509_parse($this->keypair['certificate']);
896
        if (is_array($credentials) && isset($credentials['validTo_time_t'])) {
897
898
899
900
            $expiresrecord->value = $credentials['validTo_time_t'];
            $this->keypair['expires'] = $credentials['validTo_time_t'];
        }

901
        if (empty($records)) {
902
903
904
905
906
907
908
909
                   $result = insert_record('config', $keyrecord);
            return $result & insert_record('config', $expiresrecord);
        } else {
                   $result = update_record('config', $keyrecord,     array('field' => 'openssl_keypair'));
            return $result & update_record('config', $expiresrecord, array('field' => 'openssl_keypair_expires'));
        }
    }

910
911
912
913
914
915
916
917
918
919
920
921
    /**
     * Generate public/private keys and store in the config table
     *
     * Use the distinguished name provided to create a CSR, and then sign that CSR
     * with the same credentials. Store the keypair you create in the config table.
     * If a distinguished name is not provided, create one using the fullname of
     * 'the course with ID 1' as your organization name, and your hostname (as
     * detailed in $CFG->wwwroot).
     *
     * @param   array  $dn  The distinguished name of the server
     * @return  string      The signature over that text
     */
922
    private function generate_keypair() {
923
        $host = get_hostname_from_uri(get_config('wwwroot'));
924
925
926
927
928
929
930
931
932

        $organization = get_config('sitename');
        $email        = get_config('noreplyaddress');
        $country      = get_config('country');
        $province     = get_config('province');
        $locality     = get_config('locality');

        //TODO: Create additional fields on site setup and read those from 
        //      config. Then remove the next 3 linez
933
934
935
        if (empty($country))  $country  = 'NZ';
        if (empty($province)) $province = 'Wellington';
        if (empty($locality)) $locality = 'Te Aro';
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966

        $dn = array(
           "countryName" => $country,
           "stateOrProvinceName" => $province,
           "localityName" => $locality,
           "organizationName" => $organization,
           "organizationalUnitName" => 'Mahara',
           "commonName" => get_config('wwwroot'),
           "emailAddress" => $email
        );

        // ensure we remove trailing slashes
        $dn["commonName"] = preg_replace(':/$:', '', $dn["commonName"]);

        $new_key = openssl_pkey_new();
        $csr_rsc = openssl_csr_new($dn, $new_key, array('private_key_bits',2048));
        $selfSignedCert = openssl_csr_sign($csr_rsc, null, $new_key, 28 /*days*/);
        unset($csr_rsc); // Free up the resource

        // We export our self-signed certificate to a string.
        openssl_x509_export($selfSignedCert, $this->keypair['certificate']);
        openssl_x509_free($selfSignedCert);

        // Export your public/private key pair as a PEM encoded string. You
        // can protect it with an optional passphrase if you wish.
        $export = openssl_pkey_export($new_key, $this->keypair['keypair_PEM'] /* , $passphrase */);
        openssl_pkey_free($new_key);
        unset($new_key); // Free up the resource

        return $this;
    }
967
968
969
}

class PublicKey {
970

971
972
973
    private   $credentials = array();
    private   $wwwroot     = '';
    private   $certificate = '';
974
975

    function __construct($keystring, $wwwroot) {
976

977
978
979
980
981
        $this->credentials = openssl_x509_parse($keystring);
        $this->wwwroot     = dropslash($wwwroot);
        $this->certificate = $keystring;

        if ($this->credentials == false) {
982
            throw new CryptException(get_string('errornotvalidsslcertificate', 'auth'), 1);
983
            return false;
984
        } elseif ($this->credentials['subject']['CN'] != $this->wwwroot) {
985
            throw new CryptException(get_string('errorcertificateinvalidwwwroot', 'auth', $this->credentials['subject']['CN'], $this->wwwroot), 1);
986
987
            return false;
        } else {
988
            return $this->credentials;
989
990
991
        }
    }

992
    function __get($name) {
993
        if ('expires' == $name) return $this->credentials['validTo_time_t'];
994
        return $this->{$name};
995
996
997
    }
}
?>