20.97. DD 97: Project Sesame — Anonymous Access Control#

Status: draft (2026-07-17)

20.97.1. Summary#

This design document describes how the Taler eco-system can be used to enable anonymous access to different physical access control systems. A wallet obtains blind-signed tokens (one per access group) from the merchant backend via a new fountain credential. To open a door, the wallet reads a challenge from the (offline) lock, instantiates an order from the door’s template, pays with its group token (consuming it and receiving a replacement via blind signature), and relays the resulting signed confirmation (pos_confirmation) back to the lock, which verifies it without any network connection. The merchant backend learns that someone from a given access group opened a given door, but not who.

The design reuses existing merchant features — token families, templates, OTP devices, v1 contract choices and blind signatures — and adds one new concept (the fountain) plus a small number of protocol extensions. All protocol changes proposed here are annotated with “Since protocol vOpenSesame”; the placeholder is to be replaced with the concrete merchant protocol version number once the changes are implemented.

20.97.2. Motivation#

Conventional electronic access control systems identify the user on every door opening, producing detailed movement profiles. At the same time, purely offline mechanical or RFID solutions lack revocability, usage statistics and flexible group management.

The Taler merchant backend already contains almost all building blocks for a privacy-preserving alternative:

  • Token families provide blind-signed, single-use, time-sliced tokens (see DD 46 and DD 68).

  • Templates allow order creation without merchant-side frontend logic.

  • OTP devices allow generation of confirmation (pos_confirmation) for successful payments, which an offline device (door) can verify.

Beyond pure access control, the same infrastructure supports paying to open a door (parking garage, laundry room): a template choice with a non-zero amount is an ordinary Taler payment.

20.97.3. Requirements#

  • Good UX: the user taps the door with a phone; the door opens within well under a second.

  • Anonymity within the group: the merchant backend learns “someone from group X opened door Y at time T”, but cannot identify the individual user, nor link accesses across doors to the same user.

  • Offline locks: the lock verifies a confirmation without any network connection. Only the wallet and the merchant backend need Internet connectivity.

  • Per-group tokens: one blind-signed token per access group, not per door. A user in two groups holds two tokens regardless of the number of doors.

  • Revocability: revocation latency is configurable per access group (e.g. 15 minutes for a server room, 30 days for a co-working space). Revocation means: stop issuing tokens.

  • Usage statistics: every door opening is recorded at the merchant backend (door, group, timestamp) for maintenance and reporting, without identifying users.

  • Payment support: doors may additionally (or exclusively) be opened against payment in digital cash.

  • IAM decoupling: identity management stays outside the merchant backend. Any identity/access management system can provision fountains via the private API.

  • Minimal Taler changes: reuse token families, templates, OTP devices and the pay protocol; add only the fountain and small extensions to existing endpoints.

20.97.4. Proposed Solution#

20.97.4.1. Business view#

This section is the non-technical summary; the protocol description starts at Architecture.

Employees or members carry a digital key in the Taler Wallet app on their phone. Opening a door means tapping the phone on the reader: the phone contacts the Taler service over the Internet and shows the door a signed permit. The doors themselves need no network connection and no per-user configuration.

Opening a door, business view: tap the phone, the phone trades an anonymous group pass for a signed permit, the door checks the permit offline and opens.

Opening a door (business view)#

The key is anonymous within a group: access management (the door owner) sees that some member of, say, the staff group opened a door, which is enough for maintenance planning and billing, but cannot tell which member it was and cannot build movement profiles.

Getting the key is a one-time sign-up with the company account. Access management registers a digital key for the person’s access groups and the phone receives it as a QR code or link. From then on the wallet app picks up its passes by itself in the background. The sign-up is personal, but the passes obtained with the key are not: the service cannot link a pass to the sign-up it came from, which is what makes door openings anonymous.

Sign-up, business view: sign in with the company account, access management registers a digital key, the phone scans a QR code and from then on picks up anonymous passes automatically.

Sign-up and pass pick-up (business view)#

Access is managed per group, not per door. Group membership comes from the existing IAM or HR system. When a person leaves, access management stops their key with one API call; passes already on the phone expire on their own within a period configured per area (15 minutes for a server room, days for a co-working space).

Access lifecycle, business view: person joins, phone receives the digital key, doors open during membership, key is stopped on leaving and remaining passes expire on their own.

Access lifecycle (business view)#

The same setup can also charge for opening a door (parking garage, laundry room): a door can accept payment in digital cash instead of, or in addition to, a group pass.

20.97.4.2. Architecture#

Component

Role

Network

IAM / identity provider (e.g. OIDC)

Authenticates users towards the access control operator.

online

Access control system (ACS)

Manages doors, access groups and user→group mappings; provisions token families, templates, OTP devices and fountains at the merchant backend via the private API.

online

Merchant backend

Issues blind-signed tokens, verifies token payments, computes pos_confirmation for locks.

online

Taler Wallet

Holds fountain secret and tokens; bridges lock and merchant backend.

online

Lock

Generates challenges, verifies pos_confirmation, opens the door.

offline

Deployment view: wallet bridges the offline lock and the merchant backend; the ACS provisions the merchant backend and configures locks out-of-band.

Deployment view#

20.97.4.3. Cryptographic building blocks#

Primitive

Purpose

Instantiation

Blind signature

Token issuance (withdrawal and re-issue)

existing Taler token family code (RSA/CS)

Signature over challenge (pos_confirmation)

Merchant confirmation to the lock

ECDSA (P-256) or EdDSA, new

TOTP

Door challenge / replay prevention

existing OTP devices

20.97.4.4. Data model mapping#

Data model mapping: access group to token family (1:1), door to template plus OTP device (1:1:1), user to fountain (1:1); template choices and fountain grants reference token families.

Data model mapping#

A door with a distinct access policy needs its own door id (its own OTP device and template). Doors MAY share one door id if their access policy is identical, at the cost of losing per-door usage statistics.

20.97.4.5. Access groups as token families#

Each access group is an ordinary token family created via POST [/instances/$INSTANCE]/private/tokenfamilies with kind: "subscription" — no new token family kind is introduced. The merchant backend already auto-generates one issue key per validity_granularity time slot; the hash of the issue key (h_issue) thus identifies group and time slot. Different groups can use different duration values:

  • server room: 15 minutes,

  • office building: 1 day,

  • co-working space: 30 days.

The token duration bounds the revocation latency (see Revocation).

20.97.4.6. Doors as OTP devices and templates#

Each door is registered as an OTP device (POST [/instances/$INSTANCE]/private/otp-devices) and gets a template (POST [/instances/$INSTANCE]/private/templates) whose otp_id references the device. The otp_algorithm determines how the merchant backend computes the pos_confirmation that the lock verifies:

Value

Name

pos_confirmation

0

NONE

none (existing)

1

TOTP_WITHOUT_PRICE

TOTP code, shared-secret verification (existing)

2

TOTP_WITH_PRICE

TOTP code including price (existing)

3

ECDSA_CHALLENGE

ECDSA signature over the order’s challenge (new, since protocol vOpenSesame)

4

EDDSA_CHALLENGE

EdDSA signature over the order’s challenge (new, since protocol vOpenSesame)

For the challenge-signature algorithms (3 and 4) the key pair is generated by the merchant backend; the private key is never exposed. The public key is returned on device creation and the ACS configures it into the lock. For TOTP algorithms the lock shares the otp_key secret as today.

The lock itself is configured out-of-band (not stored at the merchant) with: door id, challenge mode (TOTP or nonce), and either the TOTP secret or the device public key. Challenge handling at the lock:

Mode

Lock generates

Lock verifies

TOTP

nothing

recomputes TOTP code with its own clock

Nonce

random 32 bytes

signature verification of pos_confirmation over its own nonce with the device public key

The door template ties the device to the access groups allowed to open it. Since protocol vOpenSesame, fixed-order templates can carry v1 contract choices (see Extended: templates with token choices). Example (informal):

TemplateCreateRequest {
  template_id:          "door-201",
  template_description: "Office building room 201",
  otp_id:               "door-201",
  template_contract: {
    choices: [
      { // staff members: consume one token, get a replacement
        amount:  "KUDOS:0",
        inputs:  [ { type: "token", token_family_slug: "staff" } ],
        outputs: [ { type: "token", token_family_slug: "staff",
                     reissue_with_input_key: true } ]
      },
      { // facilities team: different group, same door
        amount:  "KUDOS:0",
        inputs:  [ { type: "token", token_family_slug: "facilities" } ],
        outputs: [ { type: "token", token_family_slug: "facilities",
                     reissue_with_input_key: true } ]
      },
      { // visitors: pay to open
        amount:  "EUR:5"
      }
    ]
  }
}

Multiple choices mean multiple access groups (or payment options) for the same door; the wallet picks the choice matching a token family it holds. Each token choice consumes one token (input) and re-issues one (output) — the single-use replacement mechanism. A one-time access pass (visitor) is simply a choice without a token output, paired with a discount-kind token family; no separate mechanism is needed.

20.97.4.6.1. Same-key constraint#

When a choice consumes and re-issues a token of the same family, the replacement token must be signed with the same issue key as the consumed token — not with the key of the current time slot. This is what the new reissue_with_input_key flag on OrderOutputToken expresses.

Without this constraint, a revoked user could keep obtaining replacement tokens under ever-newer keys through door accesses alone, bypassing the fountain (where revocation is enforced) indefinitely. With the constraint, the replacement expires together with the consumed token, forcing every wallet back to the fountain once per key slot.

20.97.4.7. Users as fountains#

A fountain is a per-user bearer credential at the merchant backend that entitles a wallet to withdraw blind-signed tokens for a set of token families. It is the only new concept introduced by this design.

  • One fountain per user, created by the ACS at user onboarding.

  • Each fountain holds a 256-bit fountain_secret (given to the user’s wallet) and a list of grants, one per access group.

  • Adding/removing group memberships: PATCH the fountain’s grants; the secret stays valid.

  • Revoking the user entirely: DELETE the fountain.

The wallet receives the credential as a new URI (an extension of LSD 0006 is required):

taler://fountain/$MERCHANT_HOST[/$INSTANCE_PATH]/$FOUNTAIN_SECRET

Each grant carries three parameters:

  • tokens_per_period_limit: how many tokens the fountain will blind-sign per key validity period (rate limit; relevant for non-subscription families such as one-time passes).

  • tokens_per_period_stash: how many tokens the wallet should hold per period (client-side target).

  • key_window_size: how many key slots ahead of the current one the wallet may withdraw for. With window size 3 and a duration of one day, the wallet can prefetch tokens for today, tomorrow and the day after, reducing fountain round trips — at the cost of revocation latency (see Revocation).

20.97.4.8. Protocol flows#

20.97.4.8.1. System setup#

Performed by the ACS through the private merchant API:

  1. For each access group: POST [/instances/$INSTANCE]/private/tokenfamilies (kind: "subscription", per-group duration and validity_granularity).

  2. For each door: POST [/instances/$INSTANCE]/private/otp-devices with otp_algorithm 1 (TOTP lock) or 3/4 (challenge-signature lock). For 3/4 the response contains the generated device public key.

  3. For each door: POST [/instances/$INSTANCE]/private/templates with otp_id = device id and one contract choice per authorized group.

  4. The ACS configures the physical lock with door id, challenge mode and the TOTP secret or device public key.

20.97.4.8.2. User onboarding#

  1. The user authenticates towards the ACS via the IAM (e.g. OIDC).

  2. The ACS resolves the user’s roles to access groups.

  3. The ACS creates the fountain: POST /private/fountains with one grant per group.

  4. The ACS hands the resulting taler://fountain/... URI to the user’s wallet (QR code, deep link, …).

  5. The wallet stores the fountain URI, fetches the fountain info and withdraws its initial tokens (next section).

Onboarding sequence: IAM login, ACS resolves groups, creates the fountain at the merchant backend, hands the fountain URI to the wallet.

User onboarding#

20.97.4.8.3. Token withdrawal via fountain#

The fountain secret is never placed in a URL (it would leak into server logs and proxies): it travels in an Authorization: Bearer header for GET and in the request body for POST.

Withdrawal sequence: wallet fetches grants and issue keys via GET /fountain/info, then submits blinded envelopes via POST /fountain/withdraw and unblinds the returned signatures.

Token withdrawal via fountain#

The wallet re-polls GET /fountain/info at the advertised poll_freq to learn about grant changes (added or removed groups).

20.97.4.8.4. Offline door access#

Door access sequence: lock hands the challenge to the wallet over NFC, the wallet creates and pays an order at the merchant backend with its group token, and presents the resulting pos_confirmation to the lock.

Offline door access#

The two HTTP round trips (order creation and payment) dominate the door opening latency; the cryptography (ECDSA verification on the lock MCU with hardware acceleration) is on the order of 15 ms.

20.97.4.8.5. Token renewal#

Tokens are replaced inline on every door access, but all tokens of a slot expire together with the slot’s issue key (enforced by the same-key constraint). Before expiry — with a grace period of, e.g., a quarter of the validity period — the wallet performs a regular fountain withdrawal (previous section) using background scheduling (Android WorkManager / iOS BGTaskScheduler). If background execution is unavailable, renewal happens on next app start, possibly making the user wait briefly at the door.

20.97.4.8.6. Revocation#

Because tokens are blind-signed, the merchant backend cannot selectively invalidate a token it has never seen. Revocation therefore means stop issuing:

  • Full revocation: DELETE /private/fountains/$FOUNTAIN_ID. The wallet’s next fountain request fails with 401; existing tokens die with their issue key.

  • Partial revocation (user leaves one group): PATCH /private/fountains/$FOUNTAIN_ID with the remaining grants. The secret stays valid; the wallet learns about the reduced grants on its next GET /fountain/info poll and drops the affected tokens.

The maximum revocation delay is duration × key_window_size: prefetched tokens for future slots remain usable until their keys expire. Operators choose these parameters per group accordingly. Other users of the group are unaffected. The same-key constraint is what makes this bound hold (see Same-key constraint).

20.97.4.8.7. Usage statistics#

No new mechanism: the existing pay webhook (WebhookEventType) fires on every successful door opening and carries the paid contract terms and order id, from which the ACS derives door id, token family and timestamp.

20.97.4.9. Protocol changes (vOpenSesame)#

20.97.4.9.1. Endpoints used unchanged#

20.97.4.9.2. Extended: challenge-signature OTP devices#

POST [/instances/$INSTANCE]/private/otp-devices accepts two new otp_algorithm values and, for these, generates the key pair itself:

interface OtpDeviceAddDetails {

  // Device ID to use.
  otp_device_id: string;

  // Human-readable description for the device.
  otp_device_description: string;

  // A key encoded with RFC 3548 Base32.
  // IMPORTANT: This is not using the typical
  // Taler base32-crockford encoding.
  // Instead it uses the RFC 3548 encoding to
  // be compatible with the TOTP standard.
  // Since protocol **vOpenSesame** this field is optional:
  // it MUST be omitted for the challenge-signature
  // algorithms (3 and 4), where the backend generates
  // the key pair and never exposes the private key.
  otp_key?: string;

  // Algorithm for computing the POS confirmation.
  // "NONE" or 0: No algorithm (no pos confirmation will be generated)
  // "TOTP_WITHOUT_PRICE" or 1: Without amounts (typical OTP device)
  // "TOTP_WITH_PRICE" or 2: With amounts (special-purpose OTP device)
  // "ECDSA_CHALLENGE" or 3: ECDSA signature over the order's
  //   challenge. Since protocol **vOpenSesame**.
  // "EDDSA_CHALLENGE" or 4: EdDSA signature over the order's
  //   challenge. Since protocol **vOpenSesame**.
  otp_algorithm: Integer | string;

  // Counter for counter-based OTP devices.
  otp_ctr?: Integer;
}

For algorithms 3 and 4 the response changes from 204 No content to 200 OK with:

interface OtpDeviceCreateResponse {

  // Public key of the key pair generated by the backend
  // for this device, to be configured into the offline
  // verifier (lock). For "ECDSA_CHALLENGE" a compressed
  // NIST P-256 point, for "EDDSA_CHALLENGE" an Ed25519
  // public key. Crockford Base32 encoded.
  // Since protocol **vOpenSesame**.
  otp_device_pub: string;
}

OtpDeviceDetails (returned by GET [/instances/$INSTANCE]/private/otp-devices/$DEVICE_ID) gains the same field:

// Public key of the device key pair, present only for
// challenge-signature algorithms (3 and 4).
// Since protocol **vOpenSesame**.
otp_device_pub?: string;

The corresponding C enum TALER_MerchantConfirmationAlgorithm (today TALER_MCA_NONE = 0, TALER_MCA_WITHOUT_PRICE = 1, TALER_MCA_WITH_PRICE = 2) is extended with TALER_MCA_ECDSA_CHALLENGE = 3 and TALER_MCA_EDDSA_CHALLENGE = 4, and TALER_build_pos_confirmation() learns to sign the hash of the order’s challenge with the device private key. In the PaymentResponse, pos_confirmation then carries the Crockford Base32 encoded signature; the JSON schema is unchanged.

20.97.4.9.3. Extended: templates with token choices#

Fixed-order templates learn to describe v1 contracts. TemplateContractFixedOrder is extended:

interface TemplateContractFixedOrder {

  // The price is imposed by the merchant and cannot be
  // changed by the customer.
  // This parameter is optional.
  amount?: Amount;

  // Contract choices for orders created from this
  // template. If present, instantiated orders are
  // version 1 contracts with exactly these choices
  // (token inputs/outputs, see OrderChoice), enabling
  // token-gated and token-re-issuing templates.
  // Must not be combined with amount.
  // Since protocol **vOpenSesame**.
  choices?: OrderChoice[];
}

20.97.4.9.4. Extended: same-key re-issue constraint#

OrderOutputToken gains a flag that binds the issue key of an output token to the input token consumed in the same choice:

interface OrderOutputToken {

  // Token output.
  type: "token";

  // Token family slug as configured in the merchant backend.
  token_family_slug: string;

  // How many units of the output are issued by the merchant.
  // Defaults to 1 if not specified.
  count?: Integer;

  // When should the output token be valid.
  valid_at?: Timestamp;

  // If true, the output tokens are blind-signed with the
  // same issue key (same h_issue) as the input token
  // of the same token family consumed by this choice,
  // instead of the key of the current time slot. The
  // choice MUST then also contain an OrderInputToken
  // for the same token_family_slug, and this field
  // must not be combined with valid_at.
  // Since protocol **vOpenSesame**.
  reissue_with_input_key?: boolean;
}

At pay time, POST [/instances/$INSTANCE]/orders/$ORDER_ID/pay selects the issue key matching the h_issue of the presented input token for such outputs. The effective key MUST be one of the keys listed for the family in the contract’s ContractTokenFamily keys array; the key_index in ContractOutputToken designates the key for the current slot and is overridden by this rule (see Discussion / Q&A).

20.97.4.9.5. Extended: challenge pass-through on template use#

POST [/instances/$INSTANCE]/templates/$TEMPLATE_ID accepts a client-supplied challenge that the backend stores with the order and later signs when computing the pos_confirmation:

interface UsingTemplateCommonRequest {

  // Type of the template being instantiated.
  template_type: string;

  // Summary to use in the contract. Only if
  // not already specified by the template.
  summary?: string;

  // The amount to be paid, including tip.
  amount?: Amount;

  // Optional tip amount.
  tip?: Amount;

  // Challenge obtained out-of-band (e.g. read from an
  // offline lock over NFC): 32 bytes, Crockford Base32
  // encoded. Only meaningful — and then mandatory — if
  // the template's OTP device uses a challenge-signature
  // algorithm (3 or 4); rejected otherwise.
  // Since protocol **vOpenSesame**.
  challenge?: string;
}

20.97.4.9.6. New: fountain management (private API)#

All fountain endpoints are new since protocol vOpenSesame.

POST [/instances/$INSTANCE]/private/fountains

Creates a fountain. Request:

interface FountainCreateRequest {

  // Human-readable description; typically an opaque user
  // identifier of the provisioning access control system.
  // Note that the merchant backend does not need — and
  // should not receive — the user's real identity.
  description: string;

  // How often wallets holding this fountain's secret
  // should re-poll GET /fountain/info for updated
  // grants.
  poll_freq: RelativeTime;

  // One grant per token family (access group) the user
  // may withdraw tokens for.
  grants: FountainGrant[];
}
interface FountainGrant {

  // Slug of the token family this grant refers to.
  token_family_slug: string;

  // Maximum number of tokens the fountain will
  // blind-sign per key validity period for this family.
  tokens_per_period_limit: Integer;

  // Number of tokens the wallet should aim to hold per
  // period (client-side stocking target; must not
  // exceed tokens_per_period_limit).
  tokens_per_period_stash: Integer;

  // Number of key slots ahead of the current one the
  // wallet may withdraw tokens for. Bounds prefetching
  // and thereby the revocation latency.
  key_window_size: Integer;
}

Response (200 OK):

interface FountainCreateResponse {

  // Identifier of the fountain, used by the access
  // control system for subsequent CRUD operations.
  fountain_id: string;

  // 256-bit bearer credential for the wallet,
  // Crockford Base32 encoded. Returned exactly once;
  // the backend stores only a hash of it.
  fountain_secret: string;
}

GET [/instances/$INSTANCE]/private/fountains

Lists all fountains of the instance (fountain_id and description per entry, following the conventions of the other private list endpoints).

GET [/instances/$INSTANCE]/private/fountains/$FOUNTAIN_ID

Returns the fountain’s description, poll_freq and grants (never the secret).

PATCH [/instances/$INSTANCE]/private/fountains/$FOUNTAIN_ID

Replaces description, poll_freq and/or grants. Used for group membership changes and partial revocation; the fountain secret remains valid.

DELETE [/instances/$INSTANCE]/private/fountains/$FOUNTAIN_ID

Deletes the fountain: full revocation of the user. Subsequent wallet requests with the secret fail with 401 Unauthorized.

20.97.4.9.7. New: fountain wallet API (public)#

GET [/instances/$INSTANCE]/fountain/info

Authenticated with Authorization: Bearer $FOUNTAIN_SECRET (the secret is deliberately kept out of URLs to avoid leakage into logs and proxies). Response (200 OK):

interface FountainInfoResponse {

  // How often the wallet should re-poll this endpoint.
  poll_freq: RelativeTime;

  // Grants of this fountain, enriched with the token
  // family metadata and issue keys the wallet needs to
  // prepare blinded envelopes.
  grants: FountainWalletGrant[];
}
interface FountainWalletGrant {

  // Slug of the token family.
  token_family_slug: string;

  // Maximum withdrawals per key validity period.
  tokens_per_period_limit: Integer;

  // Suggested number of tokens to hold per period.
  tokens_per_period_stash: Integer;

  // Number of key slots ahead the wallet may withdraw for.
  key_window_size: Integer;

  // Token family metadata including the currently valid
  // (and, within key_window_size, upcoming) issue
  // public keys.
  token_family: ContractTokenFamily;
}

Error responses: 401 Unauthorized if the secret is unknown (including deleted fountains — the wallet then reports the access as revoked).

POST [/instances/$INSTANCE]/fountain/withdraw

Withdraws blind-signed tokens for any subset of the fountain’s grants in a single request. The request is structured per grant: each entry addresses one granted token family and one key slot, and carries the envelopes to sign against that grant’s limits:

interface FountainWithdrawRequest {

  // The fountain's bearer credential.
  fountain_secret: string;

  // One entry per (granted token family, key slot)
  // being withdrawn from. At most one entry per such
  // pair.
  grants: FountainGrantWithdrawal[];
}
interface FountainGrantWithdrawal {

  // Token family to withdraw from. Must match one of
  // the fountain's grants.
  token_family_slug: string;

  // Desired validity time of the tokens; selects the
  // issue key slot. Must lie within the grant's
  // key_window_size. Defaults to "now".
  valid_at?: Timestamp;

  // Blinded envelopes to sign with the selected slot's
  // issue key, as in the pay protocol. The number of
  // envelopes is counted against the grant's
  // tokens_per_period_limit for that slot.
  envelopes: TokenEnvelope[];
}

Response (200 OK):

interface FountainWithdrawResponse {

  // One entry per entry in the request's grants
  // array, in the same order.
  grants: FountainGrantWithdrawalResult[];
}
interface FountainGrantWithdrawalResult {

  // Token family these signatures belong to.
  token_family_slug: string;

  // Hash of the issue public key that was used, so the
  // wallet can associate the tokens with the right slot.
  h_issue: HashCode;

  // Blind signatures, in the same order as the
  // envelopes of the request entry, as in the pay
  // protocol (TokenIssueBlindSig).
  blind_sigs: TokenIssueBlindSig[];
}

Error responses: 401 Unauthorized (unknown secret), 409 Conflict (entry for a family without grant, or valid_at outside the grant’s key window), 429 Too Many Requests (tokens_per_period_limit exhausted for a grant and slot).

20.97.4.9.8. New URI scheme#

taler://fountain/$MERCHANT_HOST[/$INSTANCE_PATH]/$FOUNTAIN_SECRET tells a wallet where to find the fountain and how to authenticate. This requires an extension of LSD 0006 and corresponding wallet-core support (new transaction/subscription type holding the fountain state, background renewal scheduling).

20.97.4.10. Security considerations#

20.97.4.10.1. Threat model#

Attacker

Goal

Prevented by

NFC eavesdropper

replay an access response

single-use challenge (nonce discarded, TOTP expires)

wallet of wrong group

open unauthorized door

template choices name specific token families; pay fails for tokens of other families

revoked wallet

keep access after revocation

same-key constraint: re-issued tokens die with the original key; re-withdrawal requires the fountain, which is gone

token thief

use a stolen token

tokens are single-use; the legitimate user’s next access fails, exposing the theft

colluding doors

track a user across doors

lock only sees pos_confirmation for its own challenge; blind signatures unlink accesses

compromised lock

forge confirmations

lock holds only the public key (or TOTP secret for low-security doors); device private key stays at the backend

ACS + merchant collusion

deanonymize a user

blind signatures unlink issuance from use; the merchant knows only “someone from group X”

fountain secret thief

withdraw tokens

equivalent to a stolen physical key; fountain can be deleted and re-created with a new secret

20.97.4.10.2. Privacy properties#

  • Lock sees only pos_confirmation and time — no user or group information.

  • Merchant backend sees at withdrawal only the fountain secret (no identity); at door access only door id, token family and time. Blind signatures prevent linking a door access to any withdrawal event or to other accesses by the same user.

  • Anonymity set: all users holding valid tokens of the same family (per time slot).

  • ACS knows the identity→fountain mapping but never sees door accesses of individual users.

  • Residual risk — timing correlation: withdrawal events (authenticated by fountain secret) and door accesses could be correlated by timing in backend logs. Inline replacement tokens are not withdrawals, so the correlation window is limited to the periodic per-slot renewal; wallets SHOULD randomize renewal times within the grace period.

20.97.5. Test Plan#

  • Merchant backend integration tests covering: challenge-signature OTP device lifecycle (create → key returned → pay → pos_confirmation verifies against the returned public key); templates with token choices; same-key re-issue (replacement token verifiably signed by the input token’s key, including across slot boundaries); fountain CRUD, grant limits, window enforcement, and revocation semantics (401 after DELETE, reduced grants after PATCH).

  • Wallet-core integration test: full cycle fountain URI → info → withdraw → template order with challenge → pay with token → unblind replacement → renewal after key expiry.

  • Lock-side verification tested against a software lock simulator (nonce and TOTP modes) before hardware integration.

20.97.6. Definition of Done#

  • Protocol changes ratified and merged into the merchant API reference (core/api-merchant.rst) with the final protocol version number replacing vOpenSesame.

  • Merchant backend implements the extended and new endpoints, guarded by the protocol version bump.

  • LSD 0006 extension for taler://fountain published.

  • Wallet-core implements fountain handling and background renewal.

  • End-to-end demo with at least one nonce-mode (challenge-signature) and one TOTP-mode door.

20.97.7. Alternatives#

  • Per-door token families instead of per-group: simpler template choices, but token count and fountain traffic grow with the number of doors, and the anonymity set shrinks per door.

  • Online locks: the merchant backend (or ACS) opens the door directly over the network, removing one NFC round trip. Rejected as baseline: requires connected locks; may be revisited as an optimization (see Discussion).

  • Selling access tokens as ordinary orders (existing subscription purchase flow) instead of fountains: works without new endpoints, but provides no per-user revocable issuance channel, no per-period limits, and requires a payment interaction for what is an entitlement, complicating IAM integration.

  • Exchange coins instead of merchant tokens: would provide anonymity but has no notion of merchant-scoped groups, per-slot keys or free re-issue, and would entangle access control with real money flows.

20.97.8. Drawbacks#

  • Revocation latency is bounded, not zero: up to duration × key_window_size. Short slots (server room) mean more frequent wallet renewals and more issue keys.

  • Two HTTP round trips per door opening put the latency budget at the mercy of the phone’s network conditions (concrete/metal buildings, Wi-Fi↔mobile handover); see Discussion.

  • Background renewal depends on OS task scheduling; users who block background activity may wait at the door.

  • New credential class (fountain secret) at the merchant backend increases the authentication surface of the public API.

  • The merchant backend accumulates per-group, per-door usage data; operators must treat these logs as personal-data-adjacent even though they name no user.

20.97.9. Discussion / Q&A#

  • Same-key constraint mechanics. Binding the output key to the input token (reissue_with_input_key) was chosen over “output key must equal current slot” because a payment near a slot boundary may legitimately consume a token of the previous slot. Open question: how to represent the overridden key_index in the contract terms cleanly — the current proposal keeps key_index pointing at the current-slot key and overrides it at pay time, which is implicit; an explicit marker value may be preferable.

  • Latency budget. Estimated door opening time is ~250–550 ms, dominated by the two HTTP round trips (order creation + pay), not by cryptography (P-256 verification on an MCU with hardware acceleration is ~15 ms). These numbers need real measurements. If they disappoint, candidate optimizations: pre-created orders per door, combining order creation and payment into one request, or the online-lock mode below.

  • Wallet template prefetch. The wallet only learns which token families a door accepts from the (cacheable) template. A reverse lookup (“which door ids accept family X”) would let the wallet skip order creation for doors it cannot open, but requires distributing door lists to wallets and keeping them fresh; deferred for marginal UX gain.

  • Future work (v2):

    • HOTP counter mode for clockless offline doors: the merchant maintains a monotonic counter per device and returns { ctr, HOTP(secret, ctr) }; the lock accepts iff ctr > last_seen_ctr. Note that otp_ctr is already stored by the backend today but unused by TALER_build_pos_confirmation(), so this needs new generation logic.

    • Forced key rotation (e.g. POST /private/tokenfamilies/$SLUG/rotate-keys) for immediate revocation, at the cost of forcing all wallets of the group to re-withdraw.

    • Online door mode: backend-initiated opening via a callback to the operator’s system, trading offline capability for lower interaction latency.