LIMEリファレンス
下記内容についての質問・要望点などあれば下記アドレス宛送信願います。
以下のLIMEリファレンスガイドから、各鍵の仕様・鍵生成フロー(X3DH、ダブルラチェット)などを確認します。
Linphone Instant Message Encryption v2.0 (Lime v2.0)
Nodejsテストサーバは、HTTPSリクエスト処理の参考として使用します。
X3DHメッセージ
LinphoneからHTTPSリクエストされるX3DHメッセージの構成パケット
Protocol Version <1byte> || Message Type <1byte> || Curve Id <1byte> || Message
Protocol Version:
0x01
Message Type:
0x01 : deprecated register User : a device registers its Id and Identity key on X3DH server, this message holds the Ik only and shall be supported for retro-compatibility with old clients only.
0x02: delete User : a device deletes its Id and Identity key from X3DH server.
0x03: post Signed Pre-key : a device publishes a Signed Pre-key on X3DH server.
0x04: post One-time Pre-keys : a device publishes a batch of One-time Pre-keys on X3DH server.
0x05: get peers key bundles : a device requests key bundles for a list of peer devices.
0x06: peers key bundles : X3DH server responds to device with the list of requested key bundles.
0x07: get self One-time Pre-keys : ask server for self One-time Pre-keys Ids available.
0x08: self One-time Pre-keys : server response with a count and list of all One-time Pre-keys Ids available.
0x09: register User : a device registers its Id and Identity key, Signed Pre-key and a batch of One-time Pre-keys on X3DH server.
0xFF: error : something went wrong on server side during processing of client message, server respond with details on failure.
Curve Id:
0x01 (curve 25519)
0x02 (curve 448)
Message:
0x01 : deprecated register User
- EdDSA Identity Key<32, 57bytes>
0x02: delete User
- none
0x03: post Signed Pre-key
- ECDH Signed Pre-key<32, 56bytes>
- ECDH Signed Pre-key Signature<64, 114bytes>
- Signed Pre-key Id
0x04: post One-time Pre-keys
- keys Count MSB
- keys Count LSB
- One-time Pre-key bundle<36, 60bytes>{keys Count}
with One-time Pre-key bundle:
- ECDH One-Time Pre-key<32, 56bytes>
- One-Time Pre-key Id
0x05: get peers key bundles
- request Count MSB
- request Count LSB
- request{request Count}
with request:
- Device Id size
- Device Id<variable size>
- Device Id<variable size>
0x06: peers key bundles
- bundles Count MSB
- bundles Count LSB
- key Bundle{bundles Count}
with key Bundle(if a the device has published keys on the server):
- Device Id size
- Device Id<variable size>
- Device Id<variable size>
- bundle flag [0x00,0x01]
- EdDSA Identity Key<32, 57bytes>
- ECDH Signed Pre-key<32, 56bytes>
- Signed Pre-key Id
- ECDH Signed Pre-key Signature<64, 114bytes>
- ECDH One-Time Pre-key<32, 56bytes>{0,1} only if bundle flag = 0x01
- One-Time Pre-key Id{0,1} only if bundle flag = 0x01
or key Bundle(if a the device has not published keys on the server):
- Device Id size
- Device Id<variable size>
- Device Id<variable size>
- bundle flag [0x02]
0x07: get self One-time Pre-keys
- none
0x08: self One-time Pre-keys
- OPk Count MSB
- OPk Count LSB
- OPk Id<4bytes>{OPk Count}
0x09: register User
- EdDSA Identity Key<32, 57bytes>
- ECDH Signed Pre-key<32, 56bytes>
- ECDH Signed Pre-key Signature<64, 114bytes>
- Signed Pre-key Id
- keys Count
- One-time Pre-key bundle<36, 60bytes>{keys Count}
with One-time Pre-key bundle:
- ECDH One-Time Pre-key<32, 56bytes>
- One-Time Pre-key Id
0xFF: error
- Error Code[0x00-0x08]
- Optional error message of variable size Null terminated ASCII string
データベーステーブルの作成
以下のPHPスクリプトに含まれるSQLスクリプトを使用して、Users、OPk、Configの3つのテーブルを作成します。
/* Users table:
* - id as primary key (internal use)
* - a userId(shall be the GRUU)
* - Ik (Identity key - EDDSA public key)
* - SPk(the current signed pre-key - ECDH public key)
* - SPk_sig(current SPk public key signed with Identity key)
* - SPh_id (id for SPk provided and internally used by client) - 4 bytes unsigned integer
*/
/* One Time PreKey table:
* - an id as primary key (internal use)
* - the Uid of user owning that key
* - the public key (ECDH public key)
* - OPk_id (id for OPk provided and internally used by client) - 4 bytes unsigned integer
*/
/* Config table: holds version and curveId parameters:
* - Name: the parameter name (version or curveId)
* - Value: the parameter value (db version scheme or curveId mapped to an integer)
*/
$db->query("CREATE TABLE Users (
Uid INTEGER NOT NULL AUTO_INCREMENT,
UserId TEXT NOT NULL,
Ik BLOB NOT NULL,
SPk BLOB DEFAULT NULL,
SPk_sig BLOB DEFAULT NULL,
SPk_id INTEGER UNSIGNED DEFAULT NULL,
PRIMARY KEY(Uid));");
$db->query("CREATE TABLE OPk (
id INTEGER NOT NULL AUTO_INCREMENT,
Uid INTEGER NOT NULL,
OPk BLOB NOT NULL,
OPk_id INTEGER UNSIGNED NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(Uid) REFERENCES Users(Uid) ON UPDATE CASCADE ON DELETE CASCADE);");
$db->query("CREATE TABLE Config(
Name VARCHAR(20),
Value INTEGER NOT NULL);");
ユーザ認証プロセス
ユーザ認証プロセスは、ファイル転送サーバ の認証プロセスを参考に作成。
LinphoneのX3DH(LIME)キーサーバURL。設定ファイルの読込とX3DHで使用する楕円曲線を選択。
lime-server.php
<?php
// Include the configuration file AFTER the definition of logLevel
// get configuration file path, default is /etc/lime-server/lime-server.conf
// but give a chance to the webserver to configure it
include $_SERVER["x3dh_config_path"]
?? "/etc/lime-server/lime-server.conf";
//$config_file = "/etc/lime-server/lime-server.conf";
if (curveId == CurveId::CURVE25519) {
include("x3dh-25519.php");
} elseif (curveId == CurveId::CURVE448) {
include("x3dh-448.php");
} else {
error_log ("Error: Unknown Curve " . "\n");
bad_request();
}
?>
使用する楕円曲線をCurve25519とし、ダイジェスト認証の条件設定(TLSクライアント証明書による認証には、Linphoneのソースコード側でも対応する必要あり)
x3dh-25519.php
<?php
include("common.php");
// That one shall be set by the user authentication layer
$userId = $_SERVER['HTTP_FROM'];
// first check server settings
x3dh_checkSettings();
// If digest auth is enabled, check it pass - skip it if the user passed a TLS client authentication
//if (defined("DIGEST_AUTH") && DIGEST_AUTH === true && ($_SERVER['SSL_CLIENT_VERIFY'] != 'SUCCESS')) {
if (defined("DIGEST_AUTH") && DIGEST_AUTH === true) {
if (check_user_authentication() === true) {
x3dh_process_request($userId);
}
}
?>
common.php
<?php
include("x3dh.php");
// log level one of (LogLevel::DISABLED, ERROR, WARNING, MESSAGE, DEBUG)
// default to DISABLED (recommended value)
define ("x3dh_logLevel" , LogLevel::DEBUG);
define ("x3dh_logFile", "/var/opt/belledonne-communications/log/lime-server.log"); // make sure to have actual write permission to this file
define ("x3dh_logDomain", "X3DH"); // in case Logs are mixed with other applications ones, format is [time tag] -Domain- message
// avoid date() warnings
// time zone shall be set in php.ini or in lime configuration file if needed
date_default_timezone_set(@date_default_timezone_get());
/*** User authentication ***/
function auth_get_db_conn()
{
$conn = (USE_PERSISTENT_CONNECTIONS)
? mysqli_connect('p:' . AUTH_DB_HOST, AUTH_DB_USER, AUTH_DB_PASSWORD, AUTH_DB_NAME)
: mysqli_connect(AUTH_DB_HOST, AUTH_DB_USER, AUTH_DB_PASSWORD, AUTH_DB_NAME);
if (!$conn) {
x3dh_log(LogLevel::ERROR, "Unable to connect to MySQL base " . AUTH_DB_NAME . ".\nDebugging errno: " . mysqli_connect_errno() . "\nDebugging error: " . mysqli_connect_error() . "\n");
}
return $conn;
}
// Nonce are one-time usage, in order to avoid storing them in a table
// The nonce is built using:
// - timestamp : nonce is valid for MIN_NONCE_VALIDITY_PERIOD seconds at minimum and twice it at maximum (our goal is one time usage anyway, typical value shall be 10 )
// - secret key : avoid an attacker to be able to generate a valid nonce
function auth_get_valid_nonces()
{
$time = time();
$time -= $time % MIN_NONCE_VALIDITY_PERIOD; // our nonce will be valid at leat MIN_NONCE_VALIDITY_PERIOD seconds and max twice it, so floor the timestamp
return [
hash_hmac("sha256", $time, AUTH_NONCE_KEY),
hash_hmac("sha256", $time - MIN_NONCE_VALIDITY_PERIOD, AUTH_NONCE_KEY)
];
}
function request_authentication($realm = "sip.ficusonline.com", $username = null)
{
$has_md5 = false;
$has_sha256 = false;
if ($username != null) {
// Get the password/hash from database to include only available password hash in the authenticate header
$auth_db = auth_get_db_conn();
$stmt = $auth_db->prepare(AUTH_QUERY);
if (!$stmt) {
x3dh_log(LogLevel::ERROR, "Unable to execute " . AUTH_QUERY . ".\nDebugging errno: " . mysqli_connect_errno() . "\nDebugging error: " . mysqli_connect_error() . "\n");
$has_md5 = true;
$has_sha256 = true;
} else {
$stmt->bind_param('ss', $username, $realm);
$stmt->execute();
if ($query_result = $stmt->get_result()) {
while ($row = $query_result->fetch_assoc()) {
$algorithm = $row['algorithm'];
if ($algorithm == 'CLRTXT') {
x3dh_log(LogLevel::DEBUG, "User " . $username . " has clear text password in db \n");
$has_md5 = true;
$has_sha256 = true;
break; // with clear text password, we can reconstruct MD5 or SHA256 hash, don't parse anything else from base
} elseif ($algorithm == 'MD5') {
x3dh_log(LogLevel::DEBUG, "User " . $username . " has md5 password in db \n");
$has_md5 = true;
} elseif ($algorithm == 'SHA-256') {
x3dh_log(LogLevel::DEBUG, "User " . $username . " has sha256 password in db \n");
$has_sha256 = true;
} else {
x3dh_log(LogLevel::WARNING, "User " . $username . " uses unrecognised hash algorithm " . $algorithm . " to store password in db \n");
}
}
}
$stmt->close();
}
$auth_db->close();
} else { // we don't have the username authorize both MD5 and SHA256
$has_md5 = true;
$has_sha256 = true;
}
if (($has_md5 || $has_sha256) == false) {
x3dh_log(LogLevel::WARNING, "User " . $username . " not found in db upon request_authentification\n");
// reply anyway with both hash authorized
$has_md5 = true;
$has_sha256 = true;
}
header('HTTP/1.1 401 Unauthorized');
if ($has_md5 == true) {
header('WWW-Authenticate: Digest realm="' . $realm .
'",qop="auth",algorithm=MD5,nonce="' . auth_get_valid_nonces()[0] . '",opaque="' . md5($realm) . '"');
}
if ($has_sha256 == true) {
header('WWW-Authenticate: Digest realm="' . $realm .
'",qop="auth",algorithm=SHA-256,nonce="' . auth_get_valid_nonces()[0] . '",opaque="' . md5($realm) . '"', false);
}
exit();
}
function authenticate($auth_digest, $realm = "sip.ficusonline.com")
{
// Parse the client authentication data
preg_match_all('@(realm|username|nonce|uri|nc|cnonce|qop|response|opaque|algorithm)=[\'"]?([^\'",]+)@', $auth_digest, $a);
$data = array_combine($a[1], $a[2]);
$username = $data['username'];
//x3dh_log(LogLevel::DEBUG, "Data " . $data . " User " . $username . " check here");
// Is the nonce valid?
$valid_nonces = auth_get_valid_nonces();
if (!hash_equals($valid_nonces[0], $data['nonce']) && !hash_equals($valid_nonces[1], $data['nonce'])) {
x3dh_log(LogLevel::DEBUG, "User " . $username . " tried to log using invalid nonce");
return;
}
// check that the authenticated URI and server URI match
if ($data['uri'] != $_SERVER['REQUEST_URI']) {
x3dh_log(LogLevel::DEBUG, "User " . $username . " tried to log using unmatching URI in auth and server request");
return;
}
// Check opaque is correct(even if we use a fixed value: md5($realm))
if (!hash_equals(md5($realm), $data['opaque'])) {
x3dh_log(LogLevel::DEBUG, "User " . $username . " tried to log using invalid auth opaque value");
return;
}
// Get the password/hash from database
$auth_db = auth_get_db_conn();
$stmt = $auth_db->prepare(AUTH_QUERY);
if (!$stmt) {
x3dh_log(LogLevel::ERROR, "Unable to execute " . AUTH_QUERY . ".\nDebugging errno: " . mysqli_connect_errno() . "\nDebugging error: " . mysqli_connect_error() . "\n");
return;
}
$stmt->bind_param('ss', $username, $realm);
$stmt->execute();
// Default requested to MD5 if not specified in header
$requested_algorithm = (array_key_exists('algorithm', $data))
? $data['algorithm']
: 'MD5';
$password = null;
if ($query_result = $stmt->get_result()) {
while ($row = $query_result->fetch_assoc()) {
$password = $row['password'];
$algorithm = $row['algorithm'];
if ($algorithm == 'CLRTXT') {
x3dh_log(LogLevel::DEBUG, "User " . $username . " using clear text password from db \n");
break; // with clear text password, we can reconstruct MD5 or SHA256 hash, don't parse anything else from base
} elseif ($algorithm == $requested_algorithm) {
x3dh_log(LogLevel::DEBUG, "User " . $username . " using " . $row['algorithm'] . " password from db \n");
break; // we found the requested hash in base, don't parse anything else
} else { // algo used to store the password in base won't allow to reconstruct the one given in the header, keep parsing query result
$password = null;
$algorithm = null;
}
}
}
$stmt->close();
$auth_db->close();
if (is_null($password)) {
x3dh_log(LogLevel::ERROR, "Unable to find password for User: " . $username . " in format " . $requested_algorithm);
return;
}
//select right hash
switch ($requested_algorithm) {
case 'MD5':
$hash_algo = 'md5';
break;
case 'SHA-256':
$hash_algo = 'sha256';
break;
default:
x3dh_log(LogLevel::ERROR, "Unsupported algo " . $requested_algorithm . " for User:" . $username . "\n");
break;
}
$A1 = ($algorithm == 'CLRTXT')
? hash($hash_algo, $username . ':' . $data['realm'] . ':' . $password)
: $password;
$A2 = hash($hash_algo, getenv('REQUEST_METHOD') . ':' . $data['uri']);
$valid_response = hash($hash_algo, $A1 . ':' . $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' . $A2);
// Compare with the client response
if (hash_equals($valid_response, $data['response'])) {
return $username;
}
}
function bad_request()
{
http_response_code(400);
exit();
}
function check_user_authentication()
{
//if ($_SERVER['SSL_CLIENT_VERIFY'] == 'SUCCESS') {
// x3dh_log(LogLevel::DEBUG, "Authentication successful using TLS client certificate from " . $_SERVER['HTTP_FROM']);
// return true;
//}
//x3dh_log(LogLevel::DEBUG, "HTTP_FROM: " . $_SERVER['HTTP_FROM'] . " SSL_CLIENT_VERIFY : " . $_SERVER['SSL_CLIENT_VERIFY']);
x3dh_log(LogLevel::DEBUG, "HTTP_FROM: " . $_SERVER['HTTP_FROM'] . "\n");
if (defined("DIGEST_AUTH") && DIGEST_AUTH === true) {
// Check the configuration
if (!defined("AUTH_NONCE_KEY") || strlen(AUTH_NONCE_KEY) < 12) {
x3dh_log(LogLevel::ERROR, "Your limeserver is badly configured, please set a random string in AUTH_NONCE_KEY at least 12 characters long");
http_response_code(500);
exit();
}
$headers = getallheaders();
// From is the GRUU('sip(s):username@auth_realm;gr=*;) or just a sip:uri(sip(s):username@auth_realm), we need to extract the username from it:
// from position of : until the first occurence of @
// pass it through rawurldecode has GRUU may contain escaped characters
$username_start_pos = strpos($headers['From'], ':') + 1;
$username_end_pos = strpos($headers['From'], '@');
$username = rawurldecode(substr($headers['From'], $username_start_pos, $username_end_pos - $username_start_pos));
$username_end_pos++; // point to the begining of the realm
if (!defined("AUTH_REALM")) {
$auth_realm = (strpos($headers['From'], ';') === false)
? rawurldecode(substr($headers['From'], $username_end_pos)) // From holds a sip:uri
: rawurldecode(substr($headers['From'], $username_end_pos, strpos($headers['From'], ';') - $username_end_pos)); // From holds a GRUU
} else {
$auth_realm = AUTH_REALM;
}
// Get authentication header if there is one
if (!empty($headers['Auth-Digest'])) {
x3dh_log(LogLevel::DEBUG, "Auth-Digest = " . $headers['Auth-Digest']);
$authorization = $headers['Auth-Digest'];
} elseif (!empty($headers['Authorization'])) {
x3dh_log(LogLevel::DEBUG, "Authorization = " . $headers['Authorization']);
$authorization = $headers['Authorization'];
}
//x3dh_log(LogLevel::DEBUG, "Auth-Digest: " . $headers['Auth-Digest'] . " Authorization : " . $headers['Authorization']);
// Authentication
if (!empty($authorization)) {
x3dh_log(LogLevel::DEBUG, "There is a digest authentication header for " . $headers['From'] . " (username " . $username . " requesting Auth on realm " . $auth_realm . " )");
$authenticated_username = authenticate($authorization, $auth_realm);
if ($authenticated_username != '') {
x3dh_log(LogLevel::DEBUG, "Authentication successful for " . $headers['From'] . "with username " . $username);
return true;
} else {
x3dh_log(LogLevel::DEBUG, "Authentication failed for " . $headers['From'] . " requesting authorization for user " . $username);
request_authentication($auth_realm, $username);
}
} else {
x3dh_log(LogLevel::DEBUG, "There is no authentication digest header for " . $headers['From'] . " requesting authorization for user " . $username);
request_authentication($auth_realm, $username);
}
} else {
// no auth requested
return true;
}
}
ユーザ認証に問題がなければ、X3DHキーサーバへアクセスし、X3DHに必要な各鍵の登録と読込み等を実行。
x3dh.php
<?php
function x3dh_get_db_conn() {
$db = mysqli_connect(X3DH_DB_HOST, X3DH_DB_USER, X3DH_DB_PASSWORD, X3DH_DB_NAME);
if (!$db) {
error_log ("Error: Unable to connect to MySQL.\nDebugging errno: " . mysqli_connect_errno() . "\nDebugging error: " . mysqli_connect_error() . "\n");
exit;
}
return $db;
}
// be sure we do not display any error or it may mess the returned message
ini_set('display_errors', 'Off');
// emulate simple enumeration
abstract class LogLevel {
const DISABLED = 0;
const ERROR = 1;
const WARNING = 2;
const MESSAGE = 3;
const DEBUG = 4;
};
function stringErrorLevel($level) {
switch($level) {
case LogLevel::DISABLED: return "DISABLED";
case LogLevel::ERROR: return "ERROR";
case LogLevel::WARNING: return "WARNING";
case LogLevel::MESSAGE: return "MESSAGE";
case LogLevel::DEBUG: return "DEBUG";
default: return "UNKNOWN";
}
}
function x3dh_log($level, $message) {
if (x3dh_logLevel>=$level) {
if ($level === LogLevel::ERROR) { // in ERROR case, add a backtrace
file_put_contents(x3dh_logFile, date("Y-m-d h:m:s")." -".x3dh_logDomain."- ".stringErrorLevel($level)." : $message\n".print_r(debug_backtrace(),true), FILE_APPEND);
} else {
file_put_contents(x3dh_logFile, date("Y-m-d h:m:s")." -".x3dh_logDomain."- ".stringErrorLevel($level)." : $message\n", FILE_APPEND);
}
}
}
// Resource abuse protection
// Setting to 0 disable the function
//lime_max_device_per_user does not apply to this test server as it is used to test the lime lib only and device id are not gruub in the tests
// Do not set this value too low, it shall not be lower than the server_low_limit+batch_size used by client - default is 100+25
const lime_max_opk_per_device = 200;
define ('X3DH_protocolVersion', 0x01);
define ('X3DH_headerSize', 3);
// WARNING: value shall be in sync with defines in client code */
// emulate simple enumeration with abstract class
// https://github.com/BelledonneCommunications/lime/blob/5.3.71/src/lime_x3dh_protocol.cpp
abstract class MessageTypes {
const unset_type = 0x00;
const deprecated_registerUser = 0x01;
const deleteUser = 0x02;
const postSPk = 0x03;
const postOPks = 0x04;
const getPeerBundle = 0x05;
const peerBundle = 0x06;
const getSelfOPks = 0x07;
const selfOPks = 0x08;
const registerUser=0x09;
const error = 0xff;
};
abstract class ErrorCodes {
const bad_content_type = 0x00;
const bad_curve = 0x01;
const missing_senderId = 0x02;
const bad_x3dh_protocol_version = 0x03;
const bad_size = 0x04;
const user_already_in = 0x05;
const user_not_found = 0x06;
const db_error = 0x07;
const bad_request = 0x08;
const server_failure = 0x09;
const resource_limit_reached=0x0a;
const unknown_error_code=0xfe;
const unset_error_code=0xff;
};
abstract class KeyBundleFlag {
const noOPk = 0x00;
const OPk = 0x01;
const noBundle = 0x02;
};
// define keys and signature size in bytes based on the curve used
const keySizes = array(
CurveId::CURVE25519 => array ('X_pub' => 32, 'X_priv' => 32, 'ED_pub' => 32, 'ED_priv' => 32, 'Sig' => 64),
CurveId::CURVE448 => array ('X_pub' => 56, 'X_priv' => 56, 'ED_pub' => 57, 'ED_priv' => 57, 'Sig' => 114)
);
function returnError($code, $errorMessage) {
x3dh_log(LogLevel::WARNING, "return an error message code ".$code." : ".$errorMessage);
$header = pack("C4",X3DH_protocolVersion, MessageTypes::error, curveId, $code); // build the X3DH response header, append the error code
file_put_contents('php://output', $header.$errorMessage);
exit;
}
function returnOk($message) {
x3dh_log(LogLevel::DEBUG, "Ok return a message ".bin2hex($message));
file_put_contents('php://output', $message);
exit;
}
// Check server setting:
function x3dh_checkSettings() {
// curveId must be 25519 or 448
switch(curveId) {
// Authorized values
case CurveId::CURVE25519 :
case CurveId::CURVE448 :
break;
// Any other are invalid
default:
x3dh_log(LogLevel::ERROR, "Process X3DH request given incorrect curveId parameter (".curveId.").");
returnError(ErrorCodes::server_failure, "X3DH server is not correctly configured");
}
// log Level, if not set or incorrect, disable log
switch(x3dh_logLevel) {
// Authorized values
case LogLevel::DISABLED :
case LogLevel::ERROR :
case LogLevel::WARNING :
case LogLevel::MESSAGE :
case LogLevel::DEBUG :
break;
// any other default to DISABLED
default:
error_log("X3DH log level setting is invalid ".x3dh_logLevel.". Disable X3DH logs");
define ("x3dh_logLevel", LogLevel::DISABLED);
}
}
function x3dh_process_request($userId) {
// Check setting
// that will end the script if settings are detected to be incorrect
// Could be commented out after server settings have been checked to be ok
x3dh_checkSettings();
$userId = filter_var($userId, FILTER_SANITIZE_URL); // userId is supposed to be a GRUU so it shall pass untouched the sanitize URL action
if ($userId == '') {
x3dh_log(LogLevel::ERROR, "X3DH server got a request without proper user Id");
returnError(ErrorCodes::missing_senderId, "User not correctly identified");
}
// set response content type
header('Content-type: x3dh/octet-stream');
// retrieve response body from php://input
$request = file_get_contents('php://input');
$requestSize = strlen($request);
x3dh_log(LogLevel::DEBUG, "Incoming request from ".$userId." is :".bin2hex($request));
// check available length before parsing, we must at least have a header
if ($requestSize < X3DH_headerSize) {
returnError(ErrorCodes::bad_size, "Packet is not even holding a header. Size ".$requestSize);
}
// parse X3DH header
$X3DH_header=unpack("CprotocolVersion/CmessageType/CcurveId", $request);
x3dh_log(LogLevel::DEBUG, "protocol version ".$X3DH_header['protocolVersion']." messageType ".$X3DH_header['messageType']." curveId ".$X3DH_header['curveId']);
if ($X3DH_header['protocolVersion'] != X3DH_protocolVersion) {
returnError(ErrorCodes::bad_x3dh_protocol_version, "Server running X3DH procotol version ".X3DH_protocolVersion.". Can't process packet with version ".$X3DH_header['protocolVersion']);
}
if ($X3DH_header['curveId'] != curveId) {
returnError(ErrorCodes::bad_curve, "Server running X3DH procotol using curve ".((curveId==CurveId.CURVE25519)?"25519":"448")+"(id ".curveId."). Can't process serve client using curveId ".$X3DH_header['curveId']);
}
// acknowledge message by sending an empty message with same header (modified in case of getPeerBundle and get selfOPks request)
$returnHeader = substr($request, 0, X3DH_headerSize);
// open the db connection
$db = x3dh_get_db_conn();
switch ($X3DH_header['messageType']) {
/* Deprecated Register User Identity Key : Identity Key <EDDSA Public key size >*/
case MessageTypes::deprecated_registerUser:
x3dh_log(LogLevel::MESSAGE, "Got a depricated registerUser Message from ".$userId);
$x3dh_expectedSize = keySizes[curveId]['ED_pub'];
if ($requestSize < X3DH_headerSize + $x3dh_expectedSize) {
returnError(ErrorCodes::bad_size, "Depricated Register Identity packet is expexted to be ".(X3DH_headerSize+$x3dh_expectedSize)." bytes, but we got $requestSize bytes");
}
$Ik = substr($request, X3DH_headerSize, $x3dh_expectedSize);
// Acquire lock on the user table to avoid 2 users being written at the same time with the same name
$db->query("LOCK TABLES Users WRITE");
// Check that this usedId is not already in base
if (($stmt = $db->prepare("SELECT Uid FROM Users WHERE UserId = ? LIMIT 1")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$stmt->bind_param('s', $userId);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
if ($result->num_rows !== 0) {
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::user_already_in, "Can't insert user ".$userId." - is already present in base");
}
// Insert User in DB
x3dh_log(LogLevel::DEBUG, "Insert user $userId with Ik :".bin2hex($Ik));
if (($stmt = $db->prepare("INSERT INTO Users(UserId,Ik) VALUES(?,?)")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$null = NULL;
$stmt->bind_param('sb', $userId, $null);
$stmt->send_long_data(1,$Ik);
$status = $stmt->execute();
$stmt->close();
$db->query("UNLOCK TABLES");
if (!$status) {
x3dh_log(LogLevel::ERROR, "User $userId INSERT failed error is ".$db->error);
returnError(ErrorCodes::db_error, "Error while trying to insert user ".$userId);
}
// we're done
returnOk($returnHeader);
break;
/* Register User Identity Key :
* Identity Key <EDDSA Public key size > |
* Signed Pre Key <ECDH public key size> | SPk Signature <Signature length> | SPk id <uint32_t big endian: 4 bytes>
* OPk number < uint16_t big endian: 2 bytes> | (OPk <ECDH public key size> | OPk id <uint32_t big endian: 4 bytes> ){OPk number} */
case MessageTypes::registerUser:
x3dh_log(LogLevel::MESSAGE, "Got a registerUser Message from ".$userId);
$x3dh_expectedSize = keySizes[curveId]['ED_pub']
+ keySizes[curveId]['X_pub'] + keySizes[curveId]['Sig'] + 4
+ 2;
if ($requestSize < X3DH_headerSize + $x3dh_expectedSize) {
returnError(ErrorCodes::bad_size, "Register Identity packet is expexted to be ".(X3DH_headerSize+$x3dh_expectedSize)." bytes, but we got $requestSize bytes");
}
$bufferIndex = X3DH_headerSize;
$OPk_number = unpack("n", substr($request, $bufferIndex + $x3dh_expectedSize - 2, 2))[1];
if (lime_max_opk_per_device > 0 && $OPk_number > lime_max_opk_per_device) {
returnError('resource_limit_reached', $userId . " is trying to register itself with ".$OPk_number." OPks but server has a limit of ".lime_max_opk_per_device);
}
$x3dh_expectedSize += $OPk_number * (keySizes[curveId]['X_pub'] + 4);
if ($requestSize < X3DH_headerSize + $x3dh_expectedSize) {
returnError(ErrorCodes::bad_size, "Register User packet is expected to be (with ".$OPk_number." OPks) ".(X3DH_headerSize + $x3dh_expectedSize)." bytes, but we got ".$requestSize." bytes");
}
$Ik = substr($request, $bufferIndex, keySizes[curveId]['ED_pub']);
$bufferIndex += keySizes[curveId]['ED_pub'];
$SPk = substr($request, $bufferIndex, keySizes[curveId]['X_pub']);
$bufferIndex += keySizes[curveId]['X_pub'];
$Sig = substr($request, $bufferIndex, keySizes[curveId]['Sig']);
$bufferIndex += keySizes[curveId]['Sig'];
$SPk_id = unpack("N", substr($request, $bufferIndex, 4))[1];
$bufferIndex += 6;
$OPks_param = [];
for ($i = 0; $i < $OPk_number; $i++) {
$OPk = substr($request, $bufferIndex, keySizes[curveId]['X_pub']);
$bufferIndex += keySizes[curveId]['X_pub'];
$OPk_id = unpack("N", substr($request, $bufferIndex, 4))[1];
$bufferIndex += 4;
$OPks_param[] = [$OPk, $OPk_id];
}
// Acquire lock on the user table to avoid 2 users being written at the same time with the same name
$db->query("LOCK TABLES Users WRITE");
// Check that this usedId is not already in base
if (($stmt = $db->prepare("SELECT Uid, Ik, SPk, SPk_sig, SPk_id FROM Users WHERE UserId = ? LIMIT 1")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$stmt->bind_param("s", $userId);
$stmt->execute();
$result=$stmt->get_result();
$stmt->close();
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
// Check if Ik, SPk, SPk_sig match
if ($Ik == $row['Ik'] && $SPk == $row['SPk'] && $Sig == $row['SPk_sig'] && $SPk_id == $row['SPk_id']) {
$db->query("UNLOCK TABLES");
x3dh_log(LogLevel::WARNING, "User ".$userId." was already in db, reinsertion with same Ik and SPk required, do nothing and return Ok");
returnOk($returnHeader);
} else {
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::user_already_in, "Can't insert user ".$userId." - is already present in base and we try to insert a new one with differents Keys");
}
} else {
// before version 5.6.5, begin_transaction doesn't support READ_WRITE flag
if ($db->server_version < 50605) {
$db->begin_transaction();
} else {
$db->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
}
$stmt_insert = $db->prepare("INSERT INTO Users (UserId, Ik, SPk, SPk_sig, SPk_id) VALUES (?, ?, ?, ?, ?)");
$stmt_insert->bind_param("sbbbi", $userId, $Ik, $SPk, $Sig, $SPk_id);
$stmt_insert->send_long_data(1,$Ik);
$stmt_insert->send_long_data(2,$SPk);
$stmt_insert->send_long_data(3,$Sig);
$errInsert = $stmt_insert->execute();
$stmt_insert->close();
if ($errInsert) {
$Uid = $db->insert_id;
$stmt_opk = $db->prepare("INSERT INTO OPk (Uid, OPk, OPk_id) VALUES (?, ?, ?)");
foreach ($OPks_param as $param) {
$stmt_opk->bind_param("ibi", $Uid, $param[0], $param[1]);
$stmt_opk->send_long_data(1,$param[0]);
$stmt_err = $stmt_opk->execute();
if (!$stmt_err) {
$stmt_opk->close();
$db->rollback();
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Error while trying to insert OPk for user" .$userId. " Backend says: ".$db->error);
}
}
$stmt_opk->close();
$db->commit();
$db->query("UNLOCK TABLES");
returnOk($returnHeader);
} else {
$db->rollback();
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Error while trying to insert user" .$userId. " INSERT failed, error is: ".$db->error);
}
}
break;
/* Delete user: message is empty(or at least shall be, anyway, just ignore anything present in the messange and just delete the user given in From header */
case MessageTypes::deleteUser:
x3dh_log(LogLevel::MESSAGE, "Got a deleteUser Message from ".$userId);
if (($stmt = $db->prepare("DELETE FROM Users WHERE UserId = ?")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$stmt->bind_param('s', $userId);
$status = $stmt->execute();
$stmt->close();
if ($status) {
returnOk($returnHeader);
} else {
x3dh_log(LogLevel::ERROR, "User $userId DELETE failed err is ".$db->error);
returnError(ErrorCodes::db_error, "Error while trying to delete user ".$userId);
}
break;
/* Post Signed Pre Key: Signed Pre Key <ECDH public key size> | SPk Signature <Signature length> | SPk id <uint32_t big endian: 4 bytes> */
case MessageTypes::postSPk:
x3dh_log(LogLevel::MESSAGE, "Got a postSPk Message from ".$userId);
$x3dh_expectedSize = keySizes[curveId]['X_pub'] + keySizes[curveId]['Sig'] + 4;
// check message length
if ($requestSize < X3DH_headerSize + $x3dh_expectedSize) {
returnError(ErrorCodes::bad_size, "post SPK packet is expexted to be ".(X3DH_headerSize+$x3dh_expectedSize)." bytes, but we got $requestSize bytes");
}
// parse message
$bufferIndex = X3DH_headerSize;
$SPk = substr($request, $bufferIndex, keySizes[curveId]['X_pub']);
$bufferIndex += keySizes[curveId]['X_pub'];
$Sig = substr($request, $bufferIndex, keySizes[curveId]['Sig']);
$bufferIndex += keySizes[curveId]['Sig'];
$SPk_id = unpack('N', substr($request, $bufferIndex))[1]; // SPk id is a 32 bits unsigned integer in Big endian
// check we have a matching user in DB
if (($stmt = $db->prepare("SELECT Uid FROM Users WHERE UserId = ? LIMIT 1")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$stmt->bind_param('s', $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) { // user not found
$stmt->close();
returnError(ErrorCodes::user_not_found, "Post SPk but ".$userId." not found in db"); // that will exit the script execution
}
// get the Uid from previous query
$row = $result->fetch_assoc();
$Uid = $row['Uid'];
$stmt->close();
// write the SPk
if (($stmt = $db->prepare("UPDATE Users SET SPk = ?, SPk_sig = ?, SPk_id = ? WHERE Uid = ?")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$null = NULL;
$stmt->bind_param('bbii', $null, $null, $SPk_id, $Uid);
$stmt->send_long_data(0,$SPk);
$stmt->send_long_data(1,$Sig);
$status = $stmt->execute();
$stmt->close();
if ($status) {
returnOk($returnHeader);
} else {
x3dh_log(LogLevel::ERROR, "User's $userId SPk INSERT failed err is ".$db->error);
returnError(ErrorCodes::db_error, "Error while trying to insert SPK for user $userId. Backend says :".$db->error);
}
break;
/* Post OPks : OPk number < uint16_t big endian: 2 bytes> | (OPk <ECDH public key size> | OPk id <uint32_t big endian: 4 bytes> ){OPk number} */
case MessageTypes::postOPks:
x3dh_log(LogLevel::MESSAGE,"Got a postOPks Message from ".$userId);
// get the OPks number in the first message bytes(unsigned int 16 in big endian)
$bufferIndex = X3DH_headerSize;
$OPk_number = unpack('n', substr($request, $bufferIndex))[1];
$x3dh_expectedSize = 2 + $OPk_number*(keySizes[curveId]['X_pub'] + 4); // expect: OPk count<2bytes> + number of OPks*(public key size + OPk_Id<4 bytes>)
if ($requestSize < X3DH_headerSize + $x3dh_expectedSize) {
returnError(ErrorCodes::bad_size, "post OPKs packet is expected to be ".(X3DH_headerSize+$x3dh_expectedSize)." bytes, but we got $requestSize bytes");
}
$bufferIndex+=2; // point to the beginning of first key
x3dh_log(LogLevel::DEBUG, "It contains ".$OPk_number." keys");
// check we have a matching user in DB
if (($stmt = $db->prepare("SELECT Uid FROM Users WHERE UserId = ? LIMIT 1")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$stmt->bind_param('s', $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$stmt->close();
returnError(ErrorCodes::user_not_found, "Post SPk but ".$userId." not found in db"); // that will exit the script execution
}
// get the Uid from previous query
$row = $result->fetch_assoc();
$Uid = $row['Uid'];
$stmt->close();
// Additional codes
if (lime_max_opk_per_device > 0) {
// Check OPK total number
$stmt = $db->prepare("SELECT COUNT(OPK_id) as OPk_count FROM Users as u INNER JOIN OPk as o ON u.Uid=o.Uid WHERE UserId = ?");
$stmt->bind_param("s", $userId);
$stmt->execute();
$stmt->bind_result($OPk_count);
$stmt->fetch();
$stmt->close();
// Error occured
if ($db->errno) {
returnError(ErrorCodes::db_error, "Database error in postOPks by " . $userId . ": " . $db->error);
}
// Initialise OPk = 0
if (!isset($OPk_count)) {
$OPk_count = 0;
}
// Check OPk number over limited
if ($OPk_count + $OPk_number > lime_max_opk_per_device) { // in case of too many OPk
returnError(ErrorCodes::resource_limit_reached, $userId . " is trying to insert " . $OPk_number . " OPks but server has a limit of " . lime_max_opk_per_device . " and it already holds " . $OPk_count);
}
}
// until here
if (($stmt = $db->prepare("INSERT INTO OPk(Uid, OPk, OPk_id) VALUES(?,?,?)")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$null = NULL;
$stmt ->bind_param("ibi", $Uid, $null, $OPk_id);
// before version 5.6.5, begin_transaction doesn't support READ_WRITE flag
if ($db->server_version < 50605) {
$db->begin_transaction();
} else {
$db->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
}
for ($i = 0; $i<$OPk_number; $i++) {
$OPk = substr($request, $bufferIndex, keySizes[curveId]['X_pub']);
$bufferIndex += keySizes[curveId]['X_pub'];
$stmt->send_long_data(1,$OPk);
$OPk_id = unpack('N', substr($request, $bufferIndex))[1]; // OPk id is a 32 bits unsigned integer in Big endian
$bufferIndex += 4;
if (!$stmt->execute()) {
$stmt->close();
$db->rollback();
returnError(ErrorCodes::db_error, "Error while trying to insert OPk for user ".$userId.". Backend says :".$db->error);
}
}
$stmt->close();
$db->commit();
returnOk($returnHeader);
break;
/* peerBundle : bundle Count < 2 bytes unsigned Big Endian> |
* ( deviceId Size < 2 bytes unsigned Big Endian > | deviceId
* Flag<1 byte: 0 if no OPK in bundle, 1 if present, 2 no bundle> |
* Ik <EDDSA Public Key Length> |
* SPk <ECDH Public Key Length> | SPK id <4 bytes>
* SPk_sig <Signature Length> |
* (OPk <ECDH Public Key Length> | OPk id <4 bytes>){0,1 in accordance to flag}
* ) { bundle Count}
*/
case MessageTypes::getPeerBundle:
x3dh_log(LogLevel::MESSAGE, "Got a getPeerBundle Message from ".$userId);
// first parse the message
if ($requestSize < X3DH_headerSize + 2) { // we must have at least 2 bytes to parse peers counts
returnError(ErrorCodes::bad_size, "post SPKs packet is expected to be at least ".(X3DH_headerSize+2)." bytes, but we got $requestSize bytes");
}
// get the peers number in the first message bytes(unsigned int 16 in big endian)
$bufferIndex = X3DH_headerSize;
$peersCount = unpack('n', substr($request, $bufferIndex))[1];
if ($peersCount === 0) {
returnError(ErrorCodes::bad_request, "Ask for peer Bundles but no device id given");
}
$bufferIndex+=2; // point to the beginning of first key
// build an array of arrays like [[deviceId], [deviceId], ...]
$peersBundle = array();
for ($i=0; $i<$peersCount; $i++) {
// check we have enought bytes to parse before calling unpack
if ($requestSize - $bufferIndex<2) {
returnError(ErrorCodes::bad_request, "Malformed getPeerBundle request: peers count doesn't match the device id list length");
}
$idLength = unpack('n', substr($request, $bufferIndex))[1];
$bufferIndex+=2;
if ($requestSize - $bufferIndex<$idLength) {
returnError(ErrorCodes::bad_request, "Malformed getPeerBundle request: device id given size doesn't match the string length");
}
$peersBundle[] = array(substr($request, $bufferIndex, $idLength));
$bufferIndex+=$idLength;
}
x3dh_log(LogLevel::DEBUG, "Found request for ".$peersCount." keys bundles");
x3dh_log(LogLevel::DEBUG, print_r($peersBundle, true));
//$db->query("LOCK TABLES Users WRITE, OPk WRITE"); // lock the OPk table so we won't give twice a one-time pre-key
// before version 5.6.5, begin_transaction doesn't support READ_WRITE flag
if ($db->server_version < 50605) {
$db->begin_transaction();
} else {
$db->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
}
if (($stmt = $db->prepare("SELECT u.Ik as Ik, u.SPk as SPk, u.SPk_id as SPk_id, u.SPk_sig as SPk_sig, o.OPk as OPk, o.OPk_id as OPk_id, o.id as id FROM Users as u LEFT JOIN OPk as o ON u.Uid=o.Uid WHERE UserId = ? AND u.SPk IS NOT NULL LIMIT 1 FOR UPDATE")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$keyBundleErrors = ''; // an empty string to store error message if any
for ($i=0; $i<$peersCount; $i++) {
$stmt->bind_param('s', $peersBundle[$i][0]);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if (!$row) {
x3dh_log(LogLevel::DEBUG, "User ".$peersBundle[$i][0]." not found");
} else {
array_push($peersBundle[$i], $row['Ik'], $row['SPk'], $row['SPk_id'], $row['SPk_sig']);
if ($row['OPk']) { // there is an OPk
array_push($peersBundle[$i], $row['OPk'], $row['OPk_id']);
// now that we used that one, we MUST delete it
if (($del_stmt = $db->prepare("DELETE FROM OPk WHERE id = ?")) === FALSE) {
x3dh_log(LogLevel::ERROR, "Database appears to be not what we expect");
returnError(ErrorCodes::db_error, "Server database not ready");
}
$del_stmt->bind_param('i', $row['id']);
if (!$del_stmt->execute()) {
$keyBundleErrors .=' ## could not delete OPk key retrieved user '.$peersBundle[$i][0]." err : ".$db->error;
}
$del_stmt->close();
}
}
}
$stmt->close();
// No errors: commit transition, release lock and build the message out of the key bundles extracted
if($keyBundleErrors === '') {
$db->commit();
$peersBundleBuffer = pack("C3n", X3DH_protocolVersion, MessageTypes::peerBundle, curveId, $peersCount);
for ($i = 0; $i < $peersCount; $i++) {
$deviceId = $peersBundle[$i][0];
$haveOPk = (count($peersBundle[$i]) > 5)?(KeyBundleFlag::OPk):(KeyBundleFlag::noOPk);
$peerBundle = pack("n", strlen($deviceId)); // device Id size on 2 bytes in Big Endian
if (count($peersBundle[$i]) == 1) { // no key bundle
$flag = keyBundleFlag::noBundle;
$peerBundle .= $deviceId . pack("C", $flag);
} else { // have key bundle
$flag = $haveOPk ? keyBundleFlag::OPk : keyBundleFlag::noOPk;
$SPk_id = pack("N", $peersBundle[$i][3]); // SPk id on 4 bytes in Big Endian
$peerBundle .= $deviceId . pack("C", $flag) . $peersBundle[$i][1] . $peersBundle[$i][2] . $SPk_id . $peersBundle[$i][4];
if ($haveOPk) {
$OPk_id = pack("N", $peersBundle[$i][6]); // OPk id on 4 bytes in Big Endian
$peerBundle .= $peersBundle[$i][5] . $OPk_id;
}
}
$peersBundleBuffer .= $peerBundle;
}
returnOk($peersBundleBuffer);
} else { // something went wrong
$db->rollback();
$db->query("UNLOCK TABLES");
returnError(ErrorCodes::db_error, "Error while trying to get peers bundles :".$keyBundleErrors);
}
break;
/* selfOPks : OPKs Id Count < 2 bytes unsigned Big Endian> |
* (OPk id <4 bytes>){ OPks Id Count}
*/
case MessageTypes::getSelfOPks:
x3dh_log(LogLevel::MESSAGE, "Process a getSelfOPks Message from ".$userId);
$stmt = $db->prepare("SELECT Uid FROM Users WHERE UserId = ? LIMIT 1");
$stmt->bind_param("s", $userId);
$stmt->execute();
$stmt->get_result();
if ($stmt->num_rows === 0) {
// If no users found
$stmt->close();
returnError(ErrorCodes::user_not_found, "Get Self OPks but ".$userId." not found in db");
}
// Get OPk ID for user
$stmt = $db->prepare("SELECT o.OPk_id as OPk_id FROM Users as u INNER JOIN OPk as o ON u.Uid = o.Uid WHERE UserId = ?");
$stmt->bind_param("s", $userId);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
$selfOPKsBuffer = pack("C3n", X3DH_protocolVersion, MessageTypes::selfOPks, curveId, $result->num_rows);
while ($row = $result->fetch_assoc()) {
$selfOPKsBuffer .= pack("N", $row['OPk_id']); // 'N':4 bytes big endian
}
returnOk($selfOPKsBuffer);
break;
default:
returnError(ErrorCodes::unknown_error_code, "Unknown message type ".$X3DH_header['messageType']);
break;
}
$db->close();
}
?>