Skip to content

1.9.79: ClickBank's INS works for every account, decrypted with each account's own key - #276

Merged
tracking202 merged 22 commits into
1.9.79from
1.9.79-clickbank-per-account
Oct 28, 2026
Merged

tracking202 merged 22 commits into
1.9.79from
1.9.79-clickbank-per-account

Conversation

@tracking202

@tracking202 tracking202 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #268 (PayKickstart IPN, branch 1.9.79-paykickstart-ipn): review and merge this after #268. Until #268 merges, this diff includes #268's commits. #268 is merged in at 8532b44, and with it 1.9.79 (#262, #270, #280, #281, #282), each by a merge commit. This PR's own commits are 5ec4eda, e1f2d2b and b2c115f, plus the merge resolutions. See them alone with git diff origin/1.9.79-paykickstart-ipn...1.9.79-clickbank-per-account. #288 (851K: rebills, refunds, chargebacks) is stacked on this PR.

Backlog: 7PJV ("ClickBank notifications only work for the first account")

What was wrong

  • Account 1 only. tracking202/static/cb202.php read cb_key and user_slack_incoming_webhook from account 1 (lines 7 and 10). No other account could use ClickBank INS.
  • The wrong cipher, for every account. cb202.php decrypted with AES-128-CBC. ClickBank's INS article says notifications are encrypted with CBC-AES-256. Its PHP sample calls openssl_decrypt(…, 'AES-256-CBC', substr(sha1($secretKey), 0, 32), OPENSSL_RAW_DATA, …), and its Python sample's 32-byte key is AES-256 too. AES-128 reads only 16 of the derived key's 32 bytes.
    • Executed. The openssl command line encrypted a sale as the article says. Python's cryptography, keyed as ClickBank's Python sample, decrypts that ciphertext. The old cb202.php answers it 400 Unable to decrypt notification. It accepted only the AES-128 that its own tests (PostbackHandlerTest, legacy-pixels.sh) encrypted with: a mirror of the code, not of ClickBank (CLAUDE.md Update release notes in README #9).
    • Consequence. No notification encrypted as ClickBank documents it could have decrypted, for account 1 either. Nothing ClickBank actually sent was available here to confirm this.
  • A padding oracle. A ciphertext that failed to decrypt answered 400 Unable to decrypt notification. One that decrypted to something other than an order answered 400 Malformed order payload. That difference is the condition a padding-oracle attack needs. It was not demonstrated end to end.

What changed

cb202.php is now #268's Receiver, with a ClickBank adapter.

Decryption: ClickBankCipher. It decrypts as ClickBank's samples do:

  • AES-256-CBC, keyed with the first 32 hex digits of SHA-1(secret) taken as text;
  • trailing control bytes trimmed;
  • the result must be a JSON object that names its transactionType.

Invalid UTF-8 is substituted rather than refused, because 7.0 is the first version to say it sends UTF-8.

Finding the account: ClickBankAdapter.

  • Every account's stored cb_key is tried, with surrounding whitespace trimmed. The account is the one whose key decrypts the notification (CLAUDE.md [Maintenance] Remove deprecated PHP features #16).
  • A key two accounts store is refused, as the brief asks. The outcome is ambiguous_account (409). The log names the accounts; the answer does not.
  • A ciphertext that decrypts to something that is not an order answers exactly as a wrong key does (403). There is no padding oracle.
  • The envelope is checked before any key is tried:
    • A legacy cverify form (version 4.0 and earlier) is a 415 that says to choose 8.0.
    • Anything else that is not {"notification","iv"} with base64 of whole AES blocks and a 16-byte IV is a 400.

Reading the order.

  • SALE records a conversion under its receipt at totalAccountAmount, through 1.9.79: PayKickstart's product IPN, verified with each campaign's key #268's Processor (ledger source clickbank, pixel type 2). The amount is read exactly, whether 8.0 sends it as a string or 7.0 as a number.
  • Currency. The amount is stated as USD ("Total you received for the transaction in USD", in 6.0, 7.0 and 8.0). An account that counts in another currency gets the Processor's 422.
  • The click is the first tracking code (tid=[[subid]] on the HopLink). When there is none, it is 8.0's affiliateTrackingParameters.extclid.
    • A value that is not a click id is a 400 naming it.
    • No click at all is 200 no_click, worded for a HopLink.
  • When it happened. transactionTime is read with the offset it carries and sets Notification::$occurredAt, which 1.9.79: PayKickstart's product IPN, verified with each campaign's key #268's Processor dates the row by (held at the click when earlier, at arrival when later).
    • Accepted forms: RFC 3339 as 6.0 and 8.0 send it (2023-10-05T13:47:51-06:00), and ISO 8601's basic form as 7.0's notes describe (20200819T144359-0700).
    • A time that is neither, or that names no real moment (a 13th month, hour 24, beyond a conv_time), is a 400 naming transactionTime.
    • No time at all is dated on arrival, as before.
  • TEST marks that account's cb_verified, through a new optional interface, ConfirmsTestNotifications. A failure answers 500, so ClickBank's Test URL shows it. cb202.php then posts cb_key_verified to that account's own Slack webhook, after the answer.
  • Every other type is acknowledged, as before. 1.9.79: ClickBank rebills are recorded, and refunds and chargebacks take a sale back #288 handles them.

#268's adapter contract, as 1YEB listed it:

  • signatureField() answers encryption. INS 6.0 and later are encrypted, not signed: no field carries a signature, and the ciphertext arrives in notification. The method names what failed to verify in the 403, so encryption makes the answer read "The notification's encryption does not verify with any ClickBank secret key stored in Prosper202". notification would read "The notification's notification". The legacy versions, which did sign with cverify, are refused before any key is tried.
  • $occurredAt is set as above.
  • ServedPreferences::SECRET needs no change. ClickBank's per-account secrets are cb_key and the Slack webhook (user_slack_incoming_webhook), and 1.9.79: GET /users/{id}/preferences serves no integration secret #270 already lists both. cb_verified is a flag and is on SERVED.

Shared code, kept out of the other adapters' way:

  • InboundRequest carries the raw body.
  • The Processor's test and ignored outcomes name the account when one verified.
  • OutcomeKind::AMBIGUOUS_ACCOUNT is new (409).

The page. The ClickBank panel says to put tid=[[subid]] on the HopLink, and asks for OpenSSL alone.

Docs and changelogs. There is a ClickBank section in documentation/tutorials-and-guides/09-3rd-party-integrations.md, with setup steps, an outcome table and dating. changelogs.txt and changelogs.json have entries after 1.9.79's own.

ClickBank's spec: what was verified and what was not

Read from support.clickbank.com, "Instant Notification Service (INS)" (article 10535147) and the related articles.

  • Verified against the article:
    • Versions. 8.0 is current; 7.0 is recommended; 6.0 is the first encrypted one; 1, 2, 2.1 and 4 are legacy cverify form posts.
    • The encryption and the envelope, as above.
    • The fields used: transactionType, receipt (8 to 21 characters), totalAccountAmount (USD; a number in the 7.0 example, a string in the 8.0 one), trackingCodes, affiliateTrackingParameters.extclid, and transactionTime (RFC 3339 in the 6.0 table and the 8.0 example; 7.0's notes say ISO 8601 basic).
    • TEST carries the receipt ********.
    • Delivery. Any non-2xx is retried every 4 hours, at most 5 times, and a 2xx is expected within 3 seconds.
    • The secret key is "up to 16 characters, including numbers and capital letters".
  • Not verified:
    • No notification actually sent by ClickBank was available.
    • PKCS#7 padding is inferred from the official sample's openssl_decrypt call.
    • Whether an affiliate's INS receipt is ever masked (ClickBank's reporting pages mask it).
    • Whether tid is what fills trackingCodes.

Decisions a reviewer should check

  1. A shared key is refused, not decided by the click. A ClickBank key is a whole ClickBank account's, and a TEST could not say whose key to mark verified.
  2. The stored keys of deleted or deactivated accounts still count, as in 1.9.79: PayKickstart's product IPN, verified with each campaign's key #268 and JVZoo.
  3. USD. A notification for an account whose currency is not USD is now a 422.
  4. Where this differs from JVZoo (1.9.79: JVZoo's JVZIPN at jvzoo.php, for affiliates and vendors (stacked on #268) #272): unknown fields are not refused. JVZoo refuses them because its signature joins values without their names. ClickBank's order is encrypted whole, and its fields grow by version (8.0 added affiliateTrackingParameters and commonTrackingParameters).
  5. The extclid fallback is new behaviour. A tracking code, when there is one, always decides.
  6. signatureField() is encryption (above).

Verification

Scope report of .claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed on b2c115f (PHP 8.3.6, MariaDB 10.11, full vendor/). The schema tier had a scratch database, and the live instance's 202-config.php was moved aside. Unit tier: 6167 tests, 4 skipped (the documented User DB-singleton skips). Verbatim:

Scope report
------------
  go         PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  golangci   PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Integration suites, each alone on a database created for it:

Suite Tests
ClickBankInsIntegrationTest 12 OK
PayKickstartIpnIntegrationTest (the shared Processor) 20 OK
LegacyPostbackCampaignIntegrationTest 5 OK
ConversionBridgeIntegrationTest 5 OK
ConversionLedgerIntegrationTest 26 OK

Planted defects: 15 of 15 caught for the adapter (listed in the first version of this description), and the time-reading plants in #288's run (a time read without its offset, a 13th month read as January, a sale not dated, an unreadable time read as none), each caught by a named test.

Live pass, tests/live/clickbank-ins.sh, on the merged tree: 41 passed, 0 failed. Notifications are encrypted by the openssl command line and posted with curl to an instance with two accounts:

  • each account's SALE is recorded under its own account;
  • either account's key on the other's click is a 404;
  • account 2's TEST makes account 2's GET /system/integrations read verified: true, and account 1's false;
  • a replay is a duplicate; another amount is a 409;
  • a stranger's key and the old AES-128 ciphertext are 403;
  • no tracking code is 200 no_click; 42.9 is 400;
  • a legacy form is 415; a GET is 405;
  • a key two accounts store is 409, and records once it is one account's again;
  • a sale stating its time in -06:00 is stored with that conv_time; a 13th month is a 400;
  • the log has one line per notification, with no key and no buyer detail.

Before and after. Against the old cb202.php, 9 of the first 37 checks passed. tests/live/legacy-pixels.sh: 61 passed (before the latest merge).

Could not run

  • The installer's licence step. The licence stub at my.tracking202.com is down. The instance was installed by running what 202-config/install.php does after its licence check: INSTALL::install_databases(), InstallAccount::create(), a REST key in 202_api_keys, and the partitions. Then php -S was started with the CI router, as install-instance.sh does. Skipped: the licence check itself, and the AutoCron and daily-email registrations. The API, dl.php and cb202.php were driven directly.
  • CI has not been observed on this head.
  • A real ClickBank notification was not available.
  • The full integration-suite set was not run. The run was on MariaDB 10.11; CI runs MySQL 8.0.
  • No browser pass for the panel's text change.
  • No agent-eval case. This is not an agent-facing change.

Found outside this item

  • The other pages that read account 1's Slack webhook are 974K, and were left alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

Account › API integrations handed out tracking202/static/paykickstart.php
as PayKickstart's Affiliate IPN URL under "No setup needed", and GET
/system/integrations and `p202 system integrations` listed it. The file
never existed, so every IPN sent there was a 404. The Affiliate IPN could
not have worked anyway: it is unsigned JSON with PayKickstart's own
tracking id and no custom variables, so nothing in it names a click.

The URL now takes PayKickstart's product (vendor) IPN. A vendor puts
subid=[[subid]] on the checkout link that is the campaign's offer URL,
pastes the URL into each product's IPN Integration tab and stores each
PayKickstart campaign's IPN secret key (202_users_pref, one per line, at
most 20, never served back). Each IPN is verified against every account's
stored keys with PayKickstart's published HMAC-SHA1 algorithm before
anything in it is read, and is recorded for the account whose key
verifies it, on a click of that account (another account's click reads
as no click). sales records a conversion (source paykickstart, pixel type
2) under its transaction_id at its amount; refund reverses it; test mode,
subscription-payment and every other event record nothing. A retry under
a transaction id on file is a duplicate only when it states the same
amount, and is refused (409) when it states another. Every outcome is a
status PayKickstart's log shows, with the reason, and one log line
without a key or buyer data.

The receiving side is provider-neutral (Prosper202\PaymentNotification:
a ProviderAdapter that verifies and parses, one Processor that records or
reverses through p202RecordLegacyConversion(), one Response mapping), so
JVZoo and ClickBank can follow as adapters.

HandedOutUrlsNameServedFilesTest holds every script a served literal
names, and every integration GET /system/integrations lists, to a file in
the tree; jvzoo.php, zpn.php and slack.php are listed as known dead until
their own changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
202-config/changelogs.json kept both: #258's updated lines, then the
PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#258 made the API answer a 5xx while the files are deployed and the
upgrade not run with a 503 naming the upgrade. The IPN endpoint is not in
the API's tree, and between those two moments its keys' column is not
there yet: it answered 500. The Receiver now asks
SchemaVersion::pendingUpgrade() on a failure and answers 503 with its
sentence (OutcomeKind::UPGRADE_PENDING); PayKickstart's retry lands once
the upgrade has run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ey is "the key"

admin-005 asks an agent to store a campaign's PayKickstart IPN secret key
and give the URL to paste: the key is stored with user prefs update, the
URL is the one system integrations answers, and neither the preferences
read nor the reply repeats the key. The reference agent answers it; an
agent that stores nothing and repeats the key fails all four checks.
Search puts system integrations first for the ask (two tasks in one ask),
so it is listed in searchKnownMisses rather than taught by a phrase lifted
from the ask.

A refusal of a single pasted key says "The key is 5 characters long"
rather than "Key 1"; a list still names the key by its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…what the reversal on file took back

A refund with no amount (or 0) takes back the whole sale. Its comparison
with the reversal on file was skipped when the amount was null, so a full
refund after a partial one was answered 200 duplicate: PayKickstart stopped
retrying while the rest of the sale stayed credited (CLAUDE.md #15). The
Processor now reads the reversal on file with the sale it reverses and
compares what the refund takes back, a full refund read as the sale's
amount. Any other refund of a sale already reversed is a 409 naming both:
"already reversed by 10.5, and 36.5 of its 47 is still credited; this
refund states its full amount, 47". A sale is still reversed once.

The other duplicate decision, a sale or rebill under a transaction id on
file, compares two amounts that are never null (the sale's is required,
the stored one NOT NULL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…er a deleted reversal reverses again

A sale stated again under a transaction id whose sale a refund took back
was answered 200 duplicate while the sale stayed reversed. JVZoo sends an
undone refund as RFND and then SALE again; the sale is now a 409 naming
the reversal to delete to count it (ported from the JVZoo branch).

Deleting that reversal did not make a later refund work: a deleted row
keeps its dedupe key, every refund without an id of its own shared
rev:<sale>:1, so the next refund of the sale found the deleted row and was
answered 200 duplicate with nothing reversed. Such a refund is now keyed
by its body, so only the same notification sent again unchanged finds a
reversal already on file, live or deleted; any other is either compared
with the reversal that stands (the writer refuses a second one under its
lock, and the Processor answers duplicate when it takes back the same) or,
when none stands, reverses the sale again. A refund with its own id is
still told apart by that id.

The 403 names the provider's signature field (ProviderAdapter::
signatureField(); PayKickstart's is hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
cb202.php read the ClickBank secret key and the Slack webhook of account 1
and of no other account, so a second account could not use ClickBank's
Instant Notification Service at all. It also decrypted with AES-128-CBC,
while ClickBank's INS article says notifications are encrypted with
CBC-AES-256 and its PHP and Python samples decrypt them so: AES-128 reads
16 of the derived key's 32 bytes, and a notification encrypted as the
article says did not decrypt for any account (executed: the openssl command
line's AES-256 ciphertext, which Python's cryptography decrypts keyed as
ClickBank's Python sample, is refused by the old code, which accepts only
the AES-128 its own tests encrypted with).

cb202.php now runs the shared payment-notification Receiver with a
ClickBank adapter (#268's shape):

- ClickBankCipher decrypts as ClickBank's samples do: AES-256-CBC keyed
  with the first 32 hex digits of SHA-1(secret), trailing control bytes
  trimmed, the order a JSON object naming its transactionType.
- ClickBankAdapter tries every account's stored cb_key; the account is the
  one whose key decrypts the notification. A key two accounts store is
  refused (a new outcome, ambiguous_account, 409) and the log names the
  accounts. A ciphertext that decrypts to something other than an order
  answers as one no key decrypts: the old code answered the two with
  different 400s, which is what a padding-oracle attack needs.
- The envelope is read before any key: a legacy cverify form (4.0 and
  earlier) is a 415 naming the version to choose, anything else that is
  not {"notification","iv"} a 400.
- A SALE is recorded under its receipt at totalAccountAmount, stated as
  USD (ClickBank: "Total you received for the transaction in USD"), so an
  account counting in another currency is refused by the Processor. The
  amount is read exactly whether 8.0 sends it as a string or 7.0 as a
  number. The click is the first tracking code (tid=[[subid]]), or, with
  none, the affiliate's extclid that 8.0 sends; no click is 200 no_click
  rather than the 400 ClickBank resent five times.
- TEST marks that account's cb_verified through a new optional interface,
  ConfirmsTestNotifications, which the Processor calls for a test one
  account verified; a failure answers 500 so ClickBank's Test URL shows
  it. cb202.php then posts to that account's own Slack webhook, after the
  answer (ClickBank waits 3 seconds).
- Every other type is acknowledged as before (851K handles them).

Shared code: InboundRequest carries the raw body; the Processor's test and
ignored outcomes name the account when one verified; the Receiver's
refusal no longer says "hash" (ClickBank's is a cipher). The page's
ClickBank panel says to put tid=[[subid]] on the HopLink and asks for
OpenSSL alone (mcrypt was never read).

Tests: ClickBankAdapterTest (a known-answer vector from the openssl
command line, the old cipher refused, the padding-oracle case, the
envelope, every field's reading) and ClickBankInsIntegrationTest (two
accounts and a third sharing a key, a real server and the real writer).
PostbackHandlerTest is removed: it re-implemented the AES-128 decryption
in the test and exercised nothing in the tree. tests/live/clickbank-ins.sh
is the two-account live pass, encrypting with the openssl command line;
legacy-pixels.sh encrypts as ClickBank does now.

Backlog: 7PJV

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 23, 2026 22:32
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-23T22:32:30.442407Z
We couldn’t complete this review. Comment @codex review to try again.
5ec4eda PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

claude added 10 commits October 23, 2026 22:34
… words

The Processor's no_click answer names PayKickstart's checkout link and its
subid, which is the wrong advice for a ClickBank HopLink. The adapter now
answers no_click itself, as JVZoo's does, naming the type, the receipt and
tid=[[subid]].

Backlog: 7PJV

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
changelogs.json: 1.9.79's lines, then the PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ckBank branch

The Receiver's 403 now names the provider's signature field
(ProviderAdapter::signatureField()); ClickBank's is its encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

# Conflicts:
#	202-config/changelogs.json
…ClickBank branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

# Conflicts:
#	202-config/changelogs.json
…ppened

p202RecordLegacyConversion() dated every row time(), and the Processor
dropped the time a notification states, so an IPN PayKickstart delivered
days late (it retries a failed delivery up to 13 times) was filed on the
day it arrived in every view by conversion time: the conversion list's
time filter, attribution, the click-to-conversion time. The recorder now
takes a conv_time, and the Processor passes the stated time, held between
the earliest it can be (the click a sale credits; for a refund, the sale
it takes back, so the sale keeps its own day) and its arrival (a clock
ahead of this server's). The answer and the log line say when a time was
held. With no time stated, the row is dated when it arrives, as before.

transaction_time is signed (every non-empty field is); one that is not a
Unix time is now a 400 naming it, where it was read as no time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…start IPN branch

GET /users/{id}/preferences now serves every integration secret as its
stored state (#270), and the PayKickstart IPN keys are one more:
paykickstart_ipn_secret_keys is on ServedPreferences::SECRET, answered
{"stored": true}, and the count this branch served in its place
(paykickstart_ipn_keys_stored, PayKickstartKeys::withoutKeys()) is gone.
GET /system/integrations keys_stored still says how many. The
PreferenceSecretsAreNotServedIntegrationTest round trip stores the keys
with the other secrets.

Conflicts: changelogs (1.9.79's lines, then this branch's), the
preferences docs and the OpenAPI schemas (1.9.79's, with the keys added
as a StoredSecret), and the controller (1.9.79's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ending

The page's Remove form named a key by its place and the four characters
it shows (…AB12). Another tab could remove that key and add a different
one ending in the same four characters at its place; the first tab's
form then matched the new key and removed it (measured on the pushed
class: removed(['campaign-new-AB12', …], '1', '…AB12') dropped
campaign-new-AB12). The form now carries an HMAC-SHA256 of the whole key
under a secret this session holds server-side
(PayKickstartKeys::removalToken()): the page shows nothing a reader
could match a guess against, and a form made for one key cannot match
another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…79's #270, #280, #281, #282) into the ClickBank branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

# Conflicts:
#	202-config/changelogs.json
#	changelogs.txt
#268's Processor now dates a conversion by the time its notification
states (held between the click and the arrival). ClickBankAdapter reads
transactionTime with the offset it carries: RFC 3339 as 6.0 and 8.0 send
it (2023-10-05T13:47:51-06:00) and ISO 8601's basic form as 7.0's notes
describe (20200819T144359-0700). A time that is neither, or names no
real moment (a 13th month, hour 24, beyond a conv_time), is a 400 naming
the field rather than the time it arrived; none at all is dated on
arrival, as before.

Backlog: 7PJV

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: BLOCKING

For fcd6019: 5 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 202-config/functions-upgrade.php, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in paykickstart-ipn (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
PayKickstartAdapter::storedSecrets() read every 202_users_pref row, and a
deleted account keeps its row and the keys in it. Its key kept verifying
IPNs, so a sale on a deleted account's click was recorded for it, and a key
the deleted account shared with a live one verified both. ClickBank's and
JVZoo's adapters, on the branches above, read their keys the same way, and
ClickBank refuses a key two accounts store with a 409: a deleted account
holding a live account's ClickBank key refused every notification of the
live account, with nothing in the UI to clear the deleted copy.

AccountSecrets::stored() is now the one read of a provider's secret column
that decides the accounts a notification can belong to: it joins
202_users and leaves out user_deleted = 1. A user switched off in Account ›
Users (user_active = 0) still owns its keys: a sale PayKickstart reports
for that account is not that person acting. ProviderAdapter documents it.

StoredSecretsReadThroughAccountSecretsTest holds every ProviderAdapter's
storedSecrets() to a call of AccountSecrets::stored() with no statement of
its own naming 202_users_pref, and every other statement in
202-config/PaymentNotification and the payment endpoints that names
202_users_pref to a listed reason (the Processor's currency read, for the
account already decided).

Tests: PayKickstartIpnIntegrationTest's new
testADeletedAccountsStoredKeyVerifiesNothing seeds a deleted account that
stores a live account's key and one of its own: the live account's sale is
recorded for it, the deleted account's click is a 404 and its own key a
403, and a switched-off account's sale is recorded. Planted: the
user_deleted filter removed from AccountSecrets fails it ("Recorded
transaction PK-TX-D2 on click 99040131"), and so does a user_active = 1
filter added (the switched-off account's IPN unverified); storedSecrets()
reading 202_users_pref itself fails both structural tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in #268's AccountSecrets: a deleted account's stored keys verify
nothing. The conflict was in changelogs.txt, where both sides changed
lines of 1.9.79: #268's PayKickstart line is kept as it is and this
branch's ClickBank line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
ClickBankAdapter::storedSecrets() read cb_key from every 202_users_pref row,
and a deleted account keeps its row and its key. A notification that the
key decrypts names every account that stores it, and a key two accounts
store is refused (409, ambiguous_account): so a deleted account that still
held the key a live account stores now made every one of the live
account's notifications a 409, and the deleted account's copy cannot be
cleared from the UI. A key only a deleted account stored still recorded
sales on its clicks.

storedSecrets() reads the keys through #268's AccountSecrets::stored(),
which leaves out deleted accounts; a switched-off account (user_active = 0)
still owns its key. StoredSecretsReadThroughAccountSecretsTest now holds
ClickBankAdapter too, and lists the two statements it makes on
202_users_pref for the account a TEST already decided (confirmTest(),
slackWebhook()).

Tests: ClickBankInsIntegrationTest's new
testADeletedAccountsStoredKeyIsNoSecondAccount: with a deleted account
storing the first account's key, the first account's sale is recorded and
its TEST marks only it verified; a key only the deleted account stores is
a 403; a switched-off account's sale is recorded. Planted: storedSecrets()
reading 202_users_pref itself, and AccountSecrets without its
user_deleted filter, each fail it with the 409 ("The notification decrypts
with a ClickBank secret key that more than one Prosper202 account
stores"); a user_active = 1 filter fails it with a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

Copy link
Copy Markdown
Owner Author

The adversarial review marks fcd6019 BLOCKING (5 findings), so the check is red. Its report is encrypted for maintainers, so I can't see which finding blocks.

The previous head, 0869d72, came back NON-BLOCKING with 4 findings. The only change since then is the deleted-account key fix (merged from #268, plus fcd6019):

  • A new shared AccountSecrets::stored() joins 202_users, so a deleted account's stored key verifies nothing.
  • ClickBank's and PayKickstart's storedSecrets() now read through it.

I read that delta and found no defect. The column name is checked against ^[a-z][a-z0-9_]*$ before it goes into the SQL. The join leaves out only deleted accounts (user_deleted = 0), so switched-off accounts still verify. Each adapter's key handling is unchanged. #272 got the same change for JVZoo and is NON-BLOCKING.

That still doesn't rule out a real finding elsewhere in this PR; #288's BLOCKING turned out to be one. A self-review of this PR's ClickBank code is under way, covering the cipher, the account decision, the TEST path and the logs. Any real defect will be fixed here with a test that fails without the fix, and merged up into #288 and #319. I'll report the result on this thread.

If a maintainer can share the blocking finding's title, I can go straight to it.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

The self-review of fcd6019 is done: no blocking defect found, so nothing is pushed. Everything below was measured on a live instance, not read.

  • Deleted-account change, through the real DELETE /api/v3/users/{id} path. A sale under a key that a now-deleted account also stored went from 409 to 200 recorded. A re-created account storing the same key gets 409, and the log names the live accounts, not the deleted one.
  • Padding oracle. Good padding, bad padding, a non-order JSON and an unknown key all get byte-identical answers (403, same body). Loopback medians over 1,500 requests each fall within 0.03 ms of one another, against a 0.6 ms spread.
  • Ownership, logs, content type. Neither account's key records on the other's click. No key, buyer detail or line break reaches the log. The body is read whether it comes form-encoded or as text/plain.
  • Tests. ClickBank integration 13/13, PayKickstart integration 21/21 and tests/live/clickbank-ins.sh 41/41 on this head. Removing the user_deleted filter or the shared lookup fails named tests.

Two real but non-blocking points, both already present on 0869d72 (NON-BLOCKING):

  1. Merge order. On this PR alone, RFND, CGBK, INSF and BILL answer 200 ignored, as documented here. ClickBank does not resend after a 2xx, so a refund that arrives while only this PR is deployed is lost. 1.9.79: ClickBank rebills are recorded, and refunds and chargebacks take a sale back #288 fixes that (an RFND there writes the reversal; measured). Please don't release 1.9.79: ClickBank's INS works for every account, decrypted with each account's own key #276 without 1.9.79: ClickBank rebills are recorded, and refunds and chargebacks take a sale back #288.
  2. TEST answer on servers without PHP-FPM. The answer waits for the Slack post: fastcgi_finish_request() exists only under FPM, and the shared Slack.class.php sets no curl timeout. The old cb202 posted before answering on every server, so this PR is still an improvement. The root cause is filed separately.

Two reviews of code that contains this PR in full, #288 (5e7e990) and #319 (d537116), came back NON-BLOCKING. Together with the above, the BLOCKING mark on this head looks like reviewer variance. If a maintainer can share the blocking finding, I'll address it directly.


Generated by Claude Code

@tracking202
tracking202 merged commit 5e7e990 into 1.9.79 Oct 28, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants