Passed
Push — master ( fcf5a1...20098a )
by Stefan
07:00
created

SilverbulletCertificate::updateFreshness()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 1
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 1
rs 10
c 0
b 0
f 0
cc 1
eloc 0
nc 1
nop 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
    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
    /**
151
     * we don't use caching in SB, so this function does nothing
152
     * 
153
     * @return void
154
     */
155
    public function updateFreshness() {
156
        // nothing to be done here.
157
    }
158
159
    /**
160
     * find out what the CA engine to use is
161
     * 
162
     * @param string $type which engine to use
163
     * @return CertificationAuthorityInterface engine to use
164
     */
165
    public static function getCaEngine($type) {
166
     switch ($type) {
167
            case \devices\Devices::SUPPORT_EMBEDDED_RSA:
168
                $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, 'encrypt_key' => FALSE]);
0 ignored issues
show
Unused Code introduced by
The assignment to $privateKey is dead and can be removed.
Loading history...
169
                $caEngine = new CertificationAuthorityEmbeddedRSA();
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment if this fall-through is intended.
Loading history...
Unused Code introduced by
The assignment to $caEngine is dead and can be removed.
Loading history...
170
            case \devices\Devices::SUPPORT_EDUPKI:
171
                $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, 'encrypt_key' => FALSE]);
172
                $caEngine = new CertificationAuthorityEduPki();
173
                break;
174
            case \devices\Devices::SUPPORT_EMBEDDED_ECDSA:
175
                $privateKey = openssl_pkey_new(['curve_name' => 'secp384r1', 'private_key_type' => OPENSSL_KEYTYPE_EC, 'encrypt_key' => FALSE]);
176
                $caEngine = new CertificationAuthorityEmbeddedECDSA();
0 ignored issues
show
Bug introduced by
The type core\CertificationAuthorityEmbeddedECDSA was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
177
                break;
178
            default:
179
                throw new Exception("Unknown certificate backend!");
180
        }
181
        return $caEngine;
182
    }
183
    /**
184
     * issue a certificate based on a token
185
     *
186
     * @param string $token          the token string
187
     * @param string $importPassword the PIN
188
     * @param string $certtype       is this for the RSA or ECDSA CA?
189
     * @return array
190
     */
191
    public static function issueCertificate($token, $importPassword, $certtype) {
192
        $loggerInstance = new common\Logging();
193
        $databaseHandle = DBConnection::handle("INST");
194
        $loggerInstance->debug(5, "generateCertificate() - starting.\n");
195
        $invitationObject = new SilverbulletInvitation($token);
196
        $profile = new ProfileSilverbullet($invitationObject->profile);
197
        $inst = new IdP($profile->institution);
198
        $loggerInstance->debug(5, "tokenStatus: done, got " . $invitationObject->invitationTokenStatus . ", " . $invitationObject->profile . ", " . $invitationObject->userId . ", " . $invitationObject->expiry . ", " . $invitationObject->invitationTokenString . "\n");
199
        if ($invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_VALID && $invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_PARTIALLY_REDEEMED) {
200
            throw new Exception("Attempt to generate a SilverBullet installer with an invalid/redeemed/expired token. The user should never have gotten that far!");
201
        }
202
203
        // SQL query to find the expiry date of the *user* to find the correct ValidUntil for the cert
204
        $user = $invitationObject->userId;
205
        $userrow = $databaseHandle->exec("SELECT expiry FROM silverbullet_user WHERE id = ?", "i", $user);
206
        // SELECT -> resource, not boolean
207
        if ($userrow->num_rows != 1) {
208
            throw new Exception("Despite a valid token, the corresponding user was not found in database or database query error!");
209
        }
210
        $expiryObject = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrow);
211
        $loggerInstance->debug(5, "EXP: " . $expiryObject->expiry . "\n");
212
        $expiryDateObject = date_create_from_format("Y-m-d H:i:s", $expiryObject->expiry);
213
        if ($expiryDateObject === FALSE) {
214
            throw new Exception("The expiry date we got from the DB is bogus!");
215
        }
216
        $loggerInstance->debug(5, $expiryDateObject->format("Y-m-d H:i:s") . "\n");
217
        // date_create with no parameters can't fail, i.e. is never FALSE
218
        $validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $expiryDateObject);
219
        $expiryDays = $validity->days + 1;
220
        if ($validity->invert == 1) { // negative! That should not be possible
221
            throw new Exception("Attempt to generate a certificate for a user which is already expired!");
222
        }
223
        $caEngine = SilverbulletCertificate::getCaEngine($certtype);
224
        $username = SilverbulletCertificate::findUniqueUsername($profile->getAttributes("internal:realm")[0]['value'], $certtype);
225
        $csr = $caEngine->generateCompatibleCsr($privateKey, strtoupper($inst->federation), $username);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $privateKey seems to be never defined.
Loading history...
226
227
        $loggerInstance->debug(5, "generateCertificate: proceeding to sign cert.\n");
228
229
        $certMeta = $caEngine->signRequest($csr["CSR"], $expiryDays);
230
        $cert = $certMeta["CERT"];
231
        $issuingCaPem = $certMeta["ISSUER"];
232
        $rootCaPem = $certMeta["ROOT"];
233
        $serial = $certMeta["SERIAL"];
234
235
        if ($cert === FALSE) {
236
            throw new Exception("The CA did not generate a certificate.");
237
        }
238
        $loggerInstance->debug(5, "generateCertificate: post-processing certificate.\n");
239
240
        // with the cert, our private key and import password, make a PKCS#12 container out of it
241
        $exportedCertProt = "";
242
        openssl_pkcs12_export($cert, $exportedCertProt, $privateKey, $importPassword, ['extracerts' => [$issuingCaPem /* , $rootCaPem */]]);
243
        // and without intermediate, to keep EAP conversation short where possible
244
        $exportedNoInterm = "";
245
        openssl_pkcs12_export($cert, $exportedNoInterm, $privateKey, $importPassword, []);
246
        $exportedCertClear = "";
247
        openssl_pkcs12_export($cert, $exportedCertClear, $privateKey, "", ['extracerts' => [$issuingCaPem, $rootCaPem]]);
248
        // 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!
249
        // we need the *real* expiry date, not just the day-approximation
250
        $x509 = new \core\common\X509();
251
        $certString = "";
252
        openssl_x509_export($cert, $certString);
253
        $parsedCert = $x509->processCertificate($certString);
254
        $loggerInstance->debug(5, "CERTINFO: " . print_r($parsedCert['full_details'], true));
255
        $realExpiryDate = date_create_from_format("U", $parsedCert['full_details']['validTo_time_t'])->format("Y-m-d H:i:s");
256
257
        // store new cert info in DB
258
        $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);
259
        // newborn cert immediately gets its "valid" OCSP response
260
        $certObject = new SilverbulletCertificate($serial, $certtype);
261
        $caEngine->triggerNewOCSPStatement($certObject);
262
// return PKCS#12 data stream
263
        return [
264
            "certObject" => $certObject,
265
            "certdata" => $exportedCertProt,
266
            "certdata_nointermediate" => $exportedNoInterm,
267
            "certdataclear" => $exportedCertClear,
268
            // Scrutinizer thinks this needs to be a string, but a resource is just fine
269
            "sha1" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha1"),
270
            "sha256" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha256"),
271
            'importPassword' => $importPassword,
272
            'GUID' => common\Entity::uuid("", $exportedCertProt),
273
        ];
274
    }
275
276
    /**
277
     * revokes a certificate
278
     * 
279
     * @return void
280
     * @throws Exception
281
     */
282
    public function revokeCertificate() {
283
        $nowSql = (new \DateTime())->format("Y-m-d H:i:s");
284
        // regardless if embedded or not, always keep local state in our own DB
285
        $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);
286
        $this->loggerInstance->debug(2, "Certificate revocation status for $this->serial updated, about to call triggerNewOCSPStatement().\n");
287
        // newly instantiate us, DB content has changed...
288
        $certObject = new SilverbulletCertificate((string) $this->serial, $this->ca_type);
289
        // embedded CA does "nothing special" for revocation: the DB change was the entire thing to do
290
        // but for external CAs, we need to notify explicitly that the cert is now revoked
291
        $caEngine = SilverbulletCertificate::getCaEngine($certObject->ca_type);
292
        $caEngine->revokeCertificate($certObject);
293
    }
294
295
    private static function findUniqueUsername($realm, $certtype) {
296
        $databaseHandle = DBConnection::handle("INST");
297
        $usernameIsUnique = FALSE;
298
        $username = "";
299
        while ($usernameIsUnique === FALSE) {
300
            $usernameLocalPart = common\Entity::randomString(64 - 1 - strlen($realm), "0123456789abcdefghijklmnopqrstuvwxyz");
301
            $username = $usernameLocalPart . "@" . $realm;
302
            $uniquenessQuery = $databaseHandle->exec("SELECT cn from silverbullet_certificate WHERE cn = ? AND ca_type = ?", "ss", $username, $certtype);
303
            // SELECT -> resource, not boolean
304
            if (mysqli_num_rows(/** @scrutinizer ignore-type */ $uniquenessQuery) == 0) {
305
                $usernameIsUnique = TRUE;
306
            }
307
        }
308
        return $username;
309
    }
310
    
311
}
312