pulseproof-sentinel

PPS Implementers Guide

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.


1. Mandatory Profile

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.

PHP

ext-sodium
paragonie/sodium_compat
spomky-labs/cbor-php
ext-gmp

JavaScript / TypeScript

@noble/ed25519
@noble/hashes
cbor-x

For browser environments, Web Crypto may be used where appropriate, but Ed25519 support varies.

Python

cryptography
cbor2

Go

crypto/ed25519
fxamacker/cbor

Rust

ed25519-dalek
sha2
hkdf
ciborium

3. Canonical CBOR Rules

PPS uses deterministic CBOR.

Implementations MUST:

  1. Sort map keys in ascending numeric order.
  2. Use definite-length encoding.
  3. Use the shortest possible integer encoding.
  4. Reject duplicate map keys.
  5. Reject indefinite-length items.
  6. Reject unexpected data types.
  7. Omit optional absent fields.
  8. Avoid floating-point values in signed structures.
  9. Avoid CBOR tags unless explicitly defined.

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.


4. Base64url Encoding

Use base64url without padding.

Example:

binary -> base64url_no_pad -> textual token

Do not use standard base64 with +, /, or = padding.


5. Identifiers

5.1. RP Identifier

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))

5.2. Key Identifier

A kid is an opaque byte string.

Recommended length:

16 to 64 bytes

Use pairwise kid values per RP to reduce correlation.


6. Time and Epoch

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

7. Registration Implementation

7.1. Generate Key Pair

seed = CSPRNG(32 bytes)
keypair = Ed25519_keypair_from_seed(seed)
pk = public key
sk = private key

Store sk securely.

Never transmit sk.

7.2. Build Registration Payload

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

7.3. Sign Registration

message = cbor(registration_payload)
signature = Ed25519_sign(sk, message)

If duress_pk is present:

duress_signature = Ed25519_sign(duress_sk, message)

7.4. Server Verification

Server MUST verify:

  1. CBOR parses correctly.
  2. version == 1.
  3. type == 1.
  4. alg is allowed.
  5. rp_hash matches expected RP.
  6. nonce is valid and unused.
  7. signature verifies with pk.
  8. duress signature verifies if duress_pk present.
  9. store public keys and initial state.

Initial state:

key_seq = 0
last_ctr = 0

8. Pulse Generation

8.1. Inputs

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

8.2. Build Pulse Payload

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.


9. Single-Device Ratchet Pulse

For single-device mode:

  1. Load current key pair K_t.
  2. Generate next key pair K_{t+1}.
  3. Increment operation counter ctr.
  4. Set rot_seq = stored_key_seq + 1.
  5. Build payload with:
    • pk = K_t.pk
    • next_pk = K_{t+1}.pk
  6. Sign payload with K_t.sk.
  7. Transmit Pulse.
  8. After successful delivery or local policy, delete 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

10. Rotation Certificates

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

11. Context Binding

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.


12. Policy Binding

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.


13. AmountMark

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.


14. Trust Code Derivation

14.1. Trust Input

For single-device mode:

trust_ikm = signature

For threshold mode:

trust_ikm = SHA256(cbor(signatures_map))

14.2. Salt

trust_salt = SHA256(
  utf8(rp_id)
  || 0x00
  || nonce_or_empty
  || 0x00
  || uint64_be(epoch)
)

If nonce is absent, use empty bytes.

14.3. OKM

trust_okm = HKDF-SHA256(
  ikm = trust_ikm,
  salt = trust_salt,
  info = utf8("PPS/trust/v1"),
  length = 32
)

14.4. Decimal Code

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

14.5. Verification

Server MUST derive expected Trust Code only after signature verification.

Comparison MUST be constant-time.


15. Threshold Mode Implementation

PPS version 1 threshold mode uses n-of-m Ed25519 multisignature.

15.1. Group Registration

Server stores:

gid
rp_hash
signers: map[kid => pk]
threshold
last_ctr
revoked

15.2. Pulse Construction

Threshold payload includes:

20: gid
21: thr

Threshold payload omits:

4: kid
9: pk
10: next_pk
15: rot_seq

15.3. Signing

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
}

15.4. Verification

Server MUST:

  1. Load group by gid.
  2. Reject if revoked.
  3. Require thr == stored_threshold.
  4. Require at least thr distinct signatures.
  5. Verify each signature with registered signer public key.
  6. Verify all signatures are over the same message.
  7. Verify ctr > group_last_ctr.
  8. Update group_last_ctr.

16. Duress Implementation

16.1. Duress Key Registration

Register a separate duress public key:

duress_pk

The duress private key should be hidden and protected separately.

16.2. Duress Pulse

If duress is triggered:

sign with duress_sk

Do not include an explicit duress flag in the payload.

16.3. Server Detection

Server detects duress by matching the signing public key against the stored duress public key.

16.4. Server Response

Server MUST NOT reveal duress detection.

Outward response should match normal success.

Internal actions may include:


17. Verification Order

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.

18. Error Handling

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.


19. Common Implementation Mistakes

Avoid these mistakes:

  1. Using standard base64 instead of base64url.
  2. Leaving padding in base64url.
  3. Using JSON instead of deterministic CBOR for signed structures.
  4. Sorting map keys incorrectly.
  5. Accepting non-canonical CBOR.
  6. Updating counters before signature verification.
  7. Reusing nonces.
  8. Accepting large clock skew.
  9. Using modulo without bias rejection for Trust Code.
  10. Returning different responses for duress.
  11. Trusting client-provided threshold value.
  12. Including threshold and single-device fields in the same payload.
  13. Forgetting to bind transaction hash.
  14. Using Trust Code as the only proof for high-risk operations.
  15. Implementing Ed25519 manually.

20. Testing Checklist

Implementations should test:


21. Production Readiness

Before production use: