View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011692 | Taler | merchant backend | public | 2026-07-27 22:08 | 2026-07-27 22:08 |
| Reporter | Florian Dold | Assigned To | Christian Grothoff | ||
| Priority | normal | Severity | minor | Reproducibility | always |
| Status | assigned | Resolution | open | ||
| Target 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. | ||||
| 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 |