Passed
Push — master ( 7dcdd9...287ee3 )
by Stefan
08:07 queued 17s
created

SilverbulletCertificate::revokeCertificate()   B

Complexity

Conditions 9
Paths 35

Size

Total Lines 57
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 38
dl 0
loc 57
rs 7.7564
c 0
b 0
f 0
cc 9
nc 35
nop 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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