Passed
Push — master ( 4efa95...92e190 )
by Stefan
07:10
created

SilverbulletCertificate::soapFromXmlInteger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * Contributions to this work were made on behalf of the GÉANT project, a 
5
 * project that has received funding from the European Union’s Horizon 2020 
6
 * research and innovation programme under Grant Agreement No. 731122 (GN4-2).
7
 * 
8
 * On behalf of the GÉANT project, GEANT Association is the sole owner of the 
9
 * copyright in all material which was developed by a member of the GÉANT 
10
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
11
 * Commerce in Amsterdam with registration number 40535155 and operates in the
12
 * UK as a branch of GÉANT Vereniging. 
13
 * 
14
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
15
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
16
 * 
17
 * License: see the web/copyright.inc.php file in the file structure or
18
 *          <base_url>/copyright.php after deploying the software
19
 */
20
21
/**
22
 * This file contains the SilverbulletInvitation class.
23
 *
24
 * @author Stefan Winter <[email protected]>
25
 * @author Tomasz Wolniewicz <[email protected]>
26
 *
27
 * @package Developer
28
 *
29
 */
30
31
namespace core;
32
33
use \Exception;
34
use \SoapFault;
35
36
class SilverbulletCertificate extends EntityWithDBProperties {
37
38
    public $username;
39
    public $expiry;
40
    public $serial;
41
    public $dbId;
42
    public $invitationId;
43
    public $userId;
44
    public $profileId;
45
    public $issued;
46
    public $device;
47
    public $revocationStatus;
48
    public $revocationTime;
49
    public $ocsp;
50
    public $ocspTimestamp;
51
    public $status;
52
    public $ca_type;
53
54
    const CERTSTATUS_VALID = 1;
55
    const CERTSTATUS_EXPIRED = 2;
56
    const CERTSTATUS_REVOKED = 3;
57
    const CERTSTATUS_INVALID = 4;
58
59
    /**
60
     * instantiates an existing certificate, identified either by its serial
61
     * number or the username. 
62
     * 
63
     * Use static issueCertificate() to generate a whole new cert.
64
     * 
65
     * @param int|string $identifier identify certificate either by CN or by serial
66
     * @param string     $certtype   RSA or ECDSA?
67
     */
68
    public function __construct($identifier, $certtype) {
69
        $this->databaseType = "INST";
70
        parent::__construct();
71
        $this->username = "";
72
        $this->expiry = "2000-01-01 00:00:00";
73
        $this->serial = -1;
74
        $this->dbId = -1;
75
        $this->invitationId = -1;
76
        $this->userId = -1;
77
        $this->profileId = -1;
78
        $this->issued = "2000-01-01 00:00:00";
79
        $this->device = NULL;
80
        $this->revocationStatus = "REVOKED";
81
        $this->revocationTime = "2000-01-01 00:00:00";
82
        $this->ocsp = NULL;
83
        $this->ocspTimestamp = "2000-01-01 00:00:00";
84
        $this->ca_type = $certtype;
85
        $this->status = SilverbulletCertificate::CERTSTATUS_INVALID;
86
87
        $incoming = FALSE;
88
        if (is_numeric($identifier)) {
89
            $incoming = $this->databaseHandle->exec("SELECT `id`, `profile_id`, `silverbullet_user_id`, `silverbullet_invitation_id`, `serial_number`, `cn` ,`expiry`, `issued`, `device`, `revocation_status`, `revocation_time`, `OCSP`, `OCSP_timestamp` FROM `silverbullet_certificate` WHERE serial_number = ? AND ca_type = ?", "is", $identifier, $certtype);
90
        } else { // it's a string instead
91
            $incoming = $this->databaseHandle->exec("SELECT `id`, `profile_id`, `silverbullet_user_id`, `silverbullet_invitation_id`, `serial_number`, `cn` ,`expiry`, `issued`, `device`, `revocation_status`, `revocation_time`, `OCSP`, `OCSP_timestamp` FROM `silverbullet_certificate` WHERE cn = ? AND ca_type = ?", "ss", $identifier, $certtype);
92
        }
93
94
        // SELECT -> mysqli_resource, not boolean
95
        while ($oneResult = mysqli_fetch_object(/** @scrutinizer ignore-type */ $incoming)) { // there is only at most one
96
            $this->username = $oneResult->cn;
97
            $this->expiry = $oneResult->expiry;
98
            $this->serial = $oneResult->sn;
99
            $this->dbId = $oneResult->id;
100
            $this->invitationId = $oneResult->silverbullet_invitation_id;
101
            $this->userId = $oneResult->silverbullet_user_id;
102
            $this->profileId = $oneResult->profile_id;
103
            $this->issued = $oneResult->issued;
104
            $this->device = $oneResult->device;
105
            $this->revocationStatus = $oneResult->revocation_status;
106
            $this->revocationTime = $oneResult->revocation_time;
107
            $this->ocsp = $oneResult->OCSP;
108
            $this->ocspTimestamp = $oneResult->OCSP_timestamp;
109
            // is the cert expired?
110
            $now = new \DateTime();
111
            $cert_expiry = new \DateTime($this->expiry);
112
            $delta = $now->diff($cert_expiry);
113
            $this->status = ($delta->invert == 1 ? SilverbulletCertificate::CERTSTATUS_EXPIRED : SilverbulletCertificate::CERTSTATUS_VALID);
114
            // expired is expired; even if it was previously revoked. But do update status for revoked ones...
115
            if ($this->status == SilverbulletCertificate::CERTSTATUS_VALID && $this->revocationStatus == "REVOKED") {
116
                $this->status = SilverbulletCertificate::CERTSTATUS_REVOKED;
117
            }
118
        }
119
    }
120
121
    /**
122
     * retrieve basic information about the certificate
123
     * 
124
     * @return array of basic certificate details
125
     */
126
    public function getBasicInfo() {
127
        $returnArray = []; // unnecessary because the iterator below is never empty, but Scrutinizer gets excited nontheless
128
        foreach (['status', 'serial', 'username', 'issued', 'expiry', 'ca_type'] as $key) {
129
            $returnArray[$key] = $this->$key;
130
        }
131
        $returnArray['device'] = \devices\Devices::listDevices()[$this->device]['display'] ?? $this->device;
132
        return $returnArray;
133
    }
134
135
    /**
136
     * we don't use caching in SB, so this function does nothing
137
     * 
138
     * @return void
139
     */
140
    public function updateFreshness() {
141
        // nothing to be done here.
142
    }
143
144
    /**
145
     * issue a certificate based on a token
146
     *
147
     * @param string $token          the token string
148
     * @param string $importPassword the PIN
149
     * @param string $certtype       is this for the RSA or ECDSA CA?
150
     * @return array
151
     */
152
    public static function issueCertificate($token, $importPassword, $certtype) {
153
        $loggerInstance = new common\Logging();
154
        $databaseHandle = DBConnection::handle("INST");
155
        $loggerInstance->debug(5, "generateCertificate() - starting.\n");
156
        $invitationObject = new SilverbulletInvitation($token);
157
        $profile = new ProfileSilverbullet($invitationObject->profile);
158
        $inst = new IdP($profile->institution);
159
        $loggerInstance->debug(5, "tokenStatus: done, got " . $invitationObject->invitationTokenStatus . ", " . $invitationObject->profile . ", " . $invitationObject->userId . ", " . $invitationObject->expiry . ", " . $invitationObject->invitationTokenString . "\n");
160
        if ($invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_VALID && $invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_PARTIALLY_REDEEMED) {
161
            throw new Exception("Attempt to generate a SilverBullet installer with an invalid/redeemed/expired token. The user should never have gotten that far!");
162
        }
163
164
        // SQL query to find the expiry date of the *user* to find the correct ValidUntil for the cert
165
        $user = $invitationObject->userId;
166
        $userrow = $databaseHandle->exec("SELECT expiry FROM silverbullet_user WHERE id = ?", "i", $user);
167
        // SELECT -> resource, not boolean
168
        if ($userrow->num_rows != 1) {
169
            throw new Exception("Despite a valid token, the corresponding user was not found in database or database query error!");
170
        }
171
        $expiryObject = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrow);
172
        $loggerInstance->debug(5, "EXP: " . $expiryObject->expiry . "\n");
173
        $expiryDateObject = date_create_from_format("Y-m-d H:i:s", $expiryObject->expiry);
174
        if ($expiryDateObject === FALSE) {
175
            throw new Exception("The expiry date we got from the DB is bogus!");
176
        }
177
        $loggerInstance->debug(5, $expiryDateObject->format("Y-m-d H:i:s") . "\n");
178
        // date_create with no parameters can't fail, i.e. is never FALSE
179
        $validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $expiryDateObject);
180
        $expiryDays = $validity->days + 1;
181
        if ($validity->invert == 1) { // negative! That should not be possible
182
            throw new Exception("Attempt to generate a certificate for a user which is already expired!");
183
        }
184
        switch ($certtype) {
185
            case \devices\Devices::SUPPORT_RSA:
186
                $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, 'encrypt_key' => FALSE]);
187
                break;
188
            case \devices\Devices::SUPPORT_ECDSA:
189
                $privateKey = openssl_pkey_new(['curve_name' => 'secp384r1', 'private_key_type' => OPENSSL_KEYTYPE_EC, 'encrypt_key' => FALSE]);
190
                break;
191
            default:
192
                throw new Exception("Unknown certificate type!");
193
        }
194
195
        $csr = SilverbulletCertificate::generateCsr($privateKey, strtoupper($inst->federation), $profile->getAttributes("internal:realm")[0]['value'], $certtype);
196
197
        $loggerInstance->debug(5, "generateCertificate: proceeding to sign cert.\n");
198
199
        $certMeta = SilverbulletCertificate::signCsr($csr["CSR"], $expiryDays, $certtype);
200
        $cert = $certMeta["CERT"];
201
        $issuingCaPem = $certMeta["ISSUER"];
202
        $rootCaPem = $certMeta["ROOT"];
203
        $serial = $certMeta["SERIAL"];
204
205
        if ($cert === FALSE) {
206
            throw new Exception("The CA did not generate a certificate.");
207
        }
208
        $loggerInstance->debug(5, "generateCertificate: post-processing certificate.\n");
209
210
        // with the cert, our private key and import password, make a PKCS#12 container out of it
211
        $exportedCertProt = "";
212
        openssl_pkcs12_export($cert, $exportedCertProt, $privateKey, $importPassword, ['extracerts' => [$issuingCaPem /* , $rootCaPem */]]);
213
        // and without intermediate, to keep EAP conversation short where possible
214
        $exportedNoInterm = "";
215
        openssl_pkcs12_export($cert, $exportedNoInterm, $privateKey, $importPassword, []);
216
        $exportedCertClear = "";
217
        openssl_pkcs12_export($cert, $exportedCertClear, $privateKey, "", ['extracerts' => [$issuingCaPem, $rootCaPem]]);
218
        // store resulting cert CN and expiry date in separate columns into DB - do not store the cert data itself as it contains the private key!
219
        // we need the *real* expiry date, not just the day-approximation
220
        $x509 = new \core\common\X509();
221
        $certString = "";
222
        openssl_x509_export($cert, $certString);
223
        $parsedCert = $x509->processCertificate($certString);
224
        $loggerInstance->debug(5, "CERTINFO: " . print_r($parsedCert['full_details'], true));
225
        $realExpiryDate = date_create_from_format("U", $parsedCert['full_details']['validTo_time_t'])->format("Y-m-d H:i:s");
226
227
        // store new cert info in DB
228
        $databaseHandle->exec("INSERT INTO `silverbullet_certificate` (`profile_id`, `silverbullet_user_id`, `silverbullet_invitation_id`, `serial_number`, `cn` ,`expiry`, `ca_type`) VALUES (?, ?, ?, ?, ?, ?, ?)", "iiissss", $invitationObject->profile, $invitationObject->userId, $invitationObject->identifier, $serial, $csr["USERNAME"], $realExpiryDate, $certtype);
229
        // newborn cert immediately gets its "valid" OCSP response
230
        $certObject = new SilverbulletCertificate($serial, $certtype);
231
        $certObject->triggerNewOCSPStatement();
232
// return PKCS#12 data stream
233
        return [
234
            "certObject" => $certObject,
235
            "certdata" => $exportedCertProt,
236
            "certdata_nointermediate" => $exportedNoInterm,
237
            "certdataclear" => $exportedCertClear,
238
            // Scrutinizer thinks this needs to be a string, but a resource is just fine
239
            "sha1" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha1"),
240
            "sha256" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha256"),
241
            'importPassword' => $importPassword,
242
            'GUID' => common\Entity::uuid("", $exportedCertProt),
243
        ];
244
    }
245
246
    /**
247
     * triggers a new OCSP statement for the given serial number
248
     * 
249
     * @return string DER-encoded OCSP status info (binary data!)
250
     */
251
    public function triggerNewOCSPStatement() {
252
        $logHandle = new \core\common\Logging();
253
        $logHandle->debug(2, "Triggering new OCSP statement for serial $this->serial.\n");
254
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
255
            case "embedded":
256
                $certstatus = "";
257
                // get all relevant info from object properties
258
                if ($this->serial >= 0) { // let's start with the assumption that the cert is valid
259
                    if ($this->revocationStatus == "REVOKED") {
260
                        // already revoked, simply return canned OCSP response
261
                        $certstatus = "R";
262
                    } else {
263
                        $certstatus = "V";
264
                    }
265
                }
266
267
                $originalExpiry = date_create_from_format("Y-m-d H:i:s", $this->expiry);
268
                if ($originalExpiry === FALSE) {
269
                    throw new Exception("Unable to calculate original expiry date, input data bogus!");
270
                }
271
                $validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $originalExpiry);
272
                if ($validity->invert == 1) {
273
                    // negative! Cert is already expired, no need to revoke. 
274
                    // No need to return anything really, but do return the last known OCSP statement to prevent special case
275
                    $certstatus = "E";
276
                }
277
                $profile = new ProfileSilverbullet($this->profileId);
278
                $inst = new IdP($profile->institution);
279
                $federation = strtoupper($inst->federation);
280
                // generate stub index.txt file
281
                $tempdirArray = \core\common\Entity::createTemporaryDirectory("test");
282
                $tempdir = $tempdirArray['dir'];
283
                $nowIndexTxt = (new \DateTime())->format("ymdHis") . "Z";
284
                $expiryIndexTxt = $originalExpiry->format("ymdHis") . "Z";
285
                $serialHex = strtoupper(dechex($this->serial));
286
                if (strlen($serialHex) % 2 == 1) {
287
                    $serialHex = "0" . $serialHex;
288
                }
289
290
                $indexStatement = "$certstatus\t$expiryIndexTxt\t" . ($certstatus == "R" ? "$nowIndexTxt,unspecified" : "") . "\t$serialHex\tunknown\t/O=" . CONFIG_CONFASSISTANT['CONSORTIUM']['name'] . "/OU=$federation/CN=$this->username\n";
291
                $logHandle->debug(4, "index.txt contents-to-be: $indexStatement");
292
                if (!file_put_contents($tempdir . "/index.txt", $indexStatement)) {
293
                    $logHandle->debug(1, "Unable to write openssl index.txt file for revocation handling!");
294
                }
295
                // index.txt.attr is dull but needs to exist
296
                file_put_contents($tempdir . "/index.txt.attr", "unique_subject = yes\n");
297
                // call "openssl ocsp" to manufacture our own OCSP statement
298
                // adding "-rmd sha1" to the following command-line makes the
299
                // choice of signature algorithm for the response explicit
300
                // but it's only available from openssl-1.1.0 (which we do not
301
                // want to require just for that one thing).
302
                $execCmd = CONFIG['PATHS']['openssl'] . " ocsp -issuer " . ROOT . "/config/SilverbulletClientCerts/real-" . $this->ca_type . ".pem -sha1 -ndays 10 -no_nonce -serial 0x$serialHex -CA " . ROOT . "/config/SilverbulletClientCerts/real-" . $this->ca_type . ".pem -rsigner " . ROOT . "/config/SilverbulletClientCerts/real-" . $this->ca_type . ".pem -rkey " . ROOT . "/config/SilverbulletClientCerts/real-" . $this->ca_type . ".key -index $tempdir/index.txt -no_cert_verify -respout $tempdir/$serialHex.response.der";
303
                $logHandle->debug(2, "Calling openssl ocsp with following cmdline: $execCmd\n");
304
                $output = [];
305
                $return = 999;
306
                exec($execCmd, $output, $return);
307
                if ($return !== 0) {
308
                    throw new Exception("Non-zero return value from openssl ocsp!");
309
                }
310
                $ocsp = file_get_contents($tempdir . "/$serialHex.response.der");
311
                // remove the temp dir!
312
                unlink($tempdir . "/$serialHex.response.der");
313
                unlink($tempdir . "/index.txt.attr");
314
                unlink($tempdir . "/index.txt");
315
                rmdir($tempdir);
316
                break;
317
            case "eduPKI":
318
                // nothing to be done here - eduPKI have their own OCSP responder
319
                // and the certs point to it. So we are not in the loop.
320
                break;
321
            default:
322
                /* HTTP POST the serial to the CA. The CA knows about the state of
323
                 * the certificate.
324
                 *
325
                 * $httpResponse = httpRequest("https://clientca.hosted.eduroam.org/ocsp/", ["serial" => $serial ] );
326
                 *
327
                 * The result of this if clause has to be a DER-encoded OCSP statement
328
                 * to be stored in the variable $ocsp
329
                 */
330
                throw new Exception("This type of silverbullet CA is not implemented yet!");
331
        }
332
        // write the new statement into DB
333
        $this->databaseHandle->exec("UPDATE silverbullet_certificate SET OCSP = ?, OCSP_timestamp = NOW() WHERE serial_number = ?", "si", $ocsp, $this->serial);
334
        return $ocsp;
335
    }
336
337
    /**
338
     * revokes a certificate
339
     * 
340
     * @return void
341
     * @throws Exception
342
     */
343
    public function revokeCertificate() {
344
        $nowSql = (new \DateTime())->format("Y-m-d H:i:s");
345
        // regardless if embedded or not, always keep local state in our own DB
346
        $this->databaseHandle->exec("UPDATE silverbullet_certificate SET revocation_status = 'REVOKED', revocation_time = ? WHERE serial_number = ? AND ca_type = ?", "sis", $nowSql, $this->serial, $this->ca_type);
347
        $this->loggerInstance->debug(2, "Certificate revocation status for $this->serial updated, about to call triggerNewOCSPStatement().\n");
348
        // newly instantiate us, DB content has changed...
349
        $certObject = new SilverbulletCertificate((string) $this->serial, $this->ca_type);
350
        // embedded CA does "nothing special" for revocation: the DB change was the entire thing to do
351
        // but for external CAs, we need to notify explicitly that the cert is now revoked
352
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
353
            case "embedded":
354
                $certObject->triggerNewOCSPStatement();
355
                break;
356
            case "eduPKI":
357
                try {
358
                    $soap = SilverbulletCertificate::initEduPKISoapSession("RA");
359
                    $soapRevocationSerial = $soap->newRevocationRequest(["Serial", $certObject->serial], "");
360
                    if ($soapRevocationSerial == 0) {
361
                        throw new Exception("Unable to create revocation request, serial number was zero.");
362
                    }
363
                    // retrieve the raw request to prepare for signature and approval
364
                    $soapRawRevRequest = $soap->getRawRevocationRequest($soapRevocationSerial);
365
                    if (strlen($soapRawRevRequest) < 10) { // very basic error handling
366
                        throw new Exception("Suspiciously short data to sign!");
367
                    }
368
                    // for obnoxious reasons, we have to dump the request into a file and let pkcs7_sign read from the file
369
                    // rather than just using the string. Grr.
370
                    $tempdir = \core\common\Entity::createTemporaryDirectory("test");
371
                    file_put_contents($tempdir['dir'] . "/content.txt", $soapRawRevRequest);
372
                    // retrieve our RA cert from filesystem
373
                    // sign the data, using cmdline because openssl_pkcs7_sign produces strange results
374
                    // -binary didn't help, nor switch -md to sha1 sha256 or sha512
375
                    $this->loggerInstance->debug(5, "Actual content to be signed is this:\n$soapRawRevRequest\n");
376
                    $execCmd = CONFIG['PATHS']['openssl'] . " smime -sign -binary -in " . $tempdir['dir'] . "/content.txt -out " . $tempdir['dir'] . "/signature.txt -outform pem -inkey " . ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.clearkey -signer " . ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.pem";
377
                    $this->loggerInstance->debug(2, "Calling openssl smime with following cmdline: $execCmd\n");
378
                    $output = [];
379
                    $return = 999;
380
                    exec($execCmd, $output, $return);
381
                    if ($return !== 0) {
382
                        throw new Exception("Non-zero return value from openssl smime!");
383
                    }
384
                    // and get the signature blob back from the filesystem
385
                    $detachedSig = trim(file_get_contents($tempdir['dir'] . "/signature.txt"));
386
                    $soapIssueRev = $soap->approveRevocationRequest($soapRevocationSerial, $soapRawRevRequest, $detachedSig);
387
                    if ($soapIssueRev === FALSE) {
388
                        throw new Exception("The locally approved revocation request was NOT processed by the CA.");
389
                    }
390
                } catch (Exception $e) {
391
                    // PHP 7.1 can do this much better
392
                    if (is_soap_fault($e)) {
393
                        throw new Exception("Error when sending SOAP request: " . "{$e->faultcode}: {$e->faultstring}\n");
394
                    }
395
                    throw new Exception("Something odd happened while doing the SOAP request:" . $e->getMessage());
396
                }
397
                break;
398
            default:
399
                throw new Exception("Unknown type of CA requested!");
400
        }
401
    }
402
403
    /**
404
     * create a CSR
405
     * 
406
     * @param resource $privateKey the private key to create the CSR with
407
     * @param string   $fed        the federation to which the certificate belongs
408
     * @param string   $realm      the realm for the future username
409
     * @param string   $certtype   which type of certificate to generate: RSA or ECDSA
410
     * @return array with the CSR and some meta info
411
     */
412
    private static function generateCsr($privateKey, $fed, $realm, $certtype) {
413
        $databaseHandle = DBConnection::handle("INST");
414
        $loggerInstance = new common\Logging();
415
        $usernameIsUnique = FALSE;
416
        $username = "";
417
        while ($usernameIsUnique === FALSE) {
418
            $usernameLocalPart = common\Entity::randomString(64 - 1 - strlen($realm), "0123456789abcdefghijklmnopqrstuvwxyz");
419
            $username = $usernameLocalPart . "@" . $realm;
420
            $uniquenessQuery = $databaseHandle->exec("SELECT cn from silverbullet_certificate WHERE cn = ?", "s", $username);
421
            // SELECT -> resource, not boolean
422
            if (mysqli_num_rows(/** @scrutinizer ignore-type */ $uniquenessQuery) == 0) {
423
                $usernameIsUnique = TRUE;
424
                }
425
                }
426
427
                $loggerInstance->debug(5, "generateCertificate: generating CSR.\n");
428
429
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
430
            case "embedded":
431
        switch ($certtype) {
432
            case \devices\Devices::SUPPORT_RSA:
433
                $alg = "sha256";
434
                break;
435
            case \devices\Devices::SUPPORT_ECDSA:
436
                $alg = "ecdsa-with-SHA1";
437
                break;
438
            default:
439
                throw new Exception("Unknown certificate type!");
440
        }
441
        $newCsr = openssl_csr_new(
442
                ['O' => CONFIG_CONFASSISTANT['CONSORTIUM']['name'],
443
            'OU' => $fed,
444
            'CN' => $username,
445
            // 'emailAddress' => $username,
446
                ], $privateKey, [
447
            'digest_alg' => $alg,
448
            'req_extensions' => 'v3_req',
449
                ]
450
        );
451
                break;
452
            case "eduPKI":
453
                $tempdirArray = \core\common\Entity::createTemporaryDirectory("test");
454
                $tempdir = $tempdirArray['dir'];
455
                // dump private key into directly
456
                $outstring = "";
457
                openssl_pkey_export($privateKey, $outstring);
458
                file_put_contents($tempdir . "/pkey.pem", $outstring);
459
                // PHP can only do one DC in the Subject. But we need three.
460
                $execCmd = CONFIG['PATHS']['openssl'] . " req -new -sha256 -key $tempdir/pkey.pem -out $tempdir/request.csr -subj /DC=test/DC=test/DC=eduroam/C=$fed/O=" . CONFIG_CONFASSISTANT['CONSORTIUM']['name'] . "/OU=$fed/CN=$username/emailAddress=$username";
461
                $loggerInstance->debug(2, "Calling openssl req with following cmdline: $execCmd\n");
462
                $output = [];
463
                $return = 999;
464
                exec($execCmd, $output, $return);
465
                if ($return !== 0) {
466
                    throw new Exception("Non-zero return value from openssl req!");
467
                }
468
                $newCsr = file_get_contents("$tempdir/request.csr");
469
                // remove the temp dir!
470
                unlink("$tempdir/pkey.pem");
471
                unlink("$tempdir/request.csr");
472
                rmdir($tempdir);
473
                break;
474
            default:
475
                throw new Exception("Unknown CA!");
476
        }
477
        if ($newCsr === FALSE) {
478
            throw new Exception("Unable to create a CSR!");
479
        }
480
        return [
481
            "CSR" => $newCsr, // a resource for embedded, a string for eduPKI
482
            "USERNAME" => $username,
483
            "FED" => $fed
484
        ];
485
    }
486
487
    /**
488
     * a function that converts integers beyond PHP_INT_MAX to strings for
489
     * sending in XML messages
490
     *
491
     * taken and adapted from 
492
     * https://www.uni-muenster.de/WWUCA/de/howto-special-phpsoap.html
493
     * 
494
     * @param string $x the integer as an XML fragment
495
     * @return array the integer in array notation
496
     */
497
    public static function soapFromXmlInteger($x) {
498
        $y = simplexml_load_string($x);
499
        return array(
500
            $y->getName(),
501
            $y->__toString()
502
        );
503
    }
504
505
    /**
506
     * a function that converts integers beyond PHP_INT_MAX to strings for
507
     * sending in XML messages
508
     * 
509
     * @param array $x the integer in array notation
510
     * @return string the integer as string in an XML fragment
511
     */
512
    public static function soapToXmlInteger($x) {
513
        return '<' . $x[0] . '>'
514
                . htmlentities($x[1], ENT_NOQUOTES | ENT_XML1)
515
                . '</' . $x[0] . '>';
516
    }
517
518
    /**
519
     * sets up a connection to the eduPKI SOAP interfaces
520
     * There is a public interface and an RA-restricted interface;
521
     * the latter needs an RA client certificate to identify the operator
522
     * 
523
     * @param string $type to which interface should we connect to - "PUBLIC" or "RA"
524
     * @return \SoapClient the connection object
525
     * @throws Exception
526
     */
527
    private static function initEduPKISoapSession($type) {
528
        // set context parameters common to both endpoints
529
        $context_params = [
530
            'http' => [
531
                'timeout' => 60,
532
                'user_agent' => 'Stefan',
533
                'protocol_version' => 1.1
534
            ],
535
            'ssl' => [
536
                'verify_peer' => true,
537
                'verify_peer_name' => true,
538
                // below is the CA "/C=DE/O=Deutsche Telekom AG/OU=T-TeleSec Trust Center/CN=Deutsche Telekom Root CA 2"
539
                'cafile' => ROOT . "/config/SilverbulletClientCerts/eduPKI-webserver-root.pem",
540
                'verify_depth' => 5,
541
                'capture_peer_cert' => true,
542
            ],
543
        ];
544
        $url = "";
545
        switch ($type) {
546
            case "PUBLIC":
547
                $url = "https://pki.edupki.org/edupki-test-ca/cgi-bin/pub/soap?wsdl=1";
548
                $context_params['ssl']['peer_name'] = 'pki.edupki.org';
549
                break;
550
            case "RA":
551
                $url = "https://ra.edupki.org/edupki-test-ca/cgi-bin/ra/soap?wsdl=1";
552
                $context_params['ssl']['peer_name'] = 'ra.edupki.org';
553
                break;
554
            default:
555
                throw new Exception("Unknown type of eduPKI interface requested.");
556
        }
557
        if ($type == "RA") { // add client auth parameters to the context
558
            $context_params['ssl']['local_cert'] = ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.pem";
559
            $context_params['ssl']['local_pk'] = ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.clearkey";
560
            // $context_params['ssl']['passphrase'] = SilverbulletCertificate::EDUPKI_RA_PKEY_PASSPHRASE;
561
        }
562
        // initialse connection to eduPKI CA / eduroam RA
563
        $soap = new \SoapClient($url, [
564
            'soap_version' => SOAP_1_1,
565
            'trace' => TRUE,
566
            'exceptions' => TRUE,
567
            'connection_timeout' => 5, // if can't establish the connection within 5 sec, something's wrong
568
            'cache_wsdl' => WSDL_CACHE_NONE,
569
            'user_agent' => 'eduroam CAT to eduPKI SOAP Interface',
570
            'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
571
            'stream_context' => stream_context_create($context_params),
572
            'typemap' => [
573
                [
574
                    'type_ns' => 'http://www.w3.org/2001/XMLSchema',
575
                    'type_name' => 'integer',
576
                    'from_xml' => 'core\SilverbulletCertificate::soapFromXmlInteger',
577
                    'to_xml' => 'core\SilverbulletCertificate::soapToXmlInteger',
578
                ],
579
            ],
580
                ]
581
        );
582
        return $soap;
583
    }
584
585
    const EDUPKI_RA_ID = 700;
586
    const EDUPKI_CERT_PROFILE = "User SOAP";
587
    const EDUPKI_RA_PKEY_PASSPHRASE = "...";
588
589
    /**
590
     * take a CSR and sign it with our issuing CA's certificate
591
     * 
592
     * @param mixed  $csr        the CSR
593
     * @param int    $expiryDays the number of days until the cert is going to expire
594
     * @param string $certtype   which type of certificate to use for signing
595
     * @return array the cert and some meta info
596
     */
597
    private static function signCsr($csr, $expiryDays, $certtype) {
598
        $loggerInstance = new common\Logging();
599
        $databaseHandle = DBConnection::handle("INST");
600
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
601
            case "embedded":
602
                $rootCaPem = file_get_contents(ROOT . "/config/SilverbulletClientCerts/rootca-$certtype.pem");
603
                $issuingCaPem = file_get_contents(ROOT . "/config/SilverbulletClientCerts/real-$certtype.pem");
604
                $issuingCa = openssl_x509_read($issuingCaPem);
605
                $issuingCaKey = openssl_pkey_get_private("file://" . ROOT . "/config/SilverbulletClientCerts/real-$certtype.key");
606
                $nonDupSerialFound = FALSE;
607
                do {
608
                    $serial = random_int(1000000000, PHP_INT_MAX);
609
                    $dupeQuery = $databaseHandle->exec("SELECT serial_number FROM silverbullet_certificate WHERE serial_number = ? AND ca_type = ?", "is", $serial, $certtype);
610
                    // SELECT -> resource, not boolean
611
                    if (mysqli_num_rows(/** @scrutinizer ignore-type */$dupeQuery) == 0) {
612
                        $nonDupSerialFound = TRUE;
613
                    }
614
                } while (!$nonDupSerialFound);
615
                $loggerInstance->debug(5, "generateCertificate: signing imminent with unique serial $serial, cert type $certtype.\n");
616
                switch ($certtype) {
617
                    case \devices\Devices::SUPPORT_RSA:
618
                        $alg = "sha256";
619
                        break;
620
                    case \devices\Devices::SUPPORT_ECDSA:
621
                        $alg = "ecdsa-with-SHA1";
622
                        break;
623
                    default:
624
                        throw new Exception("Unknown cert type!");
625
                }
626
                return [
627
                    "CERT" => openssl_csr_sign($csr, $issuingCa, $issuingCaKey, $expiryDays, ['digest_alg' => $alg, 'config' => dirname(__DIR__) . "/config/SilverbulletClientCerts/openssl-$certtype.cnf"], $serial),
628
                    "SERIAL" => $serial,
629
                    "ISSUER" => $raCertFile,
630
                    "ROOT" => $rootCaPem,
631
                ];
632
            case "eduPKI":
633
                // initialse connection to eduPKI CA / eduroam RA and send the request to them
634
                try {
635
                    $altArray = [# Array mit den Subject Alternative Names
636
                        "email:" . $csr["USERNAME"]
637
                    ];
638
                    $soapPub = SilverbulletCertificate::initEduPKISoapSession("PUBLIC");
639
                    $loggerInstance->debug(5, "FIRST ACTUAL SOAP REQUEST (Public, newRequest)!\n");
640
                    $loggerInstance->debug(5, "PARAM_1: " . SilverbulletCertificate::EDUPKI_RA_ID . "\n");
641
                    $loggerInstance->debug(5, "PARAM_2: " . $csr["CSR"] . "\n");
642
                    $loggerInstance->debug(5, "PARAM_3: ");
643
                    $loggerInstance->debug(5, $altArray);
644
                    $loggerInstance->debug(5, "PARAM_4: " . SilverbulletCertificate::EDUPKI_CERT_PROFILE . "\n");
645
                    $loggerInstance->debug(5, "PARAM_5: " . sha1("notused") . "\n");
646
                    $loggerInstance->debug(5, "PARAM_6: " . $csr["USERNAME"] . "\n");
647
                    $loggerInstance->debug(5, "PARAM_7: " . $csr["USERNAME"] . "\n");
648
                    $loggerInstance->debug(5, "PARAM_8: " . ProfileSilverbullet::PRODUCTNAME . "\n");
649
                    $loggerInstance->debug(5, "PARAM_9: false\n");
650
                    $soapNewRequest = $soapPub->newRequest(
651
                            SilverbulletCertificate::EDUPKI_RA_ID, # RA-ID
652
                            $csr["CSR"], # Request im PEM-Format
653
                            $altArray, # altNames
654
                            SilverbulletCertificate::EDUPKI_CERT_PROFILE, # Zertifikatprofil
655
                            sha1("notused"), # PIN
656
                            $csr["USERNAME"], # Name des Antragstellers
657
                            $csr["USERNAME"], # Kontakt-E-Mail
658
                            ProfileSilverbullet::PRODUCTNAME, # Organisationseinheit des Antragstellers
659
                            false                   # Veröffentlichen des Zertifikats?
660
                    );
661
                    $loggerInstance->debug(5, $soapPub->__getLastRequest());
662
                    $loggerInstance->debug(5, $soapPub->__getLastResponse());
663
                    if ($soapNewRequest == 0) {
664
                        throw new Exception("Error when sending SOAP request (request serial number was zero). No further details available.");
665
                    }
666
                    $soapReqnum = intval($soapNewRequest);
667
                } catch (Exception $e) {
668
                    // PHP 7.1 can do this much better
669
                    if (is_soap_fault($e)) {
670
                        throw new Exception("Error when sending SOAP request: " . "{$e->faultcode}:  {
671
                    $e->faultstring
672
                }\n");
673
                    }
674
                    throw new Exception("Something odd happened while doing the SOAP request:" . $e->getMessage());
675
                }
676
                try {
677
                    $soap = SilverbulletCertificate::initEduPKISoapSession("RA");
678
                    // tell the CA the desired expiry date of the new certificate
679
                    $expiry = new \DateTime();
680
                    $expiry->modify("+$expiryDays day");
681
                    $expiry->setTimezone(new \DateTimeZone("UTC"));
682
                    $soapExpiryChange = $soap->setRequestParameters(
683
                            $soapReqnum, [
684
                        "RaID" => SilverbulletCertificate::EDUPKI_RA_ID,
685
                        "Role" => SilverbulletCertificate::EDUPKI_CERT_PROFILE,
686
                        "Subject" => "DC=eduroam,DC=test,DC=test,C=" . $csr["FED"] . ",O=" . CONFIG_CONFASSISTANT['CONSORTIUM']['name'] . ",OU=" . $csr["FED"] . ",CN=" . $csr['USERNAME'] . ",emailAddress=" . $csr['USERNAME'],
687
                        "SubjectAltNames" => ["email:" . $csr["USERNAME"]],
688
                        "NotBefore" => (new \DateTime())->format('c'),
689
                        "NotAfter" => $expiry->format('c'),
690
                            ]
691
                    );
692
                    if ($soapExpiryChange === FALSE) {
693
                        throw new Exception("Error when sending SOAP request (unable to change expiry date).");
694
                    }
695
                    // retrieve the raw request to prepare for signature and approval
696
                    // this seems to come out base64-decoded already; maybe PHP
697
                    // considers this "convenience"? But we need it as sent on
698
                    // the wire, so re-encode it!
699
                    $soapCleartext = $soap->getRawRequest($soapReqnum);
700
701
                    $loggerInstance->debug(5, "Actual received SOAP resonse for getRawRequest was:\n\n");
702
                    $loggerInstance->debug(5, $soap->__getLastResponse());
703
                    // for obnoxious reasons, we have to dump the request into a file and let pkcs7_sign read from the file
704
                    // rather than just using the string. Grr.
705
                    $tempdir = \core\common\Entity::createTemporaryDirectory("test");
706
                    file_put_contents($tempdir['dir'] . "/content.txt", $soapCleartext);
707
                    // retrieve our RA cert from filesystem                    
708
                    // the RA certificates are not needed right now because we
709
                    // have resorted to S/MIME signatures with openssl command-line
710
                    // rather than the built-in functions. But that may change in
711
                    // the future, so let's park these two lines for future use.
712
                    // $raCertFile = file_get_contents(ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.pem");
713
                    // $raCert = openssl_x509_read($raCertFile);
714
                    // $raKey = openssl_pkey_get_private("file://" . ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.clearkey");
715
                   
716
                    // sign the data, using cmdline because openssl_pkcs7_sign produces strange results
717
                    // -binary didn't help, nor switch -md to sha1 sha256 or sha512
718
                    $loggerInstance->debug(5, "Actual content to be signed is this:\n  $soapCleartext\n");
719
                    $execCmd = CONFIG['PATHS']['openssl'] . " smime -sign -binary -in " . $tempdir['dir'] . "/content.txt -out " . $tempdir['dir'] . "/signature.txt -outform pem -inkey " . ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.clearkey -signer " . ROOT . "/config/SilverbulletClientCerts/edupki-test-ra.pem";
720
                    $loggerInstance->debug(2, "Calling openssl smime with following cmdline:   $execCmd\n");
721
                    $output = [];
722
                    $return = 999;
723
                    exec($execCmd, $output, $return);
724
                    if ($return !== 0) {
725
                        throw new Exception("Non-zero return value from openssl smime!");
726
                    }
727
                    // and get the signature blob back from the filesystem
728
                    $detachedSig = trim(file_get_contents($tempdir['dir'] . "/signature.txt"));    
729
                    $loggerInstance->debug(5, "Request for server approveRequest has parameters:\n");  
730
                    $loggerInstance->debug(5,   $soapReqnum . "\n");
731
                    $loggerInstance->debug(5,   $soapCleartext . "\n"); // PHP magically encodes this as base64 while sending!
732
                    $loggerInstance->debug(5,   $detachedSig . "\n");
733
                    $soapIssueCert = $soap->approveRequest($soapReqnum  , $soapCleartext  , $detachedSig);
734
                    $loggerInstance->debug(5, "approveRequest Request was: \n" .   $soap->__getLastRequest());
735
                    $loggerInstance->debug(5, "approveRequest Response was: \n" .   $soap->__getLastResponse());
736
                    if ($soapIssueCert === FALSE) {
737
                        throw new Exception("The locally approved request was NOT processed by the CA.");
738
                    }
739
                    // now, get the actual cert from the CA
740
                    sleep(55);
741
                    $counter = 55;
742
                    $parsedCert = NULL;
743
                    do {
744
                        $counter += 5;
745
                        sleep(5); // always start with a wait. Signature round-trip on the server side is at least one minute.
746
                        $soapCert = $soap->getCertificateByRequestSerial($soapReqnum);
747
                        $x509 = new common\X509();
748
                        if (strlen($soapCert) > 10) {
749
                            $parsedCert = $x509->processCertificate($soapCert);
750
                        }
751
                    } while (!is_array($parsedCert) && $counter < 500);
752
753
                    if (!is_array($parsedCert)) {
1 ignored issue
show
introduced by
The condition is_array($parsedCert) is always false.
Loading history...
754
                        throw new Exception("We did not actually get a certificate after waiting for 5 minutes.");
755
                    }
756
                    // let's get the CA certificate chain
757
758
                    $caInfo = $soap->getCAInfo();
759
                    $certList = $x509->splitCertificate($caInfo->CAChain[0]);
760
                    // find the root
761
                    $theRoot = "";
762
                    foreach ($certList as   $oneCert) {
763
                        $content = $x509->processCertificate($oneCert);
764
                        if ($content['root'] == 1) {
765
                            $theRoot = $content;
766
                        }
767
                    }
768
                    if ($theRoot == "") {
769
                        throw new Exception("CAInfo has no root certificate for us!");
770
                    }
771
                } catch (SoapFault $e) {
772
                    throw new Exception("SoapFault: Error when sending or receiving SOAP message: " . "{$e->faultcode}: {$e->faultname}: {$e->faultstring}: {$e->faultactor}: {$e->detail}: {$e->headerfault}\n");
773
                } catch (Exception $e) {
774
                    throw new Exception("Exception: Something odd happened between the SOAP requests:" . $e->getMessage());
775
                }
776
                return [
777
                    "CERT" => openssl_x509_read($parsedCert['pem']),
778
                    "SERIAL" => $parsedCert['full_details']['serialNumber'],
779
                    "ISSUER" => $theRoot, // change this to the actual eduPKI Issuer CA
780
                    "ROOT" => $theRoot, // change this to the actual eduPKI Root CA
781
                ];
782
            default:
783
                /* HTTP POST the CSR to the CA with the $expiryDays as parameter
784
                 * on successful execution, gets back a PEM file which is the
785
                 * certificate (structure TBD)
786
                 * $httpResponse = httpRequest("https://clientca.hosted.eduroam.org/issue/", ["csr" => $csr, "expiry" => $expiryDays ] );
787
                 *
788
                 * The result of this if clause has to be a certificate in PHP's 
789
                 * "openssl_object" style (like the one that openssl_csr_sign would 
790
                 * produce), to be stored in the variable $cert; we also need the
791
                 * serial - which can be extracted from the received cert and has
792
                 * to be stored in $serial.
793
                 */
794
                throw new Exception("External silverbullet CA is not implemented yet!");
795
        }
796
    }
797
798
}
799