PHP implementation of the PulseProof Sentinel Protocol (PPS)
PPS-PHP is a complete PHP implementation of the PulseProof Sentinel Protocol (PPS), an experimental authentication protocol that replaces shared-secret TOTP-style codes with signed, time-bound, asymmetric proofs called Pulses.
PPS is designed to complement, not replace, WebAuthn/FIDO2. It targets operational gaps where WebAuthn is unavailable or insufficient:
ext-sodium for Ed25519ext-gmp for unbiased decimal derivation| Requirement | Version |
|---|---|
| PHP | 8.1 or higher |
| ext-sodium | required |
| ext-gmp | required |
| ext-hash | required |
| ext-json | required |
| ext-mbstring | recommended |
| Extension | Purpose |
|---|---|
| ext-pdo | Database storage backend |
| ext-redis | Redis storage backend |
composer require pps-protocol/pps-php
git clone https://github.com/pps-protocol/pps-php.git
cd pps-php
composer install
<?php
require_once 'vendor/autoload.php';
use Pps\Crypto\Ed25519;
$keyPair = Ed25519::generateKeyPair();
echo "PPS-PHP installed successfully.\n";
echo "Public key: " . \Pps\Crypto\Base64Url::encode($keyPair->publicKey) . "\n";
<?php
require_once 'vendor/autoload.php';
use Pps\Client\RegistrationBuilder;
use Pps\Server\RegistrationHandler;
use Pps\Storage\InMemoryStorage;
// Server side: create registration challenge
$storage = new InMemoryStorage();
$handler = new RegistrationHandler($storage);
$challenge = $handler->createChallenge('example.com');
// Client side: generate keys and sign registration
$builder = new RegistrationBuilder();
$registration = $builder
->rpId('example.com')
->nonce($challenge->nonce)
->userHandle('user-123')
->deviceName('My Phone')
->build();
// Server side: verify and store
$result = $handler->verify($registration, 'example.com', $challenge->nonce);
if ($result->ok) {
echo "Registration successful.\n";
echo "Device KID: " . $result->kid . "\n";
}
<?php
use Pps\Client\AuthenticatorClient;
use Pps\Server\AuthenticationHandler;
use Pps\Server\ChallengeGenerator;
// Server side: create authentication challenge
$challengeGen = new ChallengeGenerator();
$challenge = $challengeGen->create('example.com');
// Client side: create Pulse
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->nonce($challenge->nonce)
->createPulse();
echo "Trust Code: " . $pulse->trustCode . "\n";
echo "Pulse Token: " . $pulse->token . "\n";
// Server side: verify Pulse
$authHandler = new AuthenticationHandler($storage);
$result = $authHandler->verify($pulse->token, 'example.com', $challenge->nonce);
if ($result->ok) {
echo "Authentication successful.\n";
}
<?php
use Pps\Client\AuthenticatorClient;
use Pps\Payload\TransactionObject;
use Pps\Payload\ContextObject;
// Create transaction
$transaction = new TransactionObject(
action: 'withdraw',
amountMinor: 2500067,
currency: 'IRR',
recipient: 'IR820540102680020817909002'
);
// Create context with transaction hash
$context = new ContextObject();
$context->txHash = $transaction->hash();
$context->sessionId = $sessionId;
// Client signs transaction
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->nonce($nonce)
->context($context)
->amountMinor(2500067)
->createPulse();
// Trust Code includes AmountMark
echo "Trust Code: " . $pulse->trustCode . "\n";
// Output: 49371867
// Last two digits (67) match amount modulo 100
<?php
use Pps\Client\AuthenticatorClient;
use Pps\Server\AuthenticationHandler;
use Pps\Server\DuressHandler;
// Client authenticates under duress
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->nonce($nonce)
->duress(true) // Use hidden duress key
->createPulse();
// Server verifies — response looks normal
$authHandler = new AuthenticationHandler($storage);
$result = $authHandler->verify($pulse->token, 'example.com', $nonce);
// Outward response is identical to normal success
echo json_encode(['status' => 'ok']);
// Internal handling
if ($result->honey) {
$duressHandler = new DuressHandler($storage);
$duressHandler->handleSilentAlert($result);
// - Restrict account
// - Block high-value operations
// - Trigger silent alert
// - Delay settlement
}
<?php
use Pps\Client\AuthenticatorClient;
use Pps\Mode\ThresholdMode;
use Pps\Server\AuthenticationHandler;
// Device 1 (phone) creates payload
$phone = new AuthenticatorClient($phoneState);
$partial = $phone
->rpId('example.com')
->nonce($nonce)
->amountMinor(5000000)
->thresholdGroup($groupId, 2)
->createPartialPulse();
// Device 2 (watch) co-signs
$watch = new AuthenticatorClient($watchState);
$coSigned = $watch
->coSign($partial)
->createPulse();
// Server verifies threshold
$authHandler = new AuthenticationHandler($storage);
$result = $authHandler->verifyThreshold($coSigned->token, 'example.com', $nonce);
if ($result->ok && $result->thresholdMet) {
echo "Threshold approval successful.\n";
}
<?php
use Pps\Payload\PolicyObject;
use Pps\Client\AuthenticatorClient;
// Server defines policy
$policy = new PolicyObject();
$policy->minTrustDigits = 8;
$policy->requireBiometric = true;
$policy->requireSecureEnclave = true;
$policy->threshold = 2;
$policy->maxAmountMinor = 10000000;
$policy->requireProximity = true;
$policy->requireAmountMark = true;
// Client binds policy to Pulse
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->nonce($nonce)
->policy($policy)
->amountMinor(2500067)
->createPulse();
// Server verifies policy hash matches expected
$result = $authHandler->verify(
$pulse->token,
'example.com',
$nonce,
expectedPolicy: $policy
);
<?php
use Pps\Payload\ContextObject;
use Pps\Client\AuthenticatorClient;
// Create context with environmental data
$context = new ContextObject();
$context->sessionId = $sessionId;
$context->txHash = $txHash;
$context->bleHash = hash('sha256', $bleBeaconPayload, true);
$context->acousticHash = hash('sha256', $acousticNonce, true);
$context->deviceIntegrityHash = hash('sha256', $integrityData, true);
// Client binds context to Pulse
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->nonce($nonce)
->context($context)
->createPulse();
// Server verifies context hash
$result = $authHandler->verify(
$pulse->token,
'example.com',
$nonce,
expectedContext: $context
);
<?php
use Pps\Client\AuthenticatorClient;
use Pps\Mode\OfflineMode;
// Client creates offline Pulse (no nonce)
$client = new AuthenticatorClient($clientState);
$pulse = $client
->rpId('example.com')
->offline(true) // No nonce required
->createPulse();
// Server verifies with stricter rate limiting
$authHandler = new AuthenticationHandler($storage);
$result = $authHandler->verifyOffline($pulse->token, 'example.com');
if ($result->ok) {
echo "Offline authentication successful.\n";
}
| Mode | Nonce | Network | Use Case |
|---|---|---|---|
| Single-Device Online | Required | Required | Standard login |
| Single-Device Offline | Optional | Not required | Terminals, IoT |
| Threshold | Required | Optional | High-value transactions |
| Duress | Required | Required | Coercion scenarios |
| Transaction Signing | Required | Required | Payments, withdrawals |
// Standard login
$pulse = $client->rpId('example.com')->nonce($nonce)->createPulse();
// High-value transaction with threshold
$pulse = $client
->rpId('example.com')
->nonce($nonce)
->amountMinor($amount)
->thresholdGroup($gid, 2)
->createPulse();
// Offline terminal
$pulse = $client->rpId('example.com')->offline(true)->createPulse();
// Duress
$pulse = $client->rpId('example.com')->nonce($nonce)->duress(true)->createPulse();
PPS-PHP supports multiple storage backends:
use Pps\Storage\InMemoryStorage;
$storage = new InMemoryStorage();
use Pps\Storage\FileStorage;
$storage = new FileStorage('/var/lib/pps');
use Pps\Storage\PdoStorage;
$pdo = new PDO('mysql:host=localhost;dbname=pps', 'user', 'pass');
$storage = new PdoStorage($pdo);
use Pps\Storage\RedisStorage;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$storage = new RedisStorage($redis);
// app/Http/Controllers/PpsAuthController.php
use Pps\Server\AuthenticationHandler;
use Pps\Server\ChallengeGenerator;
use Pps\Storage\PdoStorage;
class PpsAuthController extends Controller
{
public function challenge(ChallengeGenerator $generator)
{
$challenge = $generator->create('example.com');
return response()->json([
'nonce' => $challenge->nonceB64u,
'epoch' => $challenge->epoch,
'qr_payload' => $challenge->qrPayload,
]);
}
public function verify(Request $request, AuthenticationHandler $handler)
{
$result = $handler->verify(
$request->input('pulse_token'),
'example.com',
$request->input('nonce')
);
if ($result->honey) {
// Silent duress handling
return response()->json(['status' => 'ok']);
}
if (!$result->ok) {
return response()->json(['status' => 'error'], 401);
}
return response()->json(['status' => 'ok']);
}
}
use Pps\Server\AuthenticationHandler;
use Slim\App;
$app->post('/pps/challenge', function ($request, $response) {
$generator = new ChallengeGenerator();
$challenge = $generator->create('example.com');
$response->getBody()->write(json_encode([
'nonce' => $challenge->nonceB64u,
'epoch' => $challenge->epoch,
]));
return $response->withHeader('Content-Type', 'application/json');
});
$app->post('/pps/verify', function ($request, $response) {
$handler = new AuthenticationHandler($storage);
$body = $request->getParsedBody();
$result = $handler->verify(
$body['pulse_token'],
'example.com',
$body['nonce']
);
$response->getBody()->write(json_encode(['status' => $result->ok ? 'ok' : 'error']));
return $response->withHeader('Content-Type', 'application/json');
});
$client = new AuthenticatorClient(array $clientState);
$pulse = $client
->rpId(string $rpId)
->nonce(string $nonceB64u)
->context(ContextObject $context)
->policy(PolicyObject $policy)
->amountMinor(int $amount)
->duress(bool $duress)
->offline(bool $offline)
->thresholdGroup(string $gid, int $threshold)
->createPulse();
// Returns PulseResult with:
// - token: string (base64url Pulse Token)
// - trustCode: string (8-digit Trust Code)
// - trustPhrase: string (3-word Trust Phrase)
// - epoch: int
// - counter: int
// - newClientState: array
$handler = new AuthenticationHandler(StorageInterface $storage);
$result = $handler->verify(
string $pulseToken,
string $rpId,
?string $nonceB64u = null,
?int $amountMinor = null,
?ContextObject $expectedContext = null,
?PolicyObject $expectedPolicy = null
);
// Returns VerificationResult with:
// - ok: bool
// - mode: string ('normal' | 'duress')
// - honey: bool
// - thresholdMet: bool
// - error: ?string
// - updates: array
# Run all tests
composer test
# Run unit tests only
composer test:unit
# Run integration tests only
composer test:integration
# Run with coverage
composer test:coverage
PPS-PHP is experimental and has not been independently audited.
Do not use in production without:
See SECURITY.md for vulnerability reporting.
Apache License 2.0
See LICENSE for details.