Skip to main content
tiCrypt 2.18.1

MFA Setup

tiCrypt MFA requires users to satisfy one or more external authentication factors (SSO, Duo, etc.) in addition to their private key. This page covers deployment and integration of MFA authorities. For the ticrypt-auth.conf MFA configuration parameters, see the MFA section in the Auth Service reference.

info

MFA is deployed separately from the main backend and requires its own web server and SSO integration.

How MFA Works in tiCrypt​

Login Flow​

  1. During login, the tiCrypt backend tells the frontend how many factors are required and provides the URL of each enabled factor. The frontend uses the factors in the order the backend lists them; the user does not choose.
  2. The frontend appends ?port=<port> to the factor URL and opens it in a new window: in a browser with a new tab, in tiCrypt Connect through Connect. <port> is the port the tiCrypt app is served on locally.
  3. The MFA server authenticates the user (via SSO, push notification, etc.) and redirects the window to http://127.0.0.1:<port>/mfa.html?msg=<certificate>, where <certificate> is the signed certificate as JSON.
  4. The mfa.html page hands the certificate to tiCrypt Connect, or in a browser to the tiCrypt tab, and closes itself. If it cannot close, it shows "If this window does not close automatically, please close it." and "The MFA process has finished."
  5. The frontend sends each certificate to the backend, which validates it and returns an MFA token for that factor. The frontend repeats steps 2 to 5 until it has enough tokens.
  6. The frontend completes the login with the MFA tokens.
The factor URL must not contain a query string

The frontend adds ?port=<port> to the factor url as plain text. A url that already contains ? ends up with two query strings, and the factor page does not receive the port.

What Users See​

While a factor is open, the tiCrypt window shows "Multi-factor authentication opened in another window" with the button Re-open MFA tab, which opens the factor URL again. There is no cancel button.

MessageMeaning
"Bad MFA certificate"The certificate from the factor page is malformed, or is for a different login ID than the one logging in.
"Bad multi-factor certificate"The backend rejected the certificate, for example because of a bad signature or an expired timestamp.
"failed to obtain enough MFA tokens after 3 tries"The frontend opened the factors three times without getting enough valid tokens.

A valid MFA token is cached and reused until 5 minutes before it expires, so users are not asked for every factor at every login. See MFA Caching.

Validation​

The backend validates MFA factors using only the signed message received from the MFA server. There is no direct communication between the MFA servers and the tiCrypt backend. The frontend mediates all communication, and no frame or window.postMessage() is involved.

For each MFA factor, the backend needs:

  • The public key of the MFA server (configured in ticrypt-auth.conf)
  • The URL where users authenticate

Setting Up MFA with Shibboleth/SSO and PHP​

The simplest approach is to host a PHP script on a web page protected by your organization's Shibboleth/SSO. The script:

  1. Reads the user's email and other properties from Shibboleth environment variables
  2. Reads the user's IP address
  3. Generates a timestamp (to prevent replay attacks)
  4. Signs the payload with the MFA private key
  5. Redirects the window to mfa.html with the signed message, which passes it to the tiCrypt frontend

Hosting info.php​

  1. Host info.php on a separate server from the tiCrypt backend.
  2. Ensure the host uses HTTPS.
  3. Place the info.php script at a route on that host. Best practice is to use a dedicated subdomain.
  4. Protect the route (or virtual host) with your organization's Shibboleth/SSO.
info

The web server used for the MFA page does not matter. Apache with mod-php is the easiest combination with Shibboleth.

Setting Up Credentials​

Generate an RSA key pair for MFA signing:

openssl genrsa -out /etc/pki/tls/certs/login_rsa_2048_priv.pem 2048
openssl rsa -in /etc/pki/tls/certs/login_rsa_2048_priv.pem -pubout -out /etc/pki/tls/certs/login_rsa_2048_pub.pem
chown nginx:nginx /etc/pki/tls/certs/login_rsa_2048_p*
tip

Adjust the chown command for your web server user (e.g., apache:apache for Apache httpd).

Place the keys as follows:

  • Private key on the server hosting info.php (protected like any other private key, readable by PHP)
  • Public key path and the info.php URL in ticrypt-auth.conf under the MFA factors section

After updating the config, restart ticrypt-auth:

systemctl restart ticrypt-auth
warning

Once MFA is active, only users who can satisfy both the MFA factor and the primary factor with the exact same email can log in. Any discrepancy (e.g., case differences) prevents session creation. To tolerate case differences, set disable-case-sensitivity = true in the MFA configuration: the backend then lowercases the email from the certificate before looking up the user, which works when the tiCrypt login IDs are lowercase.

Customizing info.php​

The info.php script can be customized to match your SSO environment. The following parts are critical and must be preserved:

  • The $data array must contain at least the required fields (email, sourceIP, timestamp), and timestamp must be the last entry
  • The timestamp must keep the DateTime::ATOM format with a +HH:MM offset (see MFA Payload Schema)
  • The digital signature mechanism must remain intact
  • The $msg value assembly must not be modified
  • The <script> section that redirects to mfa.html must remain unchanged. Its postMessage branch, for a page loaded in a frame, is not used by current front ends
tip

Keep the error messages about missing keys and failed signatures. Debugging the script without them is difficult.

Optional fields for the $data array (values from SSO that auto-populate user registration):

FieldDescription
firstNameUser's first name
lastNameUser's last name
contactEmailContact email (if different from login email)
departmentUser's department
positionUser's position/title
commentThe registration comment

The email value also fills in the login ID. Add optional fields to $data before timestamp, so that the timestamp stays the last entry.

warning

The user cannot edit any field populated by info.php during registration.

tip

Extract firstName and lastName from SSO when possible. Users sometimes write their names differently, which creates confusion.

MFA Payload Schema​

The certificate passed to mfa.html in the msg parameter must include:

FieldTypeRequiredDescription
payloadString✅JSON-encoded signed payload object
authorizerString✅Factor ID matching a factor in ticrypt-auth.conf
algoString✅Signature algorithm. See Signature Algorithms.
signatureString✅Base64-encoded signature of the payload

The payload JSON object must include:

FieldTypeRequiredDescription
emailString✅tiCrypt username (normally the user's email)
sourceIPString✅User's IP address as seen by the MFA server
timestampString✅ISO-8601 datetime string with a +HH:MM offset, such as 2026-10-21T14:05:00+00:00, as the last field of the object
The timestamp format is fixed by the hand-off page

In the query string, the + of the timestamp offset arrives as a space. mfa.html puts a + back at the 8th character from the end of payload, which is where the offset sign sits when the payload ends with "timestamp":"...+HH:MM"}. Any other ending, such as a millisecond UNIX timestamp, a Z suffix, or another field after timestamp, changes the payload and the signature check fails. The backend itself would also accept a millisecond timestamp, but not through mfa.html.

Server-Side Validation​

When the backend receives the MFA response, it:

  1. Looks up the factor configuration using the authorizer field. Rejects if no matching factor exists.
  2. Decodes the signature from Base64. Rejects on decode failure.
  3. Validates the signature on payload using the configured public key and specified algorithm. Rejects on mismatch.
  4. Decodes payload as JSON and validates required fields.
  5. Checks that timestamp is not in the future and not older than the factor's cert-ttl.
  6. Looks up the user by email.
  7. Checks for duplicate submission (replay). Rejects if previously seen.
  8. Generates an MFA token valid for the factor's token-ttl.

Duo Integration​

The simplest way to integrate Duo MFA is to add it as an extra factor in the Shibboleth login flow. This way, tiCrypt is not aware of Duo and requires no additional configuration. Alternatively, you can set up a separately hosted info.php protected by Duo as its own factor.

tip

Adding Duo to Shibboleth creates a 3-factor authentication method (private key + SSO + Duo), making compliance requirements easier to satisfy.


Multiple SSO Servers​

If your organization uses different SSO servers for different user populations, you can set up a single MFA factor that supports multiple SSO providers:

  1. Host a login.html page (not protected by Shibboleth) that presents a choice of credential types.
  2. For each Shibboleth server, host an independent info.php script. All scripts must use the same private key.
  3. login.html redirects to the appropriate info.php based on user selection.
  4. The selected info.php completes authentication and returns the MFA token to the tiCrypt frontend.

Example info.php​

<?php
// Second-factor authentication script for Shibboleth-based servers
$request_method = $_SERVER['REQUEST_METHOD'];
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
if ($request_method === 'OPTIONS') {
header('HTTP/1.1 204 No Content');
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Length: 0');
exit;
} else if ($request_method !== 'GET') {
header('HTTP/1.1 405 Method Not Allowed');
exit;
}

// Location of the private key used to sign the replies
$privKeyFile = "file:///etc/pki/gv-mfa/private/shibboleth_private_key.pem";

$timezone = new DateTimeZone('UTC');
$timestamp = array_key_exists('REQUEST_TIME', $_SERVER) ? $_SERVER['REQUEST_TIME'] : time();
$date = new DateTime('@' . $timestamp, $timezone);
$dateStr = $date->format(DateTime::ATOM);

$data = array(
"email" => array_key_exists("REMOTE_USER", $_SERVER) ? $_SERVER["REMOTE_USER"] : $_SERVER["eppn"],
"sourceIP" => $_SERVER["REMOTE_ADDR"],
"timestamp" => $dateStr
);

$fPort = $_GET["port"];

$pkey = openssl_pkey_get_private($privKeyFile);
if (!$pkey) {
echo "<!-- Private key file $privKeyFile not found -->";
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: red; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Private key for digital signature could not be found
</span>
</body>
</html>
<?php
exit();
}

$dataStr = json_encode($data);

if (!openssl_sign($dataStr, $signature, $pkey, "SHA256")) {
echo "<!-- Digital signature failed -->";
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: red; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Digital signature generation failed
</span>
</body>
</html>
<?php
exit();
}

$sig = base64_encode($signature);
$msg = json_encode(array(
'payload' => $dataStr,
'authorizer' => $_SERVER["AUTH_TYPE"],
'algo' => 'RSA-PKCS1-SHA256',
'signature' => $sig
));
?>
<!DOCTYPE html>
<html>
<body>
<span style="color: blue; font-size: 40px; text-align: center; position: absolute; width: 95%; margin: 0 auto; top: 50%;">
Successful Authentication
</span>
</body>
<script type="text/javascript">
var msg = <?php echo $msg; ?>;
if (window.self != window.top) {
if (parent && parent.postMessage) {
try {
parent.postMessage(msg, '<?php echo $formOrigin; ?>')
} catch(e) {
// ignore
}
}
} else {
const urlParams = new URLSearchParams(window.location.search);
const port = "<?=$fPort?>";
const uri = encodeURI("http://127.0.0.1:" + port + "/mfa.html?msg=" + JSON.stringify(msg));
window.location.assign(uri);
}
</script>
</html>

Example: Shibboleth + NGINX Deployment​

This section walks through a complete Shibboleth SP deployment with NGINX on Rocky Linux 8.

Build Shibboleth SP with FastCGI​

On a machine with Docker:

git clone https://github.com/nginx-shib/shibboleth-fastcgi.git
cd shibboleth-fastcgi
make

Copy the resulting RPMs from build/ to the target host.

Install Shibboleth​

Add the Shibboleth repository:

# /etc/yum.repos.d/shibboleth.repo
[shibboleth]
name=Shibboleth (Rocky_8)
type=rpm-md
mirrorlist=https://shibboleth.net/cgi-bin/mirrorlist.cgi/Rocky_8
gpgcheck=1
gpgkey=https://shibboleth.net/downloads/service-provider/RPMS/repomd.xml.key
https://shibboleth.net/downloads/service-provider/RPMS/cantor.repomd.xml.key
enabled=1
exclude=shibboleth

Install and start:

dnf install shibboleth-3.3.0-1.x86_64.rpm
systemctl enable --now shibd
systemctl status shibd

Set Up FastCGI Authorizer and Responder​

dnf install supervisor

Create /etc/supervisord.d/shib.ini:

[fcgi-program:shibauthorizer]
command=/usr/lib64/shibboleth/shibauthorizer
socket=unix:///run/supervisor/shibauthorizer.sock
socket_owner=shibd:shibd
socket_mode=0666
user=shibd
stdout_logfile=/var/log/supervisor/shibauthorizer.log
stderr_logfile=/var/log/supervisor/shibauthorizer.error.log

[fcgi-program:shibresponder]
command=/usr/lib64/shibboleth/shibresponder
socket=unix:///run/supervisor/shibresponder.sock
socket_owner=shibd:shibd
socket_mode=0666
user=shibd
stdout_logfile=/var/log/supervisor/shibresponder.log
stderr_logfile=/var/log/supervisor/shibresponder.error.log
systemctl enable --now supervisord
systemctl status supervisord

Set Up PHP​

dnf module list php
dnf module reset php
dnf -y module install php:8.0
dnf -y install php php-fpm
systemctl enable --now php-fpm

Edit /etc/php-fpm.d/www.conf to set the user/group:

user = nginx
group = nginx
systemctl reload php-fpm

Place your info.php script in the Shibboleth-protected directory (e.g., /var/www/ticrypt/dart/secure/).

Build NGINX Shibboleth Module​

Build on a separate host, then copy the module to the target:

nginx -V # Note the version (e.g., 1.14.1)

# Find the matching tag at https://hg.nginx.org/pkg-oss/tags
wget https://hg.nginx.org/pkg-oss/raw-file/1.14.1-2/build_module.sh
chmod a+x build_module.sh

mkdir /root/rpmbuild
./build_module.sh -v 1.14.1 https://github.com/nginx-shib/nginx-http-shibboleth.git

When the build pauses, edit the Makefile to match your nginx -V configure arguments:

vi /tmp/build_module.sh.*/pkg-oss/rpm/SPECS/Makefile
# Remove flags not in your nginx -V output, such as:
# --with-compat \
# --with-threads \

Continue the build, then copy the module:

cp /root/rpmbuild/BUILD/nginx-module-shibboleth-*/objs/ngx_http_shibboleth_module.so /usr/lib64/nginx/modules/

Repeat for the headers-more module:

./build_module.sh -v 1.14.1 https://github.com/openresty/headers-more-nginx-module.git
# Same Makefile edit as above
cp /root/rpmbuild/BUILD/nginx-module-headersmore-*/objs/ngx_http_headers_more_filter_module.so /usr/lib64/nginx/modules/

Configure NGINX​

Load the modules:

echo 'load_module /usr/lib64/nginx/modules/ngx_http_shibboleth_module.so;' > /usr/share/nginx/modules/shibboleth.conf
echo 'load_module /usr/lib64/nginx/modules/ngx_http_headers_more_filter_module.so;' >> /usr/share/nginx/modules/shibboleth.conf

Add Shibboleth locations to /etc/nginx/default.d/shibboleth.conf:

# FastCGI authorizer for Auth Request module
location = /shibauthorizer {
internal;
include fastcgi_params;
fastcgi_pass unix:/run/supervisor/shibauthorizer.sock;
}

# FastCGI responder
location /Shibboleth.sso {
include fastcgi_params;
fastcgi_pass unix:/run/supervisor/shibresponder.sock;
}

# Shibboleth error page resources
location /shibboleth-sp {
alias /usr/share/shibboleth/;
}

Add the protected location to your site config (e.g., /etc/nginx/conf.d/ticrypt.conf):

server {
server_name your-mfa-host.example.edu;
# ...
location /dart/secure {
root /var/www/ticrypt;
shib_request /shibauthorizer;
shib_request_use_headers on;
more_clear_input_headers 'remote_user' 'email' 'staticemail'
'firstname' 'lastname' 'department' 'position';
}
}

Configure Shibboleth SP​

Edit /etc/shibboleth/shibboleth2.xml:

<SPConfig ...>
...
<RequestMapper type="XML">
<RequestMap>
<Host name="your-mfa-host.example.edu"
authType="shibboleth"
requireSession="true"
redirectToSSL="443" />
</RequestMap>
</RequestMapper>
<ApplicationDefaults entityID="https://your-mfa-host.example.edu"
REMOTE_USER="emailAddress"
homeURL="https://your-mfa-host.example.edu/dart/secure/"
cipherSuites="DEFAULT:!EXP:!LOW:!aNULL:!eNULL:!DES:!IDEA:!SEED:!RC4:!3DES:!kRSA:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1">
<Sessions ...>
<SSO entityID="urn:mace:incommon:your-organization">
SAML2
</SSO>
...
<Handler type="Session" Location="/Session" showAttributeValues="true"/>
...
</Sessions>
...
<MetadataProvider type="XML" validate="true" path="idp-metadata.xml" />
...
</ApplicationDefaults>
</SPConfig>

Add attribute mappings in /etc/shibboleth/attribute-map.xml:

<Attributes ...>
...
<Attribute name="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" id="emailAddress"/>
<Attribute name="Department" id="Department"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="Position" id="Position"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="Email" id="Email"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="FirstName" id="FirstName"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="LastName" id="LastName"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
<Attribute name="StaticEmail" id="StaticEmail"
nameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"/>
</Attributes>

Create IdP metadata files (e.g., /etc/shibboleth/idp-metadata.xml) with your identity provider's metadata.

Register the SP with Your IdP​

Provide your IdP with the SP metadata:

<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://your-mfa-host.example.edu">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://your-mfa-host.example.edu/Shibboleth.sso/SAML2/POST"
index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>

Verify​

shibd -t
systemctl restart shibd
systemctl restart supervisord
systemctl restart nginx