Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

import com.stripe.mpp.Credential;
import com.stripe.mpp.Receipt;
import com.stripe.mpp.error.InvalidChallengeException;
import com.stripe.mpp.error.PaymentExpiredException;
import com.stripe.mpp.error.VerificationFailedException;
import com.stripe.mpp.server.Intent;
import com.stripe.mpp.server.ValidationResult;
import com.stripe.mpp.store.MemoryStore;
import com.stripe.mpp.store.Store;

import java.math.BigInteger;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
Expand All @@ -35,6 +40,10 @@
* {@code keccak256(challengeId)[0..6]}). That is what stops a third party from
* presenting someone else's settled transaction as their own payment.
*
* <p>Direct verification must complete before the authenticated challenge expiry, including
* receipt verification and the replay claim. A payment broadcast before expiry may settle even
* when verification subsequently fails with an expired challenge.
*
* <p>Create the intent once and reuse it so its replay store is shared across requests:
*
* <pre>{@code
Expand Down Expand Up @@ -66,6 +75,7 @@ public class TempoChargeIntent implements Intent {
private final long retryDelayMs;
private final TempoRpc rpc;
private final Store store;
private final Clock clock;

public TempoChargeIntent(String rpcUrl) {
this(rpcUrl, DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY_MS, new TempoRpc(), new MemoryStore());
Expand All @@ -89,11 +99,18 @@ public TempoChargeIntent(String rpcUrl, Store store) {
}

TempoChargeIntent(String rpcUrl, int maxRetries, long retryDelayMs, TempoRpc rpc, Store store) {
this(rpcUrl, maxRetries, retryDelayMs, rpc, store, Clock.systemUTC());
}

TempoChargeIntent(
String rpcUrl, int maxRetries, long retryDelayMs, TempoRpc rpc, Store store, Clock clock
) {
this.rpcUrl = rpcUrl;
this.maxRetries = maxRetries;
this.retryDelayMs = retryDelayMs;
this.rpc = rpc;
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
}

@Override
Expand All @@ -109,41 +126,70 @@ public Receipt verify(Credential credential, Map<String, Object> request) {
throw new VerificationFailedException("missing or invalid payload");
}
Map<String, Object> payload = (Map<String, Object>) credential.payload();
Instant expiresAt = expiry(credential);

String type = (String) payload.get("type");
if ("transaction".equals(type)) {
// Pull: client signed the tx, server broadcasts it.
return verifyTransaction((String) payload.get("signature"), request, credential);
return verifyTransaction((String) payload.get("signature"), request, credential, expiresAt);
}
if ("hash".equals(type)) {
// Push: client already broadcast, server just verifies the receipt.
return verifyHash((String) payload.get("hash"), request, credential);
return verifyHash((String) payload.get("hash"), request, credential, expiresAt);
}
throw new VerificationFailedException("unrecognized payload type: " + type);
}

private Receipt verifyTransaction(String rawTx, Map<String, Object> request, Credential credential) {
private Receipt verifyTransaction(
String rawTx, Map<String, Object> request, Credential credential, Instant expiresAt
) {
assertNotExpired(credential.challenge().expires(), expiresAt);
String sourceAddress = parseCredentialSource(credential.source(), chainIdFrom(request));
String txHash = rpc.sendRawTransaction(rpcUrl, rawTx);
return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress));
return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress), credential, expiresAt);
}

private Receipt verifyHash(String txHash, Map<String, Object> request, Credential credential) {
private Receipt verifyHash(
String txHash, Map<String, Object> request, Credential credential, Instant expiresAt
) {
assertNotExpired(credential.challenge().expires(), expiresAt);
// Validate the declared payer before reserving the hash so a malformed
// source cannot burn an otherwise valid payment.
String sourceAddress = parseCredentialSource(credential.source(), chainIdFrom(request));
return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress));
return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress), credential, expiresAt);
}

/** Records first use of the settled transaction, rejecting a hash that was already claimed. */
private Receipt claimOnce(Receipt receipt) {
private Receipt claimOnce(Receipt receipt, Credential credential, Instant expiresAt) {
String expires = credential.challenge().expires();
assertNotExpired(expires, expiresAt);
String txHash = receipt.reference();
if (!store.tryClaim(REPLAY_KEY_PREFIX + txHash.toLowerCase(Locale.ROOT))) {
boolean claimed = store.tryClaim(REPLAY_KEY_PREFIX + txHash.toLowerCase(Locale.ROOT), expiresAt);
assertNotExpired(expires, expiresAt);
if (!claimed) {
throw new VerificationFailedException("transaction hash already used: " + txHash);
}
return receipt;
}

private Instant expiry(Credential credential) {
String expires = credential.challenge().expires();
if (expires == null) {
throw new InvalidChallengeException(credential.challenge().id(), "missing expiry");
}
try {
return Instant.parse(expires);
} catch (DateTimeException e) {
throw new InvalidChallengeException(credential.challenge().id(), "invalid expiry");
}
}

private void assertNotExpired(String expires, Instant expiresAt) {
if (!clock.instant().isBefore(expiresAt)) {
throw new PaymentExpiredException(expires);
}
}

private Receipt awaitReceipt(
String txHash,
Map<String, Object> request,
Expand Down
61 changes: 56 additions & 5 deletions src/main/java/com/stripe/mpp/store/MemoryStore.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,71 @@
package com.stripe.mpp.store;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.PriorityQueue;

/**
* Process-local {@link Store} for tests and development.
*
* <p>Claims live in memory and are lost on restart. They are only visible to one process, so a
* multi-instance deployment gets no replay protection from this store. Configure a durable, shared
* {@link Store} in production.
*
* <p>Expired claims are removed on the next claim operation, including claims for untouched keys.
* No background thread runs; an idle store retains expired entries until the next operation.
*/
public final class MemoryStore implements Store {
private final Set<String> claims = ConcurrentHashMap.newKeySet();
private final Map<String, Claim> claims = new HashMap<>();
private final PriorityQueue<Claim> expirations =
new PriorityQueue<>(Comparator.comparing(claim -> claim.expiresAt));
private final Clock clock;

public MemoryStore() {
this(Clock.systemUTC());
}

MemoryStore(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}

@Override
public boolean tryClaim(String key) {
return claims.add(key);
public synchronized boolean tryClaim(String key) {
return claim(key, null);
}

@Override
public synchronized boolean tryClaim(String key, Instant expiresAt) {
Objects.requireNonNull(expiresAt, "expiresAt");
return claim(key, expiresAt);
}

private boolean claim(String key, Instant expiresAt) {
Objects.requireNonNull(key, "key");
Instant now = clock.instant();
while (!expirations.isEmpty() && !now.isBefore(expirations.peek().expiresAt)) {
Claim expired = expirations.remove();
claims.remove(expired.key, expired);
}
if ((expiresAt != null && !clock.instant().isBefore(expiresAt)) || claims.containsKey(key)) {
return false;
}
Claim claim = new Claim(key, expiresAt);
claims.put(key, claim);
if (expiresAt != null) expirations.add(claim);
return true;
}

private static final class Claim {
final String key;
final Instant expiresAt;

Claim(String key, Instant expiresAt) {
this.key = key;
this.expiresAt = expiresAt;
}
}
}
28 changes: 26 additions & 2 deletions src/main/java/com/stripe/mpp/store/Store.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
package com.stripe.mpp.store;

import java.time.Instant;
import java.util.Objects;

/**
* Atomic claim store for replay protection.
*
* <p>Production implementations must be durable and shared across processes / servers.
*
* <p>Invariant: exactly one caller may see {@code true} for a given {@code key}.
* <p>At most one caller may claim a key while its claim is active. Claims made without an
* expiry remain active permanently.
*/
@FunctionalInterface
public interface Store {
/**
* Atomically claims {@code key} if no claim already exists.
* Atomically claims {@code key} if no active claim already exists, retaining it permanently.
*
* @param key namespaced replay-claim key
* @return {@code true} for a fresh claim or {@code false} if the key was already claimed
*/
boolean tryClaim(String key);

/**
* Atomically claims {@code key} until an absolute acceptance deadline.
*
* <p>The deadline must come from an authenticated challenge bound to the payment proof.
* Implementations must reject deadlines at or before the current time. Expiring implementations
* must check the deadline as part of their atomic claim decision, and may reclaim expired claims.
* A caller must never accept an expired proof merely because its claim has been reclaimed.
*
* <p>The default implementation preserves compatibility with existing stores by retaining
* claims permanently. Override this method to provide atomic deadline checks and expiry cleanup.
*
* @param key namespaced replay-claim key
* @param expiresAt exclusive acceptance deadline
* @return {@code true} for a fresh claim, or {@code false} for an expired or already claimed key
*/
default boolean tryClaim(String key, Instant expiresAt) {
Objects.requireNonNull(expiresAt, "expiresAt");
return Instant.now().isBefore(expiresAt) && tryClaim(key);
}
}
Loading