View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011692 | Taler | merchant backend | public | 2026-07-27 22:08 | 2026-07-28 16:22 |
| Reporter | Florian Dold | Assigned To | Christian Grothoff | ||
| Priority | normal | Severity | minor | Reproducibility | always |
| Status | resolved | Resolution | fixed | ||
| Target Version | 1.6 | Fixed in Version | 1.6 | ||
| Summary | 0011692: merchant creates duplicate token signing keys for same validity period | ||||
| Description | # Duplicate token family keys in contract terms ## 1. Symptom The `token_families` section of a v1 contract contains, for a single family, **two signing keys with identical validity periods**. From the dump attached to the bug report (the reporter replaced each `rsa_pub` value with a `"rrr"` / `"sss"` placeholder): ```json "slugdiscount1-DWDZRW6T4MFX81NFK9AF6Y13AG": { "name": "discount1", "keys": [ {"cipher":"RSA","rsa_pub":"rrr","signature_validity_start":{"t_s":1782484948},"signature_validity_end":{"t_s":1782657748}}, {"cipher":"RSA","rsa_pub":"rrr","signature_validity_start":{"t_s":1782484948},"signature_validity_end":{"t_s":1782657748}} ], ... } ``` Both referenced families in that order (`slugdiscount1-…` and `slugsubscription1-…`) showed exactly two keys each. The reproduction below shows the keys are **two genuinely distinct keys** that share one validity period — the identical-looking `rsa_pub` values in the report are an artifact of the reporter's redaction, not evidence that the same key was emitted twice. ## 2. Root cause The backend uses **two different SQL queries** to find a family's keys, and they disagree on whether the private key's retention deadline matters. **Output path** — `src/backenddb/get_token_family_key.c:124`, used when an order *issues* tokens ($2 = `valid_at`, $3 = `pay_deadline`): ```sql FROM merchant_token_families mtf LEFT JOIN merchant_token_family_keys mtfk ON ( (mtf.token_family_serial = mtfk.token_family_serial) AND ($2 >= mtfk.signature_validity_start) AND ($2 <= mtfk.signature_validity_end) AND ($3 <= mtfk.private_key_deleted_at) ) -- <=== retention condition WHERE slug=$1 ORDER BY mtfk.signature_validity_start ASC LIMIT 1 ``` **Input path** — `src/backenddb/iterate_token_family_keys.c:203`, used when an order *accepts* tokens ($2 = `now`, $3 = `pay_deadline`): ```sql FROM merchant_token_families mtf LEFT JOIN merchant_token_family_keys mtfk USING (token_family_serial) WHERE slug=$1 AND COALESCE ($2 <= mtfk.signature_validity_end, TRUE) AND COALESCE ($3 >= mtfk.signature_validity_start, TRUE); -- no retention condition ``` `private_key_deleted_at` is the point from which the merchant may discard the private key. It is written **once**, when the key is minted — `insert_token_family_key.c` binds the `key_expires` argument to column `$6`, `private_key_deleted_at`. There is exactly one minting site in the tree (`taler-merchant-httpd_post-private-orders.c:1971`), and **no code anywhere updates the column afterwards**. Its value is computed in `add_output_token_family()` as, in effect: ``` key_expires = max(pay_deadline_of_the_minting_order, round_down(key.valid_before, validity_granularity)) ``` So the stored retention deadline is pinned to *the pay deadline of whichever order happened to mint the key*. ### The failure sequence For an order whose pay deadline is later than an existing key's `private_key_deleted_at`: 1. The **output** path runs `get_token_family_key`. The existing key K1 satisfies both validity-period conditions but fails `$3 <= private_key_deleted_at`. The `LEFT JOIN` yields the family row with NULL key columns — i.e. "this family has no usable key". 2. The backend therefore mints **K2**. Because the validity window is derived deterministically from `valid_at`, the granularity and the family's `valid_after`, K2 gets the **exact same `signature_validity_start` / `signature_validity_end` as K1**. 3. The **input** path runs `iterate_token_family_keys`, which has no retention condition and returns **both** K1 and K2. 4. `add_family_key()` (`taler-merchant-httpd_post-private-orders.c:1588`) deduplicates only by public key, via `TALER_token_issue_pub_cmp`. K1 and K2 are different keys, so both are kept. Result: two distinct keys with one validity period in the contract terms. This only becomes visible when a family is referenced **both** as an output and as an input within the same order — the output reference mints the surplus key, the input reference is what surfaces the older one. That is exactly the shape of the `taler-harness playground advanced-tokens-1` scenario the reporter ran. ## 3. Reproduction Test: `taler-typescript-core` → `packages/taler-harness/src/integrationtests/test-merchant-tokenfamily-keys.ts` (committed on branch `dev/phoenix-bot/duplication` as `eeb717f6b`, marked `.todo = true` since the backend is unfixed). It mirrors the reporter's `playground advanced-tokens-1` order — five choices, each family used as an output in one choice and as an input in another — and then creates **two** orders against the same families: | Parameter | Value | | --- | --- | | `duration` | 2 minutes | | `validity_granularity` | 1 minute | | family `valid_before` | now + 1 year | | order 1 `pay_deadline` | now + 10 minutes | | order 2 `pay_deadline` | now + 365 days | Run: ``` taler-harness run-integrationtests merchant-tokenfamily-keys ``` Observed on current master: ``` [order-1] family slugdiscount1 has 1 key(s) [order-1] family slugsubscription1 has 1 key(s) [order-2] family slugdiscount1 has 2 key(s) [order-2] family slugsubscription1 has 2 key(s) start=1785182238 end=1785182358 cipher=RSA pub=040000WKWZ9VPS3MRTG6B93G... start=1785182238 end=1785182358 cipher=RSA pub=040000ZQAWVR3R2FGBA5VM45... windows identical: True pubs identical : False ``` Order 2's pay deadline (+365 days) exceeds K1's `private_key_deleted_at` (pinned to order 1's +10 minutes), so order 2 mints a second key over the same window — matching the reported symptom. ### Conditions that do *not* reproduce it The playground's **default** parameters do not trigger it: with `duration` = 2 days and `validity_granularity` = 1 hour, `round_down(valid_before)` lands roughly two days out, far beyond the +10 minute pay deadline, so the retention condition always holds. Neither a single order nor two consecutive orders produced a duplicate under those settings. A short token duration relative to a later order's pay deadline is what exposes it. Note also that the backend enforces `duration >= validity_granularity + start_offset` at family creation (HTTP 400, *"duration below validity_granularity plus start_offset"*), so a short duration requires a correspondingly short granularity. ## 4. Relationship to commit `b1682233` `b1682233` — *"fix first case of private token family key not being found"*, Christian Grothoff, **2026-06-27**, the day after this bug was filed — added precisely the clamp that keeps a freshly minted key alive to its own order's deadline: ```c /* Make sure key never expires before the payment deadline */ key_expires = GNUNET_TIME_timestamp_max (oc->parse_order.order->pay_deadline, key_expires); ``` **That fix is incomplete.** It clamps to *the minting order's* pay deadline only. Any later order with a longer pay deadline still fails the retention condition, so the defect recurs — which is what the reproduction above demonstrates on current master. The same commit touched `taler-merchant-httpd_post-orders-ORDER_ID-pay.c`, replacing a hard assertion with an error path whose comment describes the other face of this same asymmetry: ```c /* The key SHOULD have been created when we created the order (after all, the client was able to blind, and that requires us having had a public key). So how did we loose it? Bad bug, this should not be possible. */ ``` and left a `FIXME: but it does! see merchant-tokenfamilies test!` on the neighbouring `GNUNET_assert` in `sign_token_envelopes()`. ### A second, related hazard (reasoned, not yet reproduced) The in-memory lookup `find_key_index()` (`taler-merchant-httpd_post-private-orders.c:1679`) matches on the validity period **only** — it does not consider `private_key_deleted_at` either. So if a contract's *first* reference to a family is an input, the input path loads K1 into the in-memory family, and a subsequent output reference will match K1 through `find_key_index()` and bypass the retention check entirely. The order would then commit to a `key_index` whose private key may legitimately be deleted before payment — producing exactly the 500 *"private token family key not found"* that `b1682233` added handling for. This follows from reading the code; I did not construct a test for it. ## 5. Impact - **Contract terms** carry redundant keys — cosmetic, matching the "minor" severity on the ticket. - **Key proliferation:** once the retention condition is failing for a family, *every* subsequent order mints another key over the same window. This is unbounded growth in `merchant_token_family_keys`, plus a fresh RSA keypair generation per order. - **Payment failures:** the same asymmetry is the likely source of the *"private token family key not found"* internal error the pay handler now guards against (see §4). ## 6. Recommended fix The duplication is a symptom; the defect is that a key's retention deadline is pinned to one order and never revisited. Two options: **Option A — extend the existing key (recommended).** When a key covers `valid_at` but its `private_key_deleted_at` is earlier than the new order's pay deadline, extend it rather than mint a replacement: ```sql UPDATE merchant_token_family_keys SET private_key_deleted_at = GREATEST(private_key_deleted_at, $pay_deadline) WHERE ... ``` This needs a new `libtalermerchantdb` operation and a call in `add_output_token_family()` on the path where the key is found by validity period but rejected by retention. It preserves the invariant "one key per validity period" and removes the `find_key_index()` hazard in §4, since any key reachable in memory is then guaranteed to outlive the deadline. **Option B — decouple retention from the pay deadline.** Derive `private_key_deleted_at` solely from the validity period (plus a grace margin) and *reject* orders whose pay deadline outruns it. Simpler, no new DB operation, but it changes which orders are accepted and needs a protocol/UX decision. Independently of either, `add_family_key()` could reject a second key for a validity period already present — but as a defence-in-depth assertion, not the fix: it would hide the proliferation rather than stop it. Once fixed, drop the `runMerchantTokenfamilyKeysTest.todo = true` marker in the test above. ## 7. Limits of this investigation - The reporter's exact production parameters were not recovered. The reproduction establishes *a* mechanism that yields the reported symptom precisely, not proof that test/demo hit it by this same route. - I could not build the pre-report tree (`3fb61e33`, 2026-06-26) to compare behaviour: it no longer compiles against the current exchange libraries (`TALER_BANK_registration` changed signature). Rolling the exchange back too was out of proportion to the question. - The §4 "second hazard" is a code-reading conclusion, not a tested one. | ||||
| Tags | No tags attached. | ||||
|
|
Resolved by bumping the key expiration time as suggested. Originally I considered that creating additional keys was OK, but LLM is right that this would open the door to MANY keys, plus the wallets could not know which one to use without modifying the protocol, so this is cleaner. |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-07-27 22:08 | Florian Dold | New Issue | |
| 2026-07-27 22:08 | Florian Dold | Status | new => assigned |
| 2026-07-27 22:08 | Florian Dold | Assigned To | => Christian Grothoff |
| 2026-07-28 16:22 | Christian Grothoff | Status | assigned => resolved |
| 2026-07-28 16:22 | Christian Grothoff | Resolution | open => fixed |
| 2026-07-28 16:22 | Christian Grothoff | Fixed in Version | => 1.6 |
| 2026-07-28 16:22 | Christian Grothoff | Note Added: 0029251 |