Protocol Name: PulseProof Sentinel Protocol Short Name: PPS Version: 1.0.0-draft Status: Experimental / Draft Specification Intended Status: Informational / Experimental Last Updated: 2026-07-23
This document specifies the PulseProof Sentinel Protocol (PPS), an experimental, technology-agnostic protocol for time-bound asymmetric authentication proofs. PPS is not a new cryptographic primitive; it profiles established mechanisms — Ed25519 signatures, SHA-256 hashing, HKDF key derivation, and deterministic CBOR encoding — into a compact, interoperable verification model suited to offline, embedded, and non-browser environments where a full WebAuthn ceremony is unavailable.
A PPS authentication event produces a Pulse: a signed statement bound to a relying-party identifier, time epoch, monotonic counter, expiration, and optional nonce, context, policy, and transaction data. Because the verifier stores only public keys, PPS removes the shared-secret exposure problem inherent in TOTP.
PulseProof Sentinel extends the basic Pulse model with several optional but interoperable security extensions:
This specification defines the wire format, cryptographic profiles, verification rules, security model, and conformance requirements for PPS version 1.
The keywords MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
The Relying Party, abbreviated RP, is the service verifying authentication.
Examples:
example.com
login.example.com:8443
android:com.example.app
ios:com.example.app
urn:pps:service:internal:billing
The Authenticator is the user-controlled device or software module that holds private key material and generates Pulses.
Examples:
A Pulse is a signed authentication statement generated by an Authenticator.
A Pulse asserts possession of a private key bound to a specific RP, time window, counter, and optional session/context/policy/transaction data.
A Pulse Token is the serialized form of a Pulse envelope.
The canonical binary encoding is deterministic CBOR. The textual encoding is base64url without padding.
A Rotation Certificate is a signed statement authorizing transition from one public verification key to the next public verification key.
Rotation certificates enable asynchronous or offline forward-secure key ratcheting.
A Duress Key is a separate private key that can be used when the user is coerced.
A Pulse signed by a Duress Key is called a Honey-Pulse.
A Honey-Pulse is cryptographically valid but indicates that the user may be under duress.
A verifier MUST NOT reveal duress detection to the client in the outward authentication response.
A Threshold Group is a set of registered signer keys where a minimum number of signatures is required to authorize a Pulse.
A Trust Code is a short human-verifiable numeric value derived from the cryptographic proof.
The Trust Code is not the primary authentication secret.
An AmountMark is a two-digit transaction checksum embedded in a Trust Code.
PulseProof Sentinel is designed to provide:
PulseProof Sentinel does not define:
PPS may be combined with such systems but does not standardize them.
All multi-byte unsigned integers explicitly serialized by this specification use big-endian byte order when raw integer bytes are required.
CBOR integer encoding follows RFC 8949 [RFC8949].
Textual representations of binary values use base64url encoding without padding as defined in Section 5 of [RFC4648].
base64url(binary) = base64url-no-pad(binary)
All signed structures defined by this specification are encoded using deterministic CBOR.
Implementations MUST follow the deterministic encoding rules described in Section 4.2 of [RFC8949].
Additional requirements:
The mandatory hash algorithm is SHA-256 [FIPS180-4].
All hash outputs used in PPS fields are raw byte strings.
The mandatory key-derivation function is HKDF-SHA256 [RFC5869].
All random values MUST be generated using a cryptographically secure pseudorandom number generator suitable for key generation and nonce generation [RFC4086].
Nonces SHOULD be at least 128 bits and MAY be 256 bits.
PPS uses algorithm identifiers to allow cryptographic agility.
The mandatory-to-implement suite for PPS version 1 is:
PPS-ED25519-CBOR30
This suite specifies:
| Property | Value |
|---|---|
| Signature algorithm | Ed25519 [RFC8032] |
| Public key length | 32 octets |
| Signature length | 64 octets |
| Hash algorithm | SHA-256 |
| KDF | HKDF-SHA256 |
| Default epoch length | 30 seconds |
| Canonical encoding | deterministic CBOR |
| Text encoding | base64url without padding |
The following suites are reserved for future or experimental use:
PPS-MLDSA44-CBOR30
PPS-MLDSA65-CBOR30
PPS-HYB-ED25519-MLDSA44-CBOR30
These suites reference ML-DSA [FIPS204].
Support for optional suites is OPTIONAL.
A verifier MAY reject unknown or disallowed algorithms.
PPS uses Unix time, defined as the number of seconds since 1970-01-01T00:00:00Z, ignoring leap seconds.
The default epoch length is 30 seconds.
epoch = floor(unix_time / 30)
The epoch length is fixed by the algorithm identifier.
For PPS-ED25519-CBOR30, the epoch length is 30 seconds.
Verifiers SHOULD accept Pulses whose epoch is within one epoch of the verifier’s current epoch:
current_epoch - 1 <= pulse_epoch <= current_epoch + 1
Verifiers MAY permit a larger skew through local policy, but larger skew increases replay risk.
The exp field is an epoch number, not a Unix timestamp.
A Pulse MUST be rejected if:
current_epoch > exp
The RECOMMENDED default is:
exp = epoch + 2
The RP identifier, rp_id, is a canonical ASCII string.
For web origins:
rp_id = lowercase(host) [ ":" port ]
The RP identifier MUST NOT include:
Examples:
example.com
login.example.com
example.com:8443
For application origins:
android:com.example.app
ios:com.example.app
For internal services:
urn:pps:service:internal:billing
The RP hash stored in signed structures is:
rp_hash = SHA256(utf8(rp_id))
A key identifier, kid, is a byte string of length 16 to 64 octets.
It SHOULD be randomly generated and stable for a registered device.
It SHOULD NOT contain personally identifiable information.
A group identifier, gid, is a byte string of length 16 to 64 octets.
It identifies a threshold group.
A user handle is an opaque byte string of length 16 to 64 octets.
It SHOULD NOT contain personally identifiable information unless required by the deployment.
All structures in this section are CBOR maps with integer keys.
1 = registration
2 = pulse
3 = rotation
registration-payload = {
1: version,
2: type,
3: alg,
4: kid,
5: rp_hash,
6: pk,
7: user_handle,
8: nonce,
9: created_at,
? 10: device_name,
? 11: attestation,
? 12: duress_pk
}
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | version | uint | YES | MUST be 1 |
| 2 | type | uint | YES | MUST be 1 |
| 3 | alg | tstr | YES | algorithm identifier |
| 4 | kid | bstr | YES | device key identifier |
| 5 | rp_hash | bstr | YES | SHA256(rp_id), 32 octets |
| 6 | pk | bstr | YES | normal public key |
| 7 | user_handle | bstr | YES | opaque user identifier |
| 8 | nonce | bstr | YES | registration challenge |
| 9 | created_at | uint | YES | Unix timestamp |
| 10 | device_name | tstr | NO | human-readable device name |
| 11 | attestation | bstr | NO | platform attestation blob |
| 12 | duress_pk | bstr | NO | duress public key |
If duress_pk is present, the registration envelope MUST include a duress signature.
registration-envelope = {
1: message,
2: signature,
? 3: duress_signature
}
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | message | bstr | YES | CBOR-encoded registration-payload |
| 2 | signature | bstr | YES | signature by pk |
| 3 | duress_signature | bstr | CONDITIONAL | signature by duress_pk if duress_pk present |
The signature is computed over the raw bytes of message.
signature = Ed25519_sign(sk, message)
If duress_pk is present:
duress_signature = Ed25519_sign(duress_sk, message)
rotation-payload = {
1: version,
2: type,
3: from_pk,
4: to_pk,
5: seq,
6: epoch,
7: exp,
? 8: rp_hash,
? 9: nonce
}
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | version | uint | YES | MUST be 1 |
| 2 | type | uint | YES | MUST be 3 |
| 3 | from_pk | bstr | YES | current public key |
| 4 | to_pk | bstr | YES | next public key |
| 5 | seq | uint | YES | rotation sequence number |
| 6 | epoch | uint | YES | creation epoch |
| 7 | exp | uint | YES | expiration epoch |
| 8 | rp_hash | bstr | OPTIONAL | SHA256(rp_id) |
| 9 | nonce | bstr | OPTIONAL | server nonce |
rotation-certificate = {
1: message,
2: signature
}
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | message | bstr | YES | CBOR-encoded rotation-payload |
| 2 | signature | bstr | YES | signature by from_pk |
The signature is computed over the raw bytes of message.
signature = Ed25519_sign(from_sk, message)
pulse-payload = {
1: version,
2: type,
3: alg,
5: rp_hash,
6: epoch,
7: ctr,
8: exp,
? 11: nonce,
? 12: ctx,
? 13: pol,
? 14: amt,
(single-mode-fields // threshold-mode-fields)
}
Common fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | version | uint | YES | MUST be 1 |
| 2 | type | uint | YES | MUST be 2 |
| 3 | alg | tstr | YES | algorithm identifier |
| 5 | rp_hash | bstr | YES | SHA256(rp_id), 32 octets |
| 6 | epoch | uint | YES | creation epoch |
| 7 | ctr | uint | YES | operation counter |
| 8 | exp | uint | YES | expiration epoch |
| 11 | nonce | bstr | CONDITIONAL | server nonce |
| 12 | ctx | bstr | OPTIONAL | SHA256(context-object) |
| 13 | pol | bstr | OPTIONAL | SHA256(policy-object) |
| 14 | amt | uint | OPTIONAL | AmountMark, 0..99 |
single-mode-fields = (
4: kid,
9: pk,
10: next_pk,
15: rot_seq
)
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 4 | kid | bstr | YES | device key identifier |
| 9 | pk | bstr | YES | current signing public key |
| 10 | next_pk | bstr | YES | next public key after rotation |
| 15 | rot_seq | uint | YES | rotation sequence number |
In single-device ratchet mode:
pk MUST be present.next_pk MUST be present.rot_seq MUST be present.gid and thr MUST be absent.threshold-mode-fields = (
20: gid,
21: thr
)
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 20 | gid | bstr | YES | threshold group identifier |
| 21 | thr | uint | YES | required signature threshold |
In threshold mode:
gid MUST be present.thr MUST be present.kid, pk, next_pk, and rot_seq MUST be absent.pulse-envelope = {
1: message,
2: signatures,
? 3: certificates
}
Fields:
| Key | Name | Type | Required | Description |
|---|---|---|---|---|
| 1 | message | bstr | YES | CBOR-encoded pulse-payload |
| 2 | signatures | map | YES | map of signer kid to signature |
| 3 | certificates | array | OPTIONAL | rotation certificates |
The signature map is:
signatures = {
* bstr => bstr
}
The map key is the signer kid.
The map value is the signature over message.
In single-device ratchet mode, the signature map MUST contain exactly one entry whose key equals the Pulse payload kid.
In threshold mode, the signature map MUST contain at least thr valid signatures from registered group members.
The context object is OPTIONAL.
context-object = {
? 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.
The context hash included in a Pulse is:
ctx = SHA256(cbor(context-object))
If no context is used, the ctx field MUST be omitted.
The policy object is OPTIONAL.
policy-object = {
? 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
}
Types:
| Key | Name | Type |
|---|---|---|
| 1 | min_trust_digits | uint |
| 2 | require_biometric | bool |
| 3 | require_secure_enclave | bool |
| 4 | threshold | uint |
| 5 | max_amount_minor | uint |
| 6 | require_proximity | bool |
| 7 | max_clock_skew | uint |
| 8 | allow_offline | bool |
| 9 | require_amount_mark | bool |
| 10 | require_duress_support | bool |
The policy hash included in a Pulse is:
pol = SHA256(cbor(policy-object))
If no policy is used, the pol field MUST be omitted.
The binary Pulse Token is:
pulse_token_binary = cbor(pulse-envelope)
The textual Pulse Token is:
pulse_token = base64url(pulse_token_binary)
The RECOMMENDED media type is:
application/pulseproof+cbor
When transmitted in JSON, binary fields SHOULD be represented as base64url strings.
Registration binds a device key to a user account at an RP.
The RP SHOULD issue a fresh registration nonce.
The Authenticator constructs a registration payload and signs it.
If a duress key is registered, the Authenticator MUST also sign the same registration message with the duress private key.
The verifier MUST perform the following checks:
version == 1.type == 1.alg is allowed.rp_hash == SHA256(expected_rp_id).pk.duress_pk is present, verify duress_signature using duress_pk.For each registered device, the verifier SHOULD store:
kid
current_pk
key_seq = 0
last_ctr = 0
revoked = false
If duress is registered:
duress_pk
duress_key_seq = 0
duress_last_ctr = 0
PPS defines a practical asymmetric forward-secure ratchet using signed key rotation.
The Authenticator maintains a current signing key pair:
K_t = (sk_t, pk_t)
When generating a Pulse, the Authenticator generates a next key pair:
K_{t+1} = (sk_{t+1}, pk_{t+1})
The Pulse payload includes:
pk = pk_t
next_pk = pk_{t+1}
rot_seq = t + 1
The Pulse is signed with sk_t.
Because the signed payload includes next_pk, the signature authorizes rotation from pk_t to pk_{t+1}.
After creating the Pulse, the Authenticator SHOULD delete sk_t and store sk_{t+1}.
The verifier, after successful verification, updates:
current_pk = next_pk
key_seq = rot_seq
The rotation sequence is a monotonic unsigned 64-bit integer.
After registration:
key_seq = 0
Each rotation increments the sequence by one:
new_key_seq = old_key_seq + 1
A verifier MUST reject a Pulse whose rot_seq is not exactly one greater than the working key sequence after processing any rotation certificates.
Rotation certificates allow an Authenticator to rotate locally before the verifier has observed the previous Pulse.
A rotation certificate is signed by the old key and authorizes a new key.
rotation-payload = {
1: 1,
2: 3,
3: from_pk,
4: to_pk,
5: seq,
6: epoch,
7: exp,
? 8: rp_hash,
? 9: nonce
}
The certificate signature is made by from_sk.
A verifier processing certificates MUST:
working_pk to the stored current_pk.working_seq to the stored key_seq.version == 1type == 3from_pk == working_pkseq == working_seq + 1working_pkworking_pk = to_pkworking_seq = seqpulse.pk == working_pk.pulse.rot_seq == working_seq + 1.pulse.next_pk and pulse.rot_seq.An Authenticator claiming forward-secure operation MUST make a best-effort attempt to securely delete old private key material after rotation.
Secure hardware-backed storage is RECOMMENDED.
Verifiers cannot fully enforce secure deletion.
An Authenticator generating a Pulse requires:
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
The Authenticator MUST:
If the user activates duress mode, the Authenticator MUST sign using the duress private key instead of the normal private key.
The Pulse payload MUST NOT contain an explicit duress flag.
Duress detection is performed by the verifier based on the public key used.
The duress key MAY have its own forward-secure ratchet state.
In threshold mode:
gid and thr.message.Threshold mode in version 1 uses static registered signer keys unless a future extension defines per-signer ratcheting.
A verifier MUST perform validation in a strict order.
The verifier MUST:
message, signatures, and optional certificates.message as a Pulse payload.If parsing fails, the verifier MUST reject the token.
The verifier MUST check:
version == 1type == 2alg is allowedrp_hash == SHA256(expected_rp_id)expnonce matches and is unusedctx matches expected context hashpol matches expected policy hashamt matches expected amount modulo 100For single-device ratchet mode:
kid.pk to equal the working public key.pk.kid.ctr <= last_ctr.rot_seq != working_key_seq + 1.current_pk = next_pkkey_seq = rot_seqlast_ctr = ctrState updates MUST be atomic.
A verifier MAY maintain a separate duress key state for a device.
If the working public key matches the stored duress public key, the verifier MUST classify the Pulse as duress.
The verifier MUST NOT return an explicit duress indicator to the client.
The verifier MAY:
The verifier SHOULD apply the same response latency and response shape for duress and normal success.
For threshold mode:
gid.thr equals the stored group threshold.thr distinct signatures.message.ctr <= group_last_ctr.group_last_ctr = ctr.Threshold mode does not require pk, next_pk, or rot_seq.
The Trust Code is a short numeric value derived from the cryptographic proof.
It is intended for human comparison and optional typed fallback.
The Trust Code is not a replacement for the full Pulse Token.
For single-device mode:
trust_ikm = signature
where signature is the single signature over the Pulse message.
For threshold mode:
trust_ikm = SHA256(cbor(signatures))
where signatures is the complete signature map after collecting the required signatures.
trust_salt = SHA256(
utf8(rp_id)
|| 0x00
|| nonce_or_empty
|| 0x00
|| uint64_be(epoch)
)
If no nonce is present, nonce_or_empty is the empty byte string.
trust_okm = HKDF-SHA256(
ikm = trust_ikm,
salt = trust_salt,
info = utf8("PPS/trust/v1"),
length = 32
)
The default Trust Code length is 8 digits.
If an AmountMark is present, the total length MUST be at least 8 digits.
The base signature-derived portion MUST be at least 6 digits.
total_digits = base_digits + amount_digits
amount_digits = 2 if AmountMark is present, otherwise 0
base_digits >= 6
Implementations MUST avoid modulo bias when deriving decimal digits.
The following algorithm is normative for the mandatory suite:
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
Failure is cryptographically negligible.
If an amount is known, the AmountMark is:
amount_mark = amount_minor mod 100
represented as two decimal digits.
The final Trust Code is:
trust_code = base_code || amount_mark
Example:
base_code = 493718
amount_minor = 2500067
amount_mark = 67
trust_code = 49371867
A verifier MUST derive the expected Trust Code only after successful signature verification.
Trust Code comparison MUST be constant-time.
Trust Phrase support is OPTIONAL.
If implemented, the RECOMMENDED construction is:
phrase_index_0 = uint16_be(trust_okm[8:10]) mod 2048
phrase_index_1 = uint16_be(trust_okm[10:12]) mod 2048
phrase_index_2 = uint16_be(trust_okm[12:14]) mod 2048
Each index selects a word from the BIP39 English wordlist.
The Trust Phrase is:
word_0 + " " + word_1 + " " + word_2
Example:
river bronze lantern
The Trust Phrase is intended for visual comparison and SHOULD NOT be used as the sole authentication factor.
Context binding allows a Pulse to be bound to environmental or session data.
The context object is encoded using deterministic CBOR and hashed:
ctx = SHA256(cbor(context-object))
| Field | Meaning |
|---|---|
| session_id | server session identifier |
| tx_hash | transaction hash |
| ble_hash | BLE proximity beacon hash |
| acoustic_hash | acoustic nonce hash |
| ip_hash | privacy-preserving IP hash |
| ua_hash | user agent hash |
| device_integrity_hash | device integrity measurement |
| custom_hash | application-specific hash |
An RP web client may broadcast a temporary BLE beacon:
ble_beacon = random_nonce || timestamp
The Authenticator receives the beacon and includes:
ble_hash = SHA256(ble_beacon)
The verifier compares the expected hash.
This does not prove physical presence with mathematical certainty, but increases phishing and relay cost.
Policy binding ensures that the Pulse was generated under the security policy expected by the RP.
pol = SHA256(cbor(policy-object))
If the RP issued a policy, the verifier MUST compare the Pulse pol field with the expected policy hash.
If the values differ, the Pulse MUST be rejected.
Verifiers SHOULD NOT accept Pulses with weaker policy hashes than required for the requested operation.
For high-value transactions, the policy SHOULD include:
require_amount_mark = true
require_biometric = true
require_secure_enclave = true
threshold >= 2, if applicable
max_amount_minor = expected maximum
PPS does not mandate a transaction format.
However, when transaction binding is used, the RP SHOULD define a canonical transaction object and compute:
tx_hash = SHA256(cbor(transaction-object))
The tx_hash is included in the context object.
If AmountMark is used, the transaction amount modulo 100 is embedded in the Trust Code.
The verifier MUST verify both:
ctx matches expected context including tx_hashamt matches expected amount modulo 100, if presentIn online mode, the verifier SHOULD issue a fresh nonce for each authentication attempt.
The nonce MUST be:
The RECOMMENDED nonce lifetime is 60 seconds.
In offline mode, the nonce MAY be omitted.
Offline Pulses MUST be protected by:
Verifiers SHOULD treat offline Pulses as lower assurance unless additional context is present.
The operation counter ctr is a monotonic unsigned 64-bit integer.
A verifier MUST reject any Pulse where:
ctr <= stored_last_ctr
The counter MUST be updated only after successful cryptographic verification.
Implementations SHOULD use stable internal error codes.
| Error | Meaning |
|---|---|
| invalid_token | parsing failure |
| bad_version | unsupported version |
| bad_type | incorrect message type |
| bad_alg | algorithm not allowed |
| bad_rp | RP hash mismatch |
| bad_epoch | epoch outside allowed window |
| expired | expiration passed |
| bad_nonce | nonce missing, invalid, or reused |
| bad_context | context hash mismatch |
| bad_policy | policy hash mismatch |
| bad_amount | AmountMark mismatch |
| unknown_key | public key not recognized |
| bad_certificate | rotation certificate invalid |
| bad_rotation_sequence | rotation sequence mismatch |
| counter_replay | counter not increasing |
| bad_signature | signature verification failed |
| bad_trust_code | Trust Code mismatch |
| revoked_device | device key revoked |
| revoked_group | threshold group revoked |
| threshold_not_met | insufficient valid signatures |
| rate_limited | too many attempts |
| internal_error | server error |
Verifiers SHOULD return a generic error to unauthenticated clients:
authentication_failed
Detailed errors MAY be logged server-side.
PPS defines the following URI scheme for challenge delivery:
pulseproof://
pulseproof://challenge?
v=1
&rp=example.com
&nonce=base64url
&epoch=uint
&session=base64url
&tx=base64url
&policy=base64url
Parameters:
| Parameter | Required | Description |
|---|---|---|
| v | YES | protocol version, MUST be 1 |
| rp | YES | RP identifier |
| nonce | RECOMMENDED | base64url nonce |
| epoch | OPTIONAL | server time hint |
| session | OPTIONAL | base64url session identifier |
| tx | OPTIONAL | base64url transaction hash |
| policy | OPTIONAL | base64url CBOR policy object |
All base64url values MUST omit padding.
HTTP binding is OPTIONAL.
When used, the following JSON structure is RECOMMENDED.
{
"v": 1,
"rp": "example.com",
"nonce": "base64url",
"epoch": 58972480,
"session_id": "base64url",
"tx_hash": "base64url",
"policy": "base64url"
}
{
"session_id": "base64url",
"pulse_token": "base64url",
"trust_code": "49371867"
}
For normal success:
{
"status": "ok"
}
For duress success, the outward response SHOULD be indistinguishable:
{
"status": "ok"
}
The verifier MAY include a Trust Phrase after successful verification if the UI requires mutual visual confirmation.
A conformant PPS Core Authenticator MUST:
PPS-ED25519-CBOR30.A conformant PPS Core Authenticator SHOULD:
A conformant PPS Core Verifier MUST:
PPS-ED25519-CBOR30.A conformant PPS Core Verifier SHOULD:
An implementation claiming PPS Forward-Secure support MUST:
next_pk and rot_seq in single-device Pulses.An implementation claiming PPS Duress support MUST:
An implementation claiming PPS Threshold support MUST:
PPS removes shared symmetric secrets from the verifier.
A verifier database breach exposes public keys and metadata, not private keys.
This is a significant improvement over TOTP shared secrets.
PPS improves phishing resistance when used with nonce-bound challenges and full Pulse Token transmission.
A Pulse is bound to:
Manual Trust Code entry alone provides weaker protection.
A numeric Trust Code has limited entropy.
Approximate entropy:
6 digits = 19.9 bits
8 digits = 26.6 bits
10 digits = 33.2 bits
12 digits = 39.9 bits
Therefore, Trust Code-only fallback MUST be protected by strict rate limiting.
For high-security operations, the full Pulse Token SHOULD be used.
Replay protection is provided by:
Verifiers MUST mark nonces as used.
Verifiers MUST update counters and key states atomically.
The PPS forward-secure ratchet depends on secure deletion of old private keys.
If an attacker continuously controls the Authenticator, forward secrecy may not protect future operations.
Secure hardware and user-presence checks are strongly recommended.
Rotation certificates are signed authorizations to advance a key state.
If an attacker obtains a valid rotation certificate before the verifier does, they may cause the verifier to advance key state.
This can cause denial-of-service or state desynchronization but does not by itself reveal private keys.
Mitigations:
Duress detection MUST remain hidden from the client response.
Verifiers SHOULD equalize:
Silent alerts SHOULD respect user safety and legal requirements.
Threshold mode increases resilience against single-device compromise.
However, if all threshold devices are compromised or coerced, threshold protection fails.
Offline threshold communication channels such as BLE, NFC, or QR should be protected against relay when possible.
If malware can trick the user into approving a malicious transaction, PPS cannot fully prevent fraud.
Mitigations:
Large clock-skew windows increase replay risk.
Verifiers SHOULD use NTP or another trusted time source.
Authenticators SHOULD synchronize time when possible.
Implementations MUST reject non-canonical CBOR.
CBOR parsers should be tested against malformed input, duplicate keys, indefinite lengths, and oversized structures.
Ed25519 is not believed to be secure against large-scale quantum computers.
Deployments requiring long-term quantum resistance SHOULD plan migration to ML-DSA or hybrid suites.
PPS fields SHOULD avoid personally identifiable information.
Recommended:
user_handle = opaque random identifier
kid = opaque random identifier
gid = opaque random identifier
Authenticators SHOULD use pairwise kid values per RP to reduce cross-service correlation.
Context fields such as IP address or user agent SHOULD be hashed or salted if stored.
Example:
ip_hash = SHA256(ip || daily_salt)
Daily salts reduce long-term correlation.
Duress events SHOULD be accessible only to authorized security personnel.
Audit logs SHOULD be protected and access-controlled.
This document makes the following requests.
This document requests registration of the following media type:
application/pulseproof+cbor
Purpose:
Binary encoding of PulseProof Sentinel envelopes.
This document requests registration of the following URI scheme:
pulseproof
Purpose:
PulseProof Sentinel challenge and authentication handoff.
This document defines a conceptual PulseProof Sentinel Algorithm Registry.
Initial values:
PPS-ED25519-CBOR30
PPS-MLDSA44-CBOR30
PPS-MLDSA65-CBOR30
PPS-HYB-ED25519-MLDSA44-CBOR30
Future updates SHOULD specify:
[RFC2119]
Bradner, S., “Key words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, March 1997.
[RFC4086]
Eastlake 3rd, D., Schiller, J., and S. Crocker, “Randomness Requirements for Security”, BCP 106, RFC 4086, June 2005.
[RFC4648]
Josefsson, S., “The Base16, Base32, and Base64 Data Encodings”, RFC 4648, October 2006.
[RFC5869]
Krawczyk, H. and P. Eronen, “HMAC-based Extract-and-Expand Key Derivation Function (HKDF)”, RFC 5869, May 2010.
[RFC8032]
Josefsson, S. and I. Liusvaara, “Edwards-Curve Digital Signature Algorithm (EdDSA)”, RFC 8032, January 2017.
[RFC8174]
Leiba, B., “Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words”, BCP 14, RFC 8174, May 2017.
[RFC8949]
Bormann, C. and P. Hoffman, “Concise Binary Object Representation (CBOR)”, STD 94, RFC 8949, December 2020.
[FIPS180-4]
National Institute of Standards and Technology, “Secure Hash Standard (SHS)”, FIPS PUB 180-4.
[FIPS204]
National Institute of Standards and Technology, “Module-Lattice-Based Digital Signature Standard”, FIPS PUB 204.
[RFC8610]
Birkholz, H., Vigano, C., and C. Bormann, “Concise Data Definition Language (CDDL): A Notational Convention to Express Concise Binary Object Representation (CBOR) and JSON Data Structures”, RFC 8610, June 2019.
[WebAuthn]
W3C, “Web Authentication: An API for accessing Public Key Credentials”.
[FIDO2]
FIDO Alliance, “FIDO2: WebAuthn and CTAP”.
[RFC6238]
M’Raihi, D., Machani, S., Pei, M., and J. Rydell, “TOTP: Time-Based One-Time Password Algorithm”, RFC 6238, May 2011.
[BIP39]
Bitcoin Improvement Proposal 39, “Mnemonic code for generating deterministic keys”.
This appendix is informative.
uint64 = uint .size 8
alg = tstr
bstr-id = bstr .size (16..64)
rp-hash = bstr .size 32
ctx-hash = bstr .size 32
pol-hash = bstr .size 32
public-key = bstr .size 32
signature = bstr .size 64
nonce = bstr .size (16..64)
amount-mark = uint .le 99
registration-payload = {
1: 1,
2: 1,
3: alg,
4: bstr-id,
5: rp-hash,
6: public-key,
7: bstr-id,
8: nonce,
9: uint64,
? 10: tstr,
? 11: bstr,
? 12: public-key
}
registration-envelope = {
1: bstr .cbor registration-payload,
2: signature,
? 3: signature
}
rotation-payload = {
1: 1,
2: 3,
3: public-key,
4: public-key,
5: uint64,
6: uint64,
7: uint64,
? 8: rp-hash,
? 9: nonce
}
rotation-certificate = {
1: bstr .cbor rotation-payload,
2: signature
}
single-mode-fields = (
4: bstr-id,
9: public-key,
10: public-key,
15: uint64
)
threshold-mode-fields = (
20: bstr-id,
21: uint
)
pulse-payload = {
1: 1,
2: 2,
3: alg,
5: rp-hash,
6: uint64,
7: uint64,
8: uint64,
? 11: nonce,
? 12: ctx-hash,
? 13: pol-hash,
? 14: amount-mark,
(single-mode-fields // threshold-mode-fields)
}
signature-map = {
* bstr-id => signature
}
pulse-envelope = {
1: bstr .cbor pulse-payload,
2: signature-map,
? 3: [ + rotation-certificate ]
}
context-object = {
? 1: bstr,
? 2: bstr,
? 3: bstr,
? 4: bstr,
? 5: bstr,
? 6: bstr,
? 7: bstr,
? 8: bstr
}
policy-object = {
? 1: uint,
? 2: bool,
? 3: bool,
? 4: uint,
? 5: uint64,
? 6: bool,
? 7: uint,
? 8: bool,
? 9: bool,
? 10: bool
}
For single-device ratchet mode:
DeviceState {
kid
user_handle
current_pk
key_seq
last_ctr
revoked
created_at
updated_at
}
For duress:
DuressState {
kid
duress_pk
duress_key_seq
duress_last_ctr
}
For threshold groups:
ThresholdGroupState {
gid
rp_hash
signers: map[kid => public_key]
threshold
last_ctr
revoked
created_at
updated_at
}
For nonces:
NonceState {
nonce
session_id
created_at
expires_at
used
}
The following order is RECOMMENDED:
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.
This specification describes novel combinations of asymmetric time-bound proofs, forward-secure key ratcheting, silent duress signaling, threshold offline co-signing, policy binding, context binding, and transaction-visible trust codes.
No representation is made regarding the patent status of these techniques in any jurisdiction.
Implementers intending to patent, commercialize, or deploy this protocol in regulated environments SHOULD perform prior-art searches and obtain appropriate legal review.
This specification is experimental and has not been independently audited.