This guide helps developers implement PulseProof Sentinel Protocol, abbreviated PPS, version 1.
The normative specification is SPEC.md. This document is informative and implementation-focused.
The mandatory PPS profile is:
PPS-ED25519-CBOR30
It requires:
| Component | Value |
|---|---|
| Signature algorithm | Ed25519 |
| Hash algorithm | SHA-256 |
| KDF | HKDF-SHA256 |
| Canonical encoding | Deterministic CBOR |
| Text encoding | base64url without padding |
| Default epoch length | 30 seconds |
Do not implement low-level cryptography manually.
ext-sodium
paragonie/sodium_compat
spomky-labs/cbor-php
ext-gmp
@noble/ed25519
@noble/hashes
cbor-x
For browser environments, Web Crypto may be used where appropriate, but Ed25519 support varies.
cryptography
cbor2
crypto/ed25519
fxamacker/cbor
ed25519-dalek
sha2
hkdf
ciborium
PPS uses deterministic CBOR.
Implementations MUST:
Example logical payload:
{
"1": 1,
"2": 2,
"3": "PPS-ED25519-CBOR30",
"5": "base64url_rp_hash",
"6": 58972480,
"7": 17,
"8": 58972482
}
The actual signed structure is binary CBOR, not JSON.
Use base64url without padding.
Example:
binary -> base64url_no_pad -> textual token
Do not use standard base64 with +, /, or = padding.
The RP identifier is a canonical string.
For web services:
example.com
login.example.com
example.com:8443
Rules:
For apps:
android:com.example.app
ios:com.example.app
The RP hash is:
rp_hash = SHA256(utf8(rp_id))
A kid is an opaque byte string.
Recommended length:
16 to 64 bytes
Use pairwise kid values per RP to reduce correlation.
Default epoch length:
30 seconds
Compute:
epoch = floor(unix_time / 30)
Recommended expiration:
exp = epoch + 2
Verifier skew recommendation:
current_epoch - 1 <= pulse_epoch <= current_epoch + 1
seed = CSPRNG(32 bytes)
keypair = Ed25519_keypair_from_seed(seed)
pk = public key
sk = private key
Store sk securely.
Never transmit sk.
Registration payload fields:
1: version = 1
2: type = 1
3: alg
4: kid
5: rp_hash
6: pk
7: user_handle
8: nonce
9: created_at
10: device_name, optional
11: attestation, optional
12: duress_pk, optional
message = cbor(registration_payload)
signature = Ed25519_sign(sk, message)
If duress_pk is present:
duress_signature = Ed25519_sign(duress_sk, message)
Server MUST verify:
version == 1.type == 1.alg is allowed.rp_hash matches expected RP.pk.duress_pk present.Initial state:
key_seq = 0
last_ctr = 0
rp_id
current private key
current public key
kid
nonce, optional but recommended
context object, optional
policy object, optional
amount_minor, optional
duress flag, optional
Common fields:
1: version = 1
2: type = 2
3: alg
5: rp_hash
6: epoch
7: ctr
8: exp
11: nonce, optional
12: ctx, optional
13: pol, optional
14: amt, optional
Single-device ratchet fields:
4: kid
9: pk
10: next_pk
15: rot_seq
Threshold mode fields:
20: gid
21: thr
Do not include both single-device ratchet fields and threshold fields in the same Pulse.
For single-device mode:
K_t.K_{t+1}.ctr.rot_seq = stored_key_seq + 1.pk = K_t.pknext_pk = K_{t+1}.pkK_t.sk.K_t.sk and store K_{t+1}.sk.Server verifies:
signature by pk
rot_seq == working_key_seq + 1
ctr > last_ctr
Then updates:
current_pk = next_pk
key_seq = rot_seq
last_ctr = ctr
Rotation certificates are optional and useful for offline catch-up.
A rotation certificate authorizes:
from_pk -> to_pk
Rotation payload:
1: version = 1
2: type = 3
3: from_pk
4: to_pk
5: seq
6: epoch
7: exp
8: rp_hash, optional
9: nonce, optional
Signature:
signature = Ed25519_sign(from_sk, cbor(rotation_payload))
Server processing:
working_pk = stored current_pk
working_seq = stored key_seq
for each certificate:
verify from_pk == working_pk
verify seq == working_seq + 1
verify signature
working_pk = to_pk
working_seq = seq
then require pulse.pk == working_pk
Context object fields:
1: session_id
2: tx_hash
3: ble_hash
4: acoustic_hash
5: ip_hash
6: ua_hash
7: device_integrity_hash
8: custom_hash
All values are byte strings.
Compute:
ctx = SHA256(cbor(context_object))
If no context is used, omit ctx.
Policy object fields:
1: min_trust_digits
2: require_biometric
3: require_secure_enclave
4: threshold
5: max_amount_minor
6: require_proximity
7: max_clock_skew
8: allow_offline
9: require_amount_mark
10: require_duress_support
Compute:
pol = SHA256(cbor(policy_object))
If no policy is used, omit pol.
The server MUST compare the expected policy hash with the Pulse pol field.
If an amount is known:
amount_mark = amount_minor mod 100
Represent as two decimal digits:
00 to 99
Example:
amount_minor = 2500067
amount_mark = 67
The Pulse amt field stores the integer value 67.
The Trust Code includes the same two digits at the end.
For single-device mode:
trust_ikm = signature
For threshold mode:
trust_ikm = SHA256(cbor(signatures_map))
trust_salt = SHA256(
utf8(rp_id)
|| 0x00
|| nonce_or_empty
|| 0x00
|| uint64_be(epoch)
)
If nonce is absent, use empty bytes.
trust_okm = HKDF-SHA256(
ikm = trust_ikm,
salt = trust_salt,
info = utf8("PPS/trust/v1"),
length = 32
)
Default total digits:
8
If AmountMark is present:
total_digits = base_digits + 2
base_digits >= 6
Use unbiased rejection sampling:
DecimalFromOKM(okm, digits):
modulus = 10 ^ digits
limit = 2^64 - (2^64 mod modulus)
for i from 0 to 255:
block = SHA256(
okm
|| 0x00
|| uint8(i)
|| utf8("PPS/decimal")
)
x = uint64_be(block[0:8])
if x < limit:
value = x mod modulus
return zero_padded_decimal(value, digits)
fail
Final Trust Code:
trust_code = base_code || amount_mark
If no AmountMark:
trust_code = base_code
Server MUST derive expected Trust Code only after signature verification.
Comparison MUST be constant-time.
PPS version 1 threshold mode uses n-of-m Ed25519 multisignature.
Server stores:
gid
rp_hash
signers: map[kid => pk]
threshold
last_ctr
revoked
Threshold payload includes:
20: gid
21: thr
Threshold payload omits:
4: kid
9: pk
10: next_pk
15: rot_seq
Each signer signs the same Pulse message:
message = cbor(pulse_payload)
signature_i = Ed25519_sign(sk_i, message)
Envelope signature map:
signatures = {
kid_1: signature_1,
kid_2: signature_2
}
Server MUST:
gid.thr == stored_threshold.thr distinct signatures.ctr > group_last_ctr.group_last_ctr.Register a separate duress public key:
duress_pk
The duress private key should be hidden and protected separately.
If duress is triggered:
sign with duress_sk
Do not include an explicit duress flag in the payload.
Server detects duress by matching the signing public key against the stored duress public key.
Server MUST NOT reveal duress detection.
Outward response should match normal success.
Internal actions may include:
Recommended verification order:
1. Parse token.
2. Check version and type.
3. Check algorithm.
4. Check RP hash.
5. Check epoch and expiration.
6. Check nonce.
7. Check context hash.
8. Check policy hash.
9. Check AmountMark.
10. Process rotation certificates, if present.
11. Verify signatures.
12. Verify counters.
13. Verify rotation sequence.
14. Verify Trust Code, if entered.
15. Update state atomically.
16. Return outward response.
Use stable internal error codes:
invalid_token
bad_version
bad_type
bad_alg
bad_rp
bad_epoch
expired
bad_nonce
bad_context
bad_policy
bad_amount
unknown_key
bad_certificate
bad_rotation_sequence
counter_replay
bad_signature
bad_trust_code
revoked_device
revoked_group
threshold_not_met
rate_limited
internal_error
Return generic client error:
authentication_failed
Log detailed errors server-side.
Avoid these mistakes:
Implementations should test:
Before production use: