core/SilverbulletCertificate.php
<?php
/*
* Contributions to this work were made on behalf of the GÉANT project, a
* project that has received funding from the European Union’s Horizon 2020
* research and innovation programme under Grant Agreement No. 731122 (GN4-2).
*
* On behalf of the GÉANT project, GEANT Association is the sole owner of the
* copyright in all material which was developed by a member of the GÉANT
* project. GÉANT Vereniging (Association) is registered with the Chamber of
* Commerce in Amsterdam with registration number 40535155 and operates in the
* UK as a branch of GÉANT Vereniging.
*
* Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands.
* UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
*
* License: see the web/copyright.inc.php file in the file structure or
* <base_url>/copyright.php after deploying the software
*/
/**
* This file contains the SilverbulletInvitation class.
*
* @author Stefan Winter <stefan.winter@restena.lu>
* @author Tomasz Wolniewicz <twoln@umk.pl>
*
* @package Developer
*
*/
namespace core;
use \Exception;
use \SoapFault;
class SilverbulletCertificate extends EntityWithDBProperties
{
/**
* The username in the certificate (CN)
*
* @var string
*/
public $username;
/**
* expiry date and time of the certificate in mysql date notation
*
* @var string
*/
public $expiry;
/**
* serial number of the certificate. For CAs with serials beyond 64 bit
* length, this is a string, otherwise an integer. Storage in SQL is always
* a BLOB so can handle both.
*
* @var integer|string
*/
public $serial;
/**
* row_id index of this certificate in the database table
*
* @var integer
*/
public $dbId;
/**
* the row_id index of the invitation which was consumed to generate this
* certificate
*
* @var integer
*/
public $invitationId;
/**
* the user ID that belongs to this certificate
*
* @var integer
*/
public $userId;
/**
* the ID of the profile to which this certificate belongs
*
* @var integer
*/
public $profileId;
/**
* date and time of issuance of the certificate (start of validity) in MySQL
* timestamp notation
*
* @var string
*/
public $issued;
/**
* the device for which this certificate was generated
*
* @var string
*/
public $device;
/**
* whether or not this certificate is revoked. Can take values REVOKED or
* NOT_REVOKED
*
* @var string
*/
public $revocationStatus;
/**
* date and time of revocation of this certificate, if any. In MySQL
* timestamp notation
*
* @var string
*/
public $revocationTime;
/**
* the most current OCSP statement for this certificate (binary data)
*
* @var string
*/
public $ocsp;
/**
* date and time of issuance of the current OCSP statement for this
* certificate (mySQL timestamp notation)
*
* @var string
*/
public $ocspTimestamp;
/**
* overall status of the certificate. See constants below for possible
* values.
*
* @var integer
*/
public $status;
/**
* which CA issued the certificate. Typical values are "RSA" or "ECDSA".
*
* @var string
*/
public $ca_type;
/**
* any additional info about the certificate. Expected to be a JSON string.
*
* @var string
*/
public $annotation;
/**
* Certificate is valid at the current point in time.
*/
const CERTSTATUS_VALID = 1;
/**
* Certificate has expired. This status is set regardless whether it has
* also been revoked before; once the expiry date is over, it is just
* expired.
*
*/
const CERTSTATUS_EXPIRED = 2;
/**
* Certificate is within its validity time, but has been revoked.
*/
const CERTSTATUS_REVOKED = 3;
/**
* This is not a certificate we know about.
*/
const CERTSTATUS_INVALID = 4;
/**
* instantiates an existing certificate, identified either by its serial
* number or the username.
*
* Use static issueCertificate() to generate a whole new cert.
*
* @param int|string $identifier identify certificate either by CN or by serial
* @param string $certtype RSA or ECDSA?
*/
public function __construct($identifier, $certtype = NULL)
{
$this->databaseType = "INST";
parent::__construct();
$this->username = "";
$this->expiry = "2000-01-01 00:00:00";
$this->serial = -1;
$this->dbId = -1;
$this->invitationId = -1;
$this->userId = -1;
$this->profileId = -1;
$this->issued = "2000-01-01 00:00:00";
$this->device = NULL;
$this->revocationStatus = "REVOKED";
$this->revocationTime = "2000-01-01 00:00:00";
$this->ocsp = NULL;
$this->ocspTimestamp = "2000-01-01 00:00:00";
$this->ca_type = $certtype;
$this->status = SilverbulletCertificate::CERTSTATUS_INVALID;
$this->annotation = NULL;
$incoming = FALSE;
if (is_numeric($identifier)) {
$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`, `ca_type`, `extrainfo` FROM `silverbullet_certificate` WHERE serial_number = ? AND ca_type = ?", "is", $identifier, $certtype);
} else { // it's a string instead
$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`, `ca_type`, `extrainfo` FROM `silverbullet_certificate` WHERE cn = ?", "s", $identifier);
}
// SELECT -> mysqli_resource, not boolean
while ($oneResult = mysqli_fetch_object(/** @scrutinizer ignore-type */ $incoming)) { // there is only at most one
$this->username = $oneResult->cn;
$this->expiry = $oneResult->expiry;
$this->serial = $oneResult->serial_number;
$this->dbId = $oneResult->id;
$this->invitationId = $oneResult->silverbullet_invitation_id;
$this->userId = $oneResult->silverbullet_user_id;
$this->profileId = $oneResult->profile_id;
$this->issued = $oneResult->issued;
$this->device = $oneResult->device;
$this->revocationStatus = $oneResult->revocation_status;
$this->revocationTime = $oneResult->revocation_time;
$this->ocsp = $oneResult->OCSP;
$this->ocspTimestamp = $oneResult->OCSP_timestamp;
$this->ca_type = $oneResult->ca_type;
$this->annotation = $oneResult->extrainfo;
// is the cert expired?
$now = new \DateTime();
$cert_expiry = new \DateTime($this->expiry);
$delta = $now->diff($cert_expiry);
$this->status = ($delta->invert == 1 ? SilverbulletCertificate::CERTSTATUS_EXPIRED : SilverbulletCertificate::CERTSTATUS_VALID);
// expired is expired; even if it was previously revoked. But do update status for revoked ones...
if ($this->status == SilverbulletCertificate::CERTSTATUS_VALID && $this->revocationStatus == "REVOKED") {
$this->status = SilverbulletCertificate::CERTSTATUS_REVOKED;
}
}
}
/**
* retrieve basic information about the certificate
*
* @return array of basic certificate details
*/
public function getBasicInfo()
{
$returnArray = []; // unnecessary because the iterator below is never empty, but Scrutinizer gets excited nonetheless
foreach (['status', 'serial', 'username', 'issued', 'expiry', 'ca_type', 'annotation'] as $key) {
$returnArray[$key] = $this->$key;
}
$returnArray['device'] = \devices\Devices::listDevices()[$this->device]['display'] ?? $this->device;
return $returnArray;
}
/**
* adds extra information about the certificate to the DB
*
* @param array $annotation information to be stored
* @return void
*/
public function annotate($annotation)
{
$encoded = json_encode($annotation);
$this->annotation = $encoded;
$this->databaseHandle->exec("UPDATE silverbullet_certificate SET extrainfo = ? WHERE serial_number = ?", "si", $encoded, $this->serial);
}
/**
* we don't use caching in SB, so this function does nothing
*
* @return void
*/
public function updateFreshness()
{
// nothing to be done here.
}
/**
* find out what the CA engine to use is
*
* @param string $type which engine to use
* @return CertificationAuthorityInterface engine to use
* @throws Exception
*/
public static function getCaEngine($type)
{
switch ($type) {
case \devices\Devices::SUPPORT_EMBEDDED_RSA:
$caEngine = new CertificationAuthorityEmbeddedRSA();
break;
case \devices\Devices::SUPPORT_EDUPKI:
$caEngine = new CertificationAuthorityEduPki();
break;
case \devices\Devices::SUPPORT_EMBEDDED_ECDSA:
$caEngine = new CertificationAuthorityEmbeddedECDSA();
break;
default:
throw new Exception("Unknown certificate backend!");
}
return $caEngine;
}
/**
* issue a certificate based on a token
*
* @param string $token the token string
* @param string $importPassword the PIN
* @param string $certtype is this for the RSA or ECDSA CA?
* @return array
* @throws Exception
*/
public static function issueCertificate($token, $importPassword, $certtype)
{
$loggerInstance = new common\Logging();
$databaseHandle = DBConnection::handle("INST");
$loggerInstance->debug(5, "generateCertificate() - starting.\n");
$invitationObject = new SilverbulletInvitation($token);
$profile = new ProfileSilverbullet($invitationObject->profile);
$inst = new IdP($profile->institution);
$loggerInstance->debug(5, "tokenStatus: done, got " . $invitationObject->invitationTokenStatus . ", " . $invitationObject->profile . ", " . $invitationObject->userId . ", " . $invitationObject->expiry . ", " . $invitationObject->invitationTokenString . "\n");
if ($invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_VALID && $invitationObject->invitationTokenStatus != SilverbulletInvitation::SB_TOKENSTATUS_PARTIALLY_REDEEMED) {
throw new Exception("Attempt to generate a SilverBullet installer with an invalid/redeemed/expired token. The user should never have got that far!");
}
// SQL query to find the expiry date of the *user* to find the correct ValidUntil for the cert
$user = $invitationObject->userId;
$userrow = $databaseHandle->exec("SELECT expiry FROM silverbullet_user WHERE id = ?", "i", $user);
// SELECT -> resource, not boolean
if ($userrow->num_rows != 1) {
throw new Exception("Despite a valid token, the corresponding user was not found in database or database query error!");
}
$expiryObject = mysqli_fetch_object(/** @scrutinizer ignore-type */ $userrow);
$loggerInstance->debug(5, "EXP: " . $expiryObject->expiry . "\n");
$expiryDateObject = date_create_from_format("Y-m-d H:i:s", $expiryObject->expiry);
if ($expiryDateObject === FALSE) {
throw new Exception("The expiry date we got from the DB is bogus!");
}
$loggerInstance->debug(5, $expiryDateObject->format("Y-m-d H:i:s") . "\n");
// date_create with no parameters can't fail, i.e. is never FALSE
$validity = date_diff(/** @scrutinizer ignore-type */ date_create(), $expiryDateObject);
$expiryDays = $validity->days + 1;
if ($validity->invert == 1) { // negative! That should not be possible
throw new Exception("Attempt to generate a certificate for a user which is already expired!");
}
$caEngine = SilverbulletCertificate::getCaEngine($certtype);
$username = SilverbulletCertificate::findUniqueUsername($profile->getAttributes("internal:realm")[0]['value'], $certtype);
$privateKey = $caEngine->generateCompatiblePrivateKey();
$csr = $caEngine->generateCompatibleCsr($privateKey, strtoupper($inst->federation), $username);
$loggerInstance->debug(5, "generateCertificate: proceeding to sign cert.\n");
$certMeta = $caEngine->signRequest($csr, $expiryDays);
$cert = $certMeta["CERT"];
$issuingCaPem = $certMeta["ISSUER"];
$rootCaPem = $certMeta["ROOT"];
$serial = $certMeta["SERIAL"];
if ($cert === FALSE) {
throw new Exception("The CA did not generate a certificate.");
}
$loggerInstance->debug(5, "generateCertificate: post-processing certificate.\n");
// with the cert, our private key and import password, make a PKCS#12 container out of it
$exportedCertProt = "";
openssl_pkcs12_export($cert, $exportedCertProt, $privateKey, $importPassword, ['extracerts' => [$issuingCaPem /* , $rootCaPem */]]);
// and without intermediate, to keep EAP conversation short where possible
$exportedNoInterm = "";
openssl_pkcs12_export($cert, $exportedNoInterm, $privateKey, $importPassword, []);
$exportedCertClear = "";
openssl_pkcs12_export($cert, $exportedCertClear, $privateKey, "", ['extracerts' => [$issuingCaPem, $rootCaPem]]);
$pkey_3des = "";
openssl_pkey_export($privateKey, $pkey_3des, $importPassword, [ "encrypt_key_cipher" => OPENSSL_CIPHER_3DES ]);
// 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!
// we need the *real* expiry date, not just the day-approximation
$x509 = new \core\common\X509();
$certString = "";
openssl_x509_export($cert, $certString);
$parsedCert = $x509->processCertificate($certString);
$loggerInstance->debug(5, "CERTINFO: " . /** @scrutinizer ignore-type */ print_r($parsedCert['full_details'], true));
$realExpiryDate = date_create_from_format("U", $parsedCert['full_details']['validTo_time_t'])->format("Y-m-d H:i:s");
// store new cert info in DB
$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);
// newborn cert immediately gets its "valid" OCSP response
$certObject = new SilverbulletCertificate($serial, $certtype);
// the engine knows the format of its own serial numbers, no reason to get excited
$caEngine->triggerNewOCSPStatement(/** @scrutinizer ignore-type */ $certObject->serial);
// let the RADIUS users know the actual username for CUI generation
$radiusDbs = DBConnection::handle("RADIUS"); // is an array of server conns
foreach ($radiusDbs as $oneRadiusDb) {
$oneRadiusDb->exec("INSERT IGNORE INTO radcheck (username, attribute, op, value) VALUES (?, 'CUI-Source-Username', ':=', ?)", "ss", ($profile->getUserById($invitationObject->userId))[$invitationObject->userId] , $csr["USERNAME"]);
}
// return PKCS#12 data stream
return [
"certObject" => $certObject,
"certdata" => $exportedCertProt,
"certdata_nointermediate" => $exportedNoInterm,
"certdataclear" => $exportedCertClear,
"cert_PEM" => $parsedCert['pem'],
"cert_DER" => $parsedCert['der'],
"pkey_3des" => $pkey_3des,
// Scrutinizer thinks this needs to be a string, but a resource is just fine
"sha1" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha1"),
"sha256" => openssl_x509_fingerprint(/** @scrutinizer ignore-type */$cert, "sha256"),
'importPassword' => $importPassword,
'GUID' => common\Entity::uuid("", $exportedCertProt),
'CN' => $csr["USERNAME"],
];
}
/**
* revokes a certificate
*
* @return void
* @throws Exception
*/
public function revokeCertificate()
{
$nowSql = (new \DateTime())->format("Y-m-d H:i:s");
// regardless if embedded or not, always keep local state in our own DB
$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);
$this->loggerInstance->debug(2, "Certificate revocation status for $this->serial updated, about to call triggerNewOCSPStatement().\n");
// newly instantiate us, DB content has changed...
$certObject = new SilverbulletCertificate((string) $this->serial, $this->ca_type);
// embedded CA does "nothing special" for revocation: the DB change was the entire thing to do
// but for external CAs, we need to notify explicitly that the cert is now revoked
$caEngine = SilverbulletCertificate::getCaEngine($certObject->ca_type);
// the engine knows the format of its own serial numbers, no reason to get excited
$caEngine->revokeCertificate(/** @scrutinizer ignore-type */ $certObject->serial);
}
/**
* we need a unique CN for every certificate. This function generates a
* random CN and verifies that it does not yet exist in the DB
*
* @param string $realm the realm for the username
* @param string $certtype typically RSA or ECDSA
* @return string the username, realm included
*/
private static function findUniqueUsername($realm, $certtype)
{
$databaseHandle = DBConnection::handle("INST");
$usernameIsUnique = FALSE;
$username = "";
while ($usernameIsUnique === FALSE) {
$usernameLocalPart = common\Entity::randomString(64 - 1 - strlen($realm), "0123456789abcdefghijklmnopqrstuvwxyz");
$username = $usernameLocalPart . "@" . $realm;
$uniquenessQuery = $databaseHandle->exec("SELECT cn from silverbullet_certificate WHERE cn = ? AND ca_type = ?", "ss", $username, $certtype);
// SELECT -> resource, not boolean
if (mysqli_num_rows(/** @scrutinizer ignore-type */ $uniquenessQuery) == 0) {
$usernameIsUnique = TRUE;
}
}
return $username;
}
}