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.
MFA is deployed separately from the main backend and requires its own web server and SSO integration.
How MFA Works in tiCrypt
Login Flow
- 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.
- 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. - 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. - The
mfa.htmlpage 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." - 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.
- The frontend completes the login with the MFA tokens.
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.
| Message | Meaning |
|---|---|
| "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:
- Reads the user's email and other properties from Shibboleth environment variables
- Reads the user's IP address
- Generates a timestamp (to prevent replay attacks)
- Signs the payload with the MFA private key
- Redirects the window to
mfa.htmlwith the signed message, which passes it to the tiCrypt frontend
Hosting info.php
- Host
info.phpon a separate server from the tiCrypt backend. - Ensure the host uses HTTPS.
- Place the
info.phpscript at a route on that host. Best practice is to use a dedicated subdomain. - Protect the route (or virtual host) with your organization's Shibboleth/SSO.
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*
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.phpURL inticrypt-auth.confunder the MFA factors section
After updating the config, restart ticrypt-auth:
systemctl restart ticrypt-auth
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
$dataarray must contain at least the required fields (email,sourceIP,timestamp), andtimestampmust be the last entry - The timestamp must keep the
DateTime::ATOMformat with a+HH:MMoffset (see MFA Payload Schema) - The digital signature mechanism must remain intact
- The
$msgvalue assembly must not be modified - The
<script>section that redirects tomfa.htmlmust remain unchanged. ItspostMessagebranch, for a page loaded in a frame, is not used by current front ends
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):
| Field | Description |
|---|---|
firstName | User's first name |
lastName | User's last name |
contactEmail | Contact email (if different from login email) |
department | User's department |
position | User's position/title |
comment | The 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.
The user cannot edit any field populated by info.php during registration.
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:
| Field | Type | Required | Description |
|---|---|---|---|
payload | String | ✅ | JSON-encoded signed payload object |
authorizer | String | ✅ | Factor ID matching a factor in ticrypt-auth.conf |
algo | String | ✅ | Signature algorithm. See Signature Algorithms. |
signature | String | ✅ | Base64-encoded signature of the payload |
The payload JSON object must include:
| Field | Type | Required | Description |
|---|---|---|---|
email | String | ✅ | tiCrypt username (normally the user's email) |
sourceIP | String | ✅ | User's IP address as seen by the MFA server |
timestamp | String | ✅ | 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 |
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:
- Looks up the factor configuration using the
authorizerfield. Rejects if no matching factor exists. - Decodes the
signaturefrom Base64. Rejects on decode failure. - Validates the signature on
payloadusing the configured public key and specified algorithm. Rejects on mismatch. - Decodes
payloadas JSON and validates required fields. - Checks that
timestampis not in the future and not older than the factor'scert-ttl. - Looks up the user by
email. - Checks for duplicate submission (replay). Rejects if previously seen.
- 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.
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:
- Host a
login.htmlpage (not protected by Shibboleth) that presents a choice of credential types. - For each Shibboleth server, host an independent
info.phpscript. All scripts must use the same private key. login.htmlredirects to the appropriateinfo.phpbased on user selection.- The selected
info.phpcompletes 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