Passed
Push — master ( 32b95a...2c2bb9 )
by Stefan
05:45 queued 29s
created

ProfileSilverbullet::userStatus()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 1
1
<?php
2
3
/*
4
 * ******************************************************************************
5
 * Copyright 2011-2017 DANTE Ltd. and GÉANT on behalf of the GN3, GN3+, GN4-1 
6
 * and GN4-2 consortia
7
 *
8
 * License: see the web/copyright.php file in the file structure
9
 * ******************************************************************************
10
 */
11
12
/**
13
 * This file contains the Profile class.
14
 *
15
 * @author Stefan Winter <[email protected]>
16
 * @author Tomasz Wolniewicz <[email protected]>
17
 *
18
 * @package Developer
19
 *
20
 */
21
22
namespace core;
23
24
use \Exception;
25
26
/**
27
 * This class represents an EAP Profile.
28
 * Profiles can inherit attributes from their IdP, if the IdP has some. Otherwise,
29
 * one can set attribute in the Profile directly. If there is a conflict between
30
 * IdP-wide and Profile-wide attributes, the more specific ones (i.e. Profile) win.
31
 *
32
 * @author Stefan Winter <[email protected]>
33
 * @author Tomasz Wolniewicz <[email protected]>
34
 *
35
 * @license see LICENSE file in root directory
36
 *
37
 * @package Developer
38
 */
39
class ProfileSilverbullet extends AbstractProfile {
40
41
    const SB_TOKENSTATUS_VALID = 0;
42
    const SB_TOKENSTATUS_PARTIALLY_REDEEMED = 1;
43
    const SB_TOKENSTATUS_REDEEMED = 2;
44
    const SB_TOKENSTATUS_EXPIRED = 3;
45
    const SB_TOKENSTATUS_INVALID = 4;
46
    const SB_CERTSTATUS_VALID = 1;
47
    const SB_CERTSTATUS_EXPIRED = 2;
48
    const SB_CERTSTATUS_REVOKED = 3;
49
    const SB_ACKNOWLEDGEMENT_REQUIRED_DAYS = 365;
50
51
    public $termsAndConditions;
52
53
    /*
54
     * 
55
     */
56
57
    const PRODUCTNAME = "Managed IdP";
58
59
    public static function randomString(
60
    $length, $keyspace = '23456789abcdefghijkmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
61
    ) {
62
        $str = '';
63
        $max = strlen($keyspace) - 1;
64
        if ($max < 1) {
65
            throw new Exception('$keyspace must be at least two characters long');
66
        }
67
        for ($i = 0; $i < $length; ++$i) {
68
            $str .= $keyspace[random_int(0, $max)];
69
        }
70
        return $str;
71
    }
72
73
    /**
74
     * Class constructor for existing profiles (use IdP::newProfile() to actually create one). Retrieves all attributes and 
75
     * supported EAP types from the DB and stores them in the priv_ arrays.
76
     * 
77
     * @param int $profileId identifier of the profile in the DB
78
     * @param IdP $idpObject optionally, the institution to which this Profile belongs. Saves the construction of the IdP instance. If omitted, an extra query and instantiation is executed to find out.
79
     */
80
    public function __construct($profileId, $idpObject = NULL) {
81
        parent::__construct($profileId, $idpObject);
82
83
        $this->entityOptionTable = "profile_option";
84
        $this->entityIdColumn = "profile_id";
85
        $this->attributes = [];
86
87
        $tempMaxUsers = 200; // abolutely last resort fallback if no per-fed and no config option
88
// set to global config value
89
90
        if (isset(CONFIG_CONFASSISTANT['SILVERBULLET']['default_maxusers'])) {
91
            $tempMaxUsers = CONFIG_CONFASSISTANT['SILVERBULLET']['default_maxusers'];
92
        }
93
        $myInst = new IdP($this->institution);
94
        $myFed = new Federation($myInst->federation);
95
        $fedMaxusers = $myFed->getAttributes("fed:silverbullet-maxusers");
96
        if (isset($fedMaxusers[0])) {
97
            $tempMaxUsers = $fedMaxusers[0]['value'];
98
        }
99
100
// realm is automatically calculated, then stored in DB
101
102
        $this->realm = "opaquehash@$myInst->identifier-$this->identifier." . strtolower($myInst->federation) . CONFIG_CONFASSISTANT['SILVERBULLET']['realm_suffix'];
103
        $this->setRealm($myInst->identifier."-".$this->identifier."." . strtolower($myInst->federation) . strtolower(CONFIG_CONFASSISTANT['SILVERBULLET']['realm_suffix']));
104
        $localValueIfAny = "";
105
106
// but there's some common internal attributes populated directly
107
        $internalAttributes = [
108
            "internal:profile_count" => $this->idpNumberOfProfiles,
109
            "internal:realm" => preg_replace('/^.*@/', '', $this->realm),
110
            "internal:use_anon_outer" => FALSE,
111
            "internal:checkuser_outer" => TRUE,
112
            "internal:checkuser_value" => "anonymous",
113
            "internal:anon_local_value" => $localValueIfAny,
114
            "internal:silverbullet_maxusers" => $tempMaxUsers,
115
            "profile:production" => "on",
116
        ];
117
118
// and we need to populate eap:server_name and eap:ca_file with the NRO-specific EAP information
119
        $silverbulletAttributes = [
120
            "eap:server_name" => "auth." . strtolower($myFed->identifier) . CONFIG_CONFASSISTANT['SILVERBULLET']['server_suffix'],
121
        ];
122
        $x509 = new \core\common\X509();
123
        $caHandle = fopen(dirname(__FILE__) . "/../config/SilverbulletServerCerts/" . strtoupper($myFed->identifier) . "/root.pem", "r");
124
        if ($caHandle !== FALSE) {
125
            $cAFile = fread($caHandle, 16000000);
126
            $silverbulletAttributes["eap:ca_file"] = $x509->der2pem(($x509->pem2der($cAFile)));
0 ignored issues
show
Bug introduced by
It seems like $cAFile can also be of type false; however, parameter $pemData of core\common\X509::pem2der() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

126
            $silverbulletAttributes["eap:ca_file"] = $x509->der2pem(($x509->pem2der(/** @scrutinizer ignore-type */ $cAFile)));
Loading history...
127
        }
128
129
        $temp = array_merge($this->addInternalAttributes($internalAttributes), $this->addInternalAttributes($silverbulletAttributes));
130
        $tempArrayProfLevel = array_merge($this->addDatabaseAttributes(), $temp);
131
132
// now, fetch and merge IdP-wide attributes
133
134
        $this->attributes = $this->levelPrecedenceAttributeJoin($tempArrayProfLevel, $this->idpAttributes, "IdP");
135
136
        $this->privEaptypes = $this->fetchEAPMethods();
137
138
        $this->name = ProfileSilverbullet::PRODUCTNAME;
139
140
        $this->loggerInstance->debug(3, "--- END Constructing new Profile object ... ---\n");
141
142
        $this->termsAndConditions = "<h2>Product Definition</h2>
143
        <p>" . \core\ProfileSilverbullet::PRODUCTNAME . " outsources the technical setup of " . CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'] . " " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . " functions to the " . CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'] . " Operations Team. The system includes</p>
144
            <ul>
145
                <li>a web-based user management interface where user accounts and access credentials can be created and revoked (there is a limit to the number of active users)</li>
146
                <li>a technical infrastructure ('CA') which issues and revokes credentials</li>
147
                <li>a technical infrastructure ('RADIUS') which verifies access credentials and subsequently grants access to " . CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'] . "</li>
148
                <li><span style='color: red;'>TBD: a lookup/notification system which informs you of network abuse complaints by " . CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'] . " Service Providers that pertain to your users</span></li>
149
            </ul>
150
        <h2>User Account Liability</h2>
151
        <p>As an " . CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'] . " " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . " administrator using this system, you are authorized to create user accounts according to your local " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . " policy. You are fully responsible for the accounts you issue and are the data controller for all user information you deposit in this system; the system is a data processor.</p>";
152
        $this->termsAndConditions .= "<p>Your responsibilities include that you</p>
153
        <ul>
154
            <li>only issue accounts to members of your " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . ", as defined by your local policy.</li>
155
            <li>must make sure that all accounts that you issue can be linked by you to actual human end users</li>
156
            <li>have to immediately revoke accounts of users when they leave or otherwise stop being a member of your " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . "</li>
157
            <li>will act upon notifications about possible network abuse by your users and will appropriately sanction them</li>
158
        </ul>
159
        <p>";
160
        $this->termsAndConditions .= "Failure to comply with these requirements may make your " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_federation'] . " act on your behalf, which you authorise, and will ultimately lead to the deletion of your " . CONFIG_CONFASSISTANT['CONSORTIUM']['nomenclature_institution'] . " (and all the users you create inside) in this system.";
161
        $this->termsAndConditions .= "</p>
162
        <h2>Privacy</h2>
163
        <p>With " . \core\ProfileSilverbullet::PRODUCTNAME . ", we are necessarily storing personally identifiable information about the end users you create. While the actual human is only identifiable with your help, we consider all the user data as relevant in terms of privacy jurisdiction. Please note that</p>
164
        <ul>
165
            <li>You are the only one who needs to be able to make a link to the human behind the usernames you create. The usernames you create in the system have to be rich enough to allow you to make that identification step. Also consider situations when you are unavailable or leave the organisation and someone else needs to perform the matching to an individual.</li>
166
            <li>The identifiers we create in the credentials are not linked to the usernames you add to the system; they are randomly generated pseudonyms.</li>
167
            <li>Each access credential carries a different pseudonym, even if it pertains to the same username.</li>
168
            <li>If you choose to deposit users' email addresses in the system, you authorise the system to send emails on your behalf regarding operationally relevant events to the users in question (e.g. notification of nearing expiry dates of credentials, notification of access revocation).
169
        </ul>";
170
    }
171
172
    public function invitationMailSubject() {
173
            return sprintf(_("Your %s access is ready"), CONFIG_CONFASSISTANT['CONSORTIUM']['display_name']);
174
    }
175
    
176
    public function invitationMailBody($invitationLink) {
177
        $text = _("Hello!");
178
        $text .= "\n\n";
179
        $text .= sprintf(_("A new %s access credential has been created for you by your network administrator."),CONFIG_CONFASSISTANT['CONSORTIUM']['display_name']);
180
        $text .= " ";
181
        $text .= sprintf(_("Please follow the following link with the device you want to enable for %s to get a custom %s installation program just for you. You can click on the link, copy and paste it into a browser or scan the attached QR code."), CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'], CONFIG_CONFASSISTANT['CONSORTIUM']['display_name']);
182
        $text .= "\n\n$invitationLink\n\n"; // gets replaced with the token value by getBody()
183
        $text .= sprintf(_("Please keep this email or bookmark this link for future use. After picking up your %s installation program, you can use the same link to get status information about your %s account."),CONFIG_CONFASSISTANT['CONSORTIUM']['display_name'], CONFIG_CONFASSISTANT['CONSORTIUM']['display_name']);
184
        $text .= "\n\n";
185
        $text .= _("Regards,");
186
        $text .= "\n\n";
187
        $text .= sprintf("%s", CONFIG['APPEARANCE']['productname_long']);
188
        
189
        return $text;
190
    }
191
    
192
    /**
193
     * Updates database with new installer location; NOOP because we do not
194
     * cache anything in Silverbullet
195
     * 
196
     * @param string $device the device identifier string
197
     * @param string $path the path where the new installer can be found
198
     * @param string $mime the mime type of the new installer
199
     * @param int $integerEapType the inter-representation of the EAP type that is configured in this installer
200
     */
201
    public function updateCache($device, $path, $mime, $integerEapType) {
202
        // caching is not supported in SB (private key in installers)
203
        // the following merely makes the "unused parameter" warnings go away
204
        // the FALSE in condition one makes sure it never gets executed
205
        if (FALSE || $device == "Macbeth" || $path == "heath" || $mime == "application/witchcraft" || $integerEapType == 0) {
206
            throw new Exception("FALSE is TRUE, and TRUE is FALSE! Hover through the browser and filthy code!");
207
        }
208
    }
209
210
    /**
211
     * register new supported EAP method for this profile
212
     *
213
     * @param \core\common\EAP $type The EAP Type, as defined in class EAP
214
     * @param int $preference preference of this EAP Type. If a preference value is re-used, the order of EAP types of the same preference level is undefined.
215
     *
216
     */
217
    public function addSupportedEapMethod(\core\common\EAP $type, $preference) {
218
        // the parameters really should only list SB and with prio 1 - otherwise,
219
        // something fishy is going on
220
        if ($type->getIntegerRep() != \core\common\EAP::INTEGER_SILVERBULLET || $preference != 1) {
221
            throw new Exception("Silverbullet::addSupportedEapMethod was called for a non-SP EAP type or unexpected priority!");
222
        }
223
        parent::addSupportedEapMethod($type, 1);
224
    }
225
226
    /**
227
     * It's EAP-TLS and there is no point in anonymity
228
     * @param boolean $shallwe
229
     */
230
    public function setAnonymousIDSupport($shallwe) {
231
        // we don't do anonymous outer IDs in SB
232
        if ($shallwe === TRUE) {
233
            throw new Exception("Silverbullet: attempt to add anonymous outer ID support to a SB profile!");
234
        }
235
        $this->databaseHandle->exec("UPDATE profile SET use_anon_outer = 0 WHERE profile_id = $this->identifier");
236
    }
237
238
    /**
239
     * create a CSR
240
     * 
241
     * @return 
242
     */
243
    private function generateCsr($privateKey) {
244
        // token leads us to the NRO, to set the OU property of the cert
245
        $inst = new IdP($this->institution);
246
        $federation = strtoupper($inst->federation);
247
        $usernameIsUnique = FALSE;
248
        $username = "";
249
        while ($usernameIsUnique === FALSE) {
250
            $usernameLocalPart = self::randomString(64 - 1 - strlen($this->realm), "0123456789abcdefghijklmnopqrstuvwxyz");
251
            $username = $usernameLocalPart . "@" . $this->realm;
252
            $uniquenessQuery = $this->databaseHandle->exec("SELECT cn from silverbullet_certificate WHERE cn = ?", "s", $username);
253
            // SELECT -> resource, not boolean
254
            if (mysqli_num_rows(/** @scrutinizer ignore-type */ $uniquenessQuery) == 0) {
255
                $usernameIsUnique = TRUE;
256
            }
257
        }
258
259
        $this->loggerInstance->debug(5, "generateCertificate: generating private key.\n");
260
261
        return [
262
            "CSR" => openssl_csr_new(
263
                    ['O' => CONFIG_CONFASSISTANT['CONSORTIUM']['name'],
264
                'OU' => $federation,
265
                'CN' => $username,
266
                'emailAddress' => $username,
267
                    ], $privateKey, [
268
                'digest_alg' => 'sha256',
269
                'req_extensions' => 'v3_req',
270
                    ]
271
            ),
272
            "USERNAME" => $username
273
        ];
274
    }
275
276
    /**
277
     * take a CSR and sign it with our issuing CA's certificate
278
     * 
279
     * @param mixed $csr the CSR
280
     */
281
    private function signCsr($csr, $expiryDays) {
282
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
283
            case "embedded":
284
                $rootCaPem = file_get_contents(ROOT . "/config/SilverbulletClientCerts/rootca.pem");
285
                $issuingCaPem = file_get_contents(ROOT . "/config/SilverbulletClientCerts/real.pem");
286
                $issuingCa = openssl_x509_read($issuingCaPem);
287
                $issuingCaKey = openssl_pkey_get_private("file://" . ROOT . "/config/SilverbulletClientCerts/real.key");
288
                $nonDupSerialFound = FALSE;
289
                do {
290
                    $serial = random_int(1000000000, PHP_INT_MAX);
291
                    $dupeQuery = $this->databaseHandle->exec("SELECT serial_number FROM silverbullet_certificate WHERE serial_number = ?", "i", $serial);
292
                    // SELECT -> resource, not boolean
293
                    if (mysqli_num_rows(/** @scrutinizer ignore-type */$dupeQuery) == 0) {
294
                        $nonDupSerialFound = TRUE;
295
                    }
296
                } while (!$nonDupSerialFound);
297
                $this->loggerInstance->debug(5, "generateCertificate: signing imminent with unique serial $serial.\n");
298
                return [
299
                    "CERT" => openssl_csr_sign($csr, $issuingCa, $issuingCaKey, $expiryDays, ['digest_alg' => 'sha256'], $serial),
300
                    "SERIAL" => $serial,
301
                    "ISSUER" => $issuingCaPem,
302
                    "ROOT" => $rootCaPem,
303
                ];
304
            default:
305
                /* HTTP POST the CSR to the CA with the $expiryDays as parameter
306
                 * on successful execution, gets back a PEM file which is the
307
                 * certificate (structure TBD)
308
                 * $httpResponse = httpRequest("https://clientca.hosted.eduroam.org/issue/", ["csr" => $csr, "expiry" => $expiryDays ] );
309
                 *
310
                 * The result of this if clause has to be a certificate in PHP's 
311
                 * "openssl_object" style (like the one that openssl_csr_sign would 
312
                 * produce), to be stored in the variable $cert; we also need the
313
                 * serial - which can be extracted from the received cert and has
314
                 * to be stored in $serial.
315
                 */
316
                throw new Exception("External silverbullet CA is not implemented yet!");
317
        }
318
    }
319
320
    /**
321
     * issue a certificate based on a token
322
     *
323
     * @param string $token
324
     * @param string $importPassword
325
     * @return array
326
     */
327
    public function issueCertificate($token, $importPassword) {
328
        $this->loggerInstance->debug(5, "generateCertificate() - starting.\n");
329
        $tokenStatus = ProfileSilverbullet::tokenStatus($token);
330
        $this->loggerInstance->debug(5, "tokenStatus: done, got " . $tokenStatus['status'] . ", " . $tokenStatus['profile'] . ", " . $tokenStatus['user'] . ", " . $tokenStatus['expiry'] . ", " . $tokenStatus['value'] . "\n");
331
        if ($tokenStatus['status'] != self::SB_TOKENSTATUS_VALID && $tokenStatus['status'] != self::SB_TOKENSTATUS_PARTIALLY_REDEEMED) {
332
            throw new Exception("Attempt to generate a SilverBullet installer with an invalid/redeemed/expired token. The user should never have gotten that far!");
333
        }
334
        if ($tokenStatus['profile'] != $this->identifier) {
335
            throw new Exception("Attempt to generate a SilverBullet installer, but the profile ID (constructor) and the profile from token do not match!");
336
        }
337
        // SQL query to find the expiry date of the *user* to find the correct ValidUntil for the cert
338
        $userStatus = $tokenStatus['user'];
339
        $userrow = $this->databaseHandle->exec("SELECT expiry FROM silverbullet_user WHERE id = ?", "i", $userStatus);
340
        // SELECT -> resource, not boolean
341
        if ($userrow->num_rows != 1) {
342
            throw new Exception("Despite a valid token, the corresponding user was not found in database or database query error!");
343
        }
344
        $expiryObject = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrow);
345
        $this->loggerInstance->debug(5, "EXP: " . $expiryObject->expiry . "\n");
346
        $expiryDateObject = date_create_from_format("Y-m-d H:i:s", $expiryObject->expiry);
347
        if ($expiryDateObject === FALSE) {
348
            throw new Exception("The expiry date we got from the DB is bogus!");
349
        }
350
        $this->loggerInstance->debug(5, $expiryDateObject->format("Y-m-d H:i:s") . "\n");
351
        // date_create with no parameters can't fail, i.e. is never FALSE
352
        $validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $expiryDateObject);
353
        $expiryDays = $validity->days + 1;
354
        if ($validity->invert == 1) { // negative! That should not be possible
355
            throw new Exception("Attempt to generate a certificate for a user which is already expired!");
356
        }
357
358
        $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, 'encrypt_key' => FALSE]);
359
        $csr = $this->generateCsr($privateKey);
360
361
        $this->loggerInstance->debug(5, "generateCertificate: proceeding to sign cert.\n");
362
363
        $certMeta = $this->signCsr($csr["CSR"], $expiryDays);
364
        $cert = $certMeta["CERT"];
365
        $issuingCaPem = $certMeta["ISSUER"];
366
        $rootCaPem = $certMeta["ROOT"];
367
        $serial = $certMeta["SERIAL"];
368
369
        $this->loggerInstance->debug(5, "generateCertificate: post-processing certificate.\n");
370
371
        // get the SHA1 fingerprint, this will be handy for Windows installers
372
        $sha1 = openssl_x509_fingerprint($cert, "sha1");
0 ignored issues
show
Bug introduced by
$cert of type resource is incompatible with the type string expected by parameter $x509 of openssl_x509_fingerprint(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

372
        $sha1 = openssl_x509_fingerprint(/** @scrutinizer ignore-type */ $cert, "sha1");
Loading history...
373
        // with the cert, our private key and import password, make a PKCS#12 container out of it
374
        $exportedCertProt = "";
375
        openssl_pkcs12_export($cert, $exportedCertProt, $privateKey, $importPassword, ['extracerts' => [$issuingCaPem /* , $rootCaPem */]]);
376
        $exportedCertClear = "";
377
        openssl_pkcs12_export($cert, $exportedCertClear, $privateKey, "", ['extracerts' => [$issuingCaPem, $rootCaPem]]);
378
        // 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!
379
        // we need the *real* expiry date, not just the day-approximation
380
        $x509 = new \core\common\X509();
381
        $certString = "";
382
        openssl_x509_export($cert, $certString);
383
        $parsedCert = $x509->processCertificate($certString);
384
        $this->loggerInstance->debug(5, "CERTINFO: " . print_r($parsedCert['full_details'], true));
385
        $realExpiryDate = date_create_from_format("U", $parsedCert['full_details']['validTo_time_t'])->format("Y-m-d H:i:s");
386
387
        // store new cert info in DB
388
        $newCertificateResult = $this->databaseHandle->exec("INSERT INTO `silverbullet_certificate` (`profile_id`, `silverbullet_user_id`, `silverbullet_invitation_id`, `serial_number`, `cn` ,`expiry`) VALUES (?, ?, ?, ?, ?, ?)", "iiisss", $tokenStatus['profile'], $tokenStatus['user'], $tokenStatus['db_id'], $serial, $csr["USERNAME"], $realExpiryDate);
389
        if ($newCertificateResult === false) {
390
            throw new Exception("Unable to update database with new cert details!");
391
        }
392
        $certificateId = $this->databaseHandle->lastID();
393
394
        // newborn cert immediately gets its "valid" OCSP response
395
        ProfileSilverbullet::triggerNewOCSPStatement((int) $serial);
396
// return PKCS#12 data stream
397
        return [
398
            "username" => $csr["USERNAME"],
399
            "certdata" => $exportedCertProt,
400
            "certdataclear" => $exportedCertClear,
401
            "expiry" => $expiryDateObject->format("Y-m-d\TH:i:s\Z"),
402
            "sha1" => $sha1,
403
            'importPassword' => $importPassword,
404
            'serial' => $serial,
405
            'certificateId' => $certificateId,
406
        ];
407
    }
408
409
    /**
410
     * triggers a new OCSP statement for the given serial number
411
     * 
412
     * @param int $serial the serial number of the cert in question (decimal)
413
     * @return string DER-encoded OCSP status info (binary data!)
414
     */
415
    public static function triggerNewOCSPStatement($serial) {
416
        $logHandle = new \core\common\Logging();
417
        $logHandle->debug(2, "Triggering new OCSP statement for serial $serial.\n");
418
        $ocsp = ""; // the statement
0 ignored issues
show
Unused Code introduced by
The assignment to $ocsp is dead and can be removed.
Loading history...
419
        switch (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type']) {
420
            case "embedded":
421
                // get all relevant info from DB
422
                $cn = "";
423
                $federation = NULL;
424
                $certstatus = "";
425
                $originalExpiry = date_create_from_format("Y-m-d H:i:s", "2000-01-01 00:00:00");
426
                $dbHandle = DBConnection::handle("INST");
427
                $originalStatusQuery = $dbHandle->exec("SELECT profile_id, cn, revocation_status, expiry, revocation_time, OCSP FROM silverbullet_certificate WHERE serial_number = ?", "i", $serial);
428
                // SELECT -> resource, not boolean
429
                if (mysqli_num_rows(/** @scrutinizer ignore-type */ $originalStatusQuery) > 0) {
430
                    $certstatus = "V";
431
                }
432
                while ($runner = mysqli_fetch_object(/** @scrutinizer ignore-type */ $originalStatusQuery)) { // there can be only one row
433
                    if ($runner->revocation_status == "REVOKED") {
434
                        // already revoked, simply return canned OCSP response
435
                        $certstatus = "R";
436
                    }
437
                    $originalExpiry = date_create_from_format("Y-m-d H:i:s", $runner->expiry);
438
                    if ($originalExpiry === FALSE) {
439
                        throw new Exception("Unable to calculate original expiry date, input data bogus!");
440
                    }
441
                    $validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $originalExpiry);
442
                    if ($validity->invert == 1) {
443
                        // negative! Cert is already expired, no need to revoke. 
444
                        // No need to return anything really, but do return the last known OCSP statement to prevent special case
445
                        $certstatus = "E";
446
                    }
447
                    $cn = $runner->cn;
448
                    $profile = new ProfileSilverbullet($runner->profile_id);
449
                    $inst = new IdP($profile->institution);
450
                    $federation = strtoupper($inst->federation);
451
                }
452
453
                // generate stub index.txt file
454
                $cat = new CAT();
455
                $tempdirArray = $cat->createTemporaryDirectory("test");
456
                $tempdir = $tempdirArray['dir'];
457
                $nowIndexTxt = (new \DateTime())->format("ymdHis") . "Z";
458
                $expiryIndexTxt = $originalExpiry->format("ymdHis") . "Z";
459
                $serialHex = strtoupper(dechex($serial));
460
                if (strlen($serialHex) % 2 == 1) {
461
                    $serialHex = "0" . $serialHex;
462
                }
463
                
464
                $indexStatement = "$certstatus\t$expiryIndexTxt\t" . ($certstatus == "R" ? "$nowIndexTxt,unspecified" : "") . "\t$serialHex\tunknown\t/O=" . CONFIG_CONFASSISTANT['CONSORTIUM']['name'] . "/OU=$federation/CN=$cn/emailAddress=$cn\n";
465
                $logHandle->debug(4, "index.txt contents-to-be: $indexStatement");
466
                if (!file_put_contents($tempdir . "/index.txt", $indexStatement)) {
467
                $logHandle->debug(1,"Unable to write openssl index.txt file for revocation handling!");
468
                }
469
                // index.txt.attr is dull but needs to exist
470
                file_put_contents($tempdir . "/index.txt.attr", "unique_subject = yes\n");
471
                // call "openssl ocsp" to manufacture our own OCSP statement
472
                // adding "-rmd sha1" to the following command-line makes the
473
                // choice of signature algorithm for the response explicit
474
                // but it's only available from openssl-1.1.0 (which we do not
475
                // want to require just for that one thing).
476
                $execCmd = CONFIG['PATHS']['openssl'] . " ocsp -issuer " . ROOT . "/config/SilverbulletClientCerts/real.pem -sha1 -ndays 10 -no_nonce -serial 0x$serialHex -CA " . ROOT . "/config/SilverbulletClientCerts/real.pem -rsigner " . ROOT . "/config/SilverbulletClientCerts/real.pem -rkey " . ROOT . "/config/SilverbulletClientCerts/real.key -index $tempdir/index.txt -no_cert_verify -respout $tempdir/$serialHex.response.der";
477
                $logHandle->debug(2, "Calling openssl ocsp with following cmdline: $execCmd\n");
478
                $output = [];
479
                $return = 999;
480
                exec($execCmd, $output, $return);
481
                if ($return !== 0) {
482
                    throw new Exception("Non-zero return value from openssl ocsp!");
483
                }
484
                $ocspFile = fopen($tempdir . "/$serialHex.response.der", "r");
485
                $ocsp = fread($ocspFile, 1000000);
486
                fclose($ocspFile);
487
                break;
488
            default:
489
                /* HTTP POST the serial to the CA. The CA knows about the state of
490
                 * the certificate.
491
                 *
492
                 * $httpResponse = httpRequest("https://clientca.hosted.eduroam.org/ocsp/", ["serial" => $serial ] );
493
                 *
494
                 * The result of this if clause has to be a DER-encoded OCSP statement
495
                 * to be stored in the variable $ocsp
496
                 */
497
                throw new Exception("External silverbullet CA is not implemented yet!");
498
        }
499
        // write the new statement into DB
500
        $dbHandle->exec("UPDATE silverbullet_certificate SET OCSP = ?, OCSP_timestamp = NOW() WHERE serial_number = ?", "si", $ocsp, $serial);
501
        return $ocsp;
502
    }
503
504
    /**
505
     * revokes a certificate
506
     * @param int $serial the serial number of the cert to revoke (decimal!)
507
     * @return array with revocation information
508
     */
509
    public function revokeCertificate($serial) {
510
511
512
// TODO for now, just mark as revoked in the certificates table (and use the stub OCSP updater)
513
        $nowSql = (new \DateTime())->format("Y-m-d H:i:s");
514
        if (CONFIG_CONFASSISTANT['SILVERBULLET']['CA']['type'] != "embedded") {
515
            // send revocation request to CA.
516
            // $httpResponse = httpRequest("https://clientca.hosted.eduroam.org/revoke/", ["serial" => $serial ] );
0 ignored issues
show
Unused Code Comprehensibility introduced by
53% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
517
            throw new Exception("External silverbullet CA is not implemented yet!");
518
        }
519
        // regardless if embedded or not, always keep local state in our own DB
520
        $this->databaseHandle->exec("UPDATE silverbullet_certificate SET revocation_status = 'REVOKED', revocation_time = ? WHERE serial_number = ?", "si", $nowSql, $serial);
521
        $this->loggerInstance->debug(2, "Certificate revocation status updated, about to call triggerNewOCSPStatement($serial).\n");
522
        $ocsp = ProfileSilverbullet::triggerNewOCSPStatement($serial);
523
        return ["OCSP" => $ocsp];
524
    }
525
526
    /**
527
     * 
528
     * @param string $url the URL to send the request to
529
     * @param array $postValues POST values to send
530
     */
531
    private function httpRequest($url, $postValues) {
0 ignored issues
show
Unused Code introduced by
The method httpRequest() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
532
        $options = [
533
            'http' => ['header' => 'Content-type: application/x-www-form-urlencoded\r\n', "method" => 'POST', 'content' => http_build_query($postValues)]
534
        ];
535
        $context = stream_context_create($options);
536
        return file_get_contents($url, false, $context);
537
    }
538
539
    private static function enumerateCertDetails($certQuery) {
540
        $retval = [];
541
        while ($resource = mysqli_fetch_object($certQuery)) {
542
            // is the cert expired?
543
            $now = new \DateTime();
544
            $cert_expiry = new \DateTime($resource->expiry);
545
            $delta = $now->diff($cert_expiry);
546
            $certStatus = ($delta->invert == 1 ? self::SB_CERTSTATUS_EXPIRED : self::SB_CERTSTATUS_VALID);
547
            // expired is expired; even if it was previously revoked. But do update status for revoked ones...
548
            if ($certStatus == self::SB_CERTSTATUS_VALID && $resource->revocation_status == "REVOKED") {
549
                $certStatus = self::SB_CERTSTATUS_REVOKED;
550
            }
551
            $retval[] = [
552
                "status" => $certStatus,
553
                "serial" => $resource->serial_number,
554
                "name" => $resource->cn,
555
                "issued" => $resource->issued,
556
                "expiry" => $resource->expiry,
557
                "device" => $resource->device,
558
            ];
559
        }
560
        return $retval;
561
    }
562
563
    public static function tokenStatus($tokenvalue) {
564
        $databaseHandle = DBConnection::handle("INST");
565
        $loggerInstance = new \core\common\Logging();
566
567
        /*
568
         * Finds invitation by its token attribute and loads all certificates generated using the token.
569
         * Certificate details will always be empty, since code still needs to be adapted to return multiple certificates information.
570
         */
571
        $invColumnNames = "`id`, `profile_id`, `silverbullet_user_id`, `token`, `quantity`, `expiry`";
572
        $invitationsResult = $databaseHandle->exec("SELECT $invColumnNames FROM `silverbullet_invitation` WHERE `token`=? ORDER BY `expiry` DESC", "s", $tokenvalue);
573
        // SELECT -> resource, no boolean
574
        if ($invitationsResult->num_rows == 0) {
575
            $loggerInstance->debug(2, "Token  $tokenvalue not found in database or database query error!\n");
576
            return ["status" => self::SB_TOKENSTATUS_INVALID,
577
                "cert_status" => [],];
578
        }
579
        // if not returned, we found the token in the DB
580
        $invitationRow = mysqli_fetch_object(/** @scrutinizer ignore-type */ $invitationsResult);
581
        $rowId = $invitationRow->id;
582
        $certColumnNames = "`id`, `profile_id`, `silverbullet_user_id`, `silverbullet_invitation_id`, `serial_number`, `cn`, `issued`, `expiry`, `device`, `revocation_status`, `revocation_time`, `OCSP`, `OCSP_timestamp`";
583
        $certificatesResult = $databaseHandle->exec("SELECT $certColumnNames FROM `silverbullet_certificate` WHERE `silverbullet_invitation_id` = ? ORDER BY `revocation_status`, `expiry` DESC", "i", $rowId);
584
        $certificatesNumber = ($certificatesResult ? $certificatesResult->num_rows : 0);
585
        $loggerInstance->debug(5, "At token validation level, " . $certificatesNumber . " certificates exist.\n");
586
587
        $retArray = [
588
            "cert_status" => \core\ProfileSilverbullet::enumerateCertDetails($certificatesResult),
589
            "profile" => $invitationRow->profile_id,
590
            "user" => $invitationRow->silverbullet_user_id,
591
            "expiry" => $invitationRow->expiry,
592
            "activations_remaining" => $invitationRow->quantity - $certificatesNumber,
593
            "activations_total" => $invitationRow->quantity,
594
            "value" => $invitationRow->token,
595
            "db_id" => $invitationRow->id,
596
        ];
597
598
        switch ($certificatesNumber) {
599
            case 0:
600
                // find out if it has expired
601
                $now = new \DateTime();
602
                $expiryObject = new \DateTime($invitationRow->expiry);
603
                $delta = $now->diff($expiryObject);
604
                if ($delta->invert == 1) {
605
                    $retArray['status'] = self::SB_TOKENSTATUS_EXPIRED;
606
                    $retArray['activations_remaining'] = 0;
607
                    break;
608
                }
609
                $retArray['status'] = self::SB_TOKENSTATUS_VALID;
610
                break;
611
            case $invitationRow->quantity:
612
                $retArray['status'] = self::SB_TOKENSTATUS_REDEEMED;
613
                break;
614
            default:
615
                assert($certificatesNumber > 0); // no negatives allowed
616
                assert($certificatesNumber < $invitationRow->quantity || $invitationRow->quantity == 0); // not more than max quantity allowed (unless quantity is zero)
617
                $retArray['status'] = self::SB_TOKENSTATUS_PARTIALLY_REDEEMED;
618
        }
619
620
        // now, look up certificate details and put them all in the cert_status property
621
622
        $loggerInstance->debug(5, "tokenStatus: done, returning " . $retArray['status'] . ", " . count($retArray['cert_status']) . ", " . $retArray['profile'] . ", " . $retArray['user'] . ", " . $retArray['expiry'] . ", " . $retArray['value'] . "\n");
623
        return $retArray;
624
    }
625
626
    /**
627
     * For a given certificate username, find the profile and username in CAT
628
     * this needs to be static because we do not have a known profile instance
629
     * 
630
     * @param string $certUsername a username from CN or sAN:email
631
     */
632
    public static function findUserIdFromCert($certUsername) {
633
        $dbHandle = \core\DBConnection::handle("INST");
634
        $userrows = $dbHandle->exec("SELECT silverbullet_user_id AS user_id, profile_id AS profile FROM silverbullet_certificate WHERE cn = ?", "s", $certUsername);
635
        // SELECT -> resource, not boolean
636
        while ($returnedData = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrows)) { // only one
637
            return ["profile" => $returnedData->profile, "user" => $returnedData->user_id];
638
        }
639
    }
640
641
    public function userStatus($userId) {
642
        $retval = [];
643
        $userrows = $this->databaseHandle->exec("SELECT `token` FROM `silverbullet_invitation` WHERE `silverbullet_user_id` = ? AND `profile_id` = ? ", "ii", $userId, $this->identifier);
644
        // SELECT -> resource, not boolean
645
        while ($returnedData = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrows)) {
646
            $retval[] = ProfileSilverbullet::tokenStatus($returnedData->token);
647
        }
648
        return $retval;
649
    }
650
651
    public function getUserExpiryDate($userId) {
652
        $query = $this->databaseHandle->exec("SELECT expiry FROM silverbullet_user WHERE id = ? AND profile_id = ? ", "ii", $userId, $this->identifier);
653
        // SELECT -> resource, not boolean
654
        while ($returnedData = mysqli_fetch_object(/** @scrutinizer ignore-type */ $query)) {
655
            return $returnedData->expiry;
656
        }
657
    }
658
    
659
    public function setUserExpiryDate($userId, $date) {
660
        $query = "UPDATE silverbullet_user SET expiry = ? WHERE profile_id = ? AND id = ?";
661
        $theDate = $date->format("Y-m-d");
662
        $this->databaseHandle->exec($query, "sii", $theDate, $this->identifier, $userId);
663
    }
664
665
    public function listAllUsers() {
666
        $userArray = [];
667
        $users = $this->databaseHandle->exec("SELECT `id`, `username` FROM `silverbullet_user` WHERE `profile_id` = ? ", "i", $this->identifier);
668
        // SELECT -> resource, not boolean
669
        while ($res = mysqli_fetch_object(/** @scrutinizer ignore-type */ $users)) {
670
            $userArray[$res->id] = $res->username;
671
        }
672
        return $userArray;
673
    }
674
675
    public function listActiveUsers() {
676
        // users are active if they have a non-expired invitation OR a non-expired, non-revoked certificate
677
        $userCount = [];
678
        $users = $this->databaseHandle->exec("SELECT DISTINCT u.id AS usercount FROM silverbullet_user u, silverbullet_invitation i, silverbullet_certificate c "
679
                . "WHERE u.profile_id = ? "
680
                . "AND ( "
681
                . "( u.id = i.silverbullet_user_id AND i.expiry >= NOW() )"
682
                . "     OR"
683
                . "  ( u.id = c.silverbullet_user_id AND c.expiry >= NOW() AND c.revocation_status != 'REVOKED' ) "
684
                . ")", "i", $this->identifier);
685
        // SELECT -> resource, not boolean
686
        while ($res = mysqli_fetch_object(/** @scrutinizer ignore-type */ $users)) {
687
            $userCount[] = $res->usercount;
688
        }
689
        return $userCount;
690
    }
691
692
    public function addUser($user, \DateTime $expiry) {
693
        $query = "INSERT INTO silverbullet_user (profile_id, username, expiry) VALUES(?,?,?)";
694
        $date = $expiry->format("Y-m-d");
695
        $this->databaseHandle->exec($query, "iss", $this->identifier, $user, $date);
696
        return $this->databaseHandle->lastID();
697
    }
698
699
    public function deactivateUser($userId) {
700
        // set the expiry date of any still valid invitations to NOW()
701
        $query = "SELECT id FROM silverbullet_invitation WHERE profile_id = $this->identifier AND silverbullet_user_id = ? AND expiry >= NOW()";
702
        $exec = $this->databaseHandle->exec($query, "s", $userId);
703
        // SELECT -> resource, not boolean
704
        while ($result = mysqli_fetch_object(/** @scrutinizer ignore-type */ $exec)) {
705
            $this->revokeInvitation($result->id);
706
        }
707
        // and revoke all certificates
708
        $query2 = "SELECT serial_number FROM silverbullet_certificate WHERE profile_id = $this->identifier AND silverbullet_user_id = ? AND expiry >= NOW() AND revocation_status = 'NOT_REVOKED'";
709
        $exec2 = $this->databaseHandle->exec($query2, "i", $userId);
710
        // SELECT -> resource, not boolean
711
        while ($result = mysqli_fetch_object(/** @scrutinizer ignore-type */ $exec2)) {
712
            $this->revokeCertificate($result->serial_number);
713
        }
714
        // and finally set the user expiry date to NOW(), too
715
        $query3 = "UPDATE silverbullet_user SET expiry = NOW() WHERE profile_id = $this->identifier AND id = ?";
716
        $exec3 = $this->databaseHandle->exec($query3, "i", $userId);
0 ignored issues
show
Unused Code introduced by
The assignment to $exec3 is dead and can be removed.
Loading history...
717
    }
718
    
719
    /**
720
     * 
721
     * @param string $token
722
     * @return string
723
     */
724
    public static function generateTokenLink(string $token) {
725
726
        if (isset($_SERVER['HTTPS'])) {
727
            $link = 'https://';
728
        } else {
729
            $link = 'http://';
730
        }
731
        $link .= $_SERVER['SERVER_NAME'];
732
        $relPath = dirname(dirname($_SERVER['SCRIPT_NAME']));
733 View Code Duplication
        if (substr($relPath, -1) == '/') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
734
            $relPath = substr($relPath, 0, -1);
735
            if ($relPath === FALSE) {
736
                throw new Exception("Uh. Something went seriously wrong with URL path mangling.");
737
            }
738
        }
739
        $link = $link . $relPath;
0 ignored issues
show
Bug introduced by
Are you sure $relPath of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

739
        $link = $link . /** @scrutinizer ignore-type */ $relPath;
Loading history...
740
741 View Code Duplication
        if (preg_match('/admin$/', $link)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
742
            $link = substr($link, 0, -6);
743
            if ($link === FALSE) {
744
                throw new Exception("Impossible: the string ends with '/admin' but it's not possible to cut six characters from the end?!");
745
            }
746
        }
747
        $link .= '/accountstatus/accountstatus.php?token='.$token;
748
        return $link;
749
    }
750
751
    /**
752
     *
753
     * @return string
754
     */
755
    private function generateToken() {
756
        return hash("sha512", base_convert(rand(0, (int) 10e16), 10, 36));
757
    }
758
759
    public function createInvitation($userId, $activationCount) {
760
        $query = "INSERT INTO silverbullet_invitation (profile_id, silverbullet_user_id, token, quantity, expiry) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))";
761
        $newToken = $this->generateToken();
762
        $this->databaseHandle->exec($query, "iisi", $this->identifier, $userId, $newToken, $activationCount);
763
    }
764
    
765
    public function revokeInvitation($invitationId) {
766
        $query = "UPDATE silverbullet_invitation SET expiry = NOW() WHERE id = ? AND profile_id = ?";
767
        $this->databaseHandle->exec($query, "ii", $invitationId, $this->identifier);
768
    }
769
770
    public function refreshEligibility() {
771
        $query = "UPDATE silverbullet_user SET last_ack = NOW() WHERE profile_id = ?";
772
        $this->databaseHandle->exec($query, "i", $this->identifier);
773
    }
774
}
775