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)#
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 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)#
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
|
online |
Taler Wallet |
Holds fountain secret and tokens; bridges lock and merchant backend. |
online |
Lock |
Generates challenges, verifies |
offline |
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 ( |
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#
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 |
|
|---|---|---|
0 |
|
none (existing) |
1 |
|
TOTP code, shared-secret verification (existing) |
2 |
|
TOTP code including price (existing) |
3 |
|
ECDSA signature over the order’s challenge (new, since protocol vOpenSesame) |
4 |
|
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 |
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:
PATCHthe fountain’s grants; the secret stays valid.Revoking the user entirely:
DELETEthe 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:
For each access group:
POST [/instances/$INSTANCE]/private/tokenfamilies(kind: "subscription", per-groupdurationandvalidity_granularity).For each door:
POST [/instances/$INSTANCE]/private/otp-deviceswithotp_algorithm1 (TOTP lock) or 3/4 (challenge-signature lock). For 3/4 the response contains the generated device public key.For each door:
POST [/instances/$INSTANCE]/private/templateswithotp_id= device id and one contract choice per authorized group.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#
The user authenticates towards the ACS via the IAM (e.g. OIDC).
The ACS resolves the user’s roles to access groups.
The ACS creates the fountain:
POST /private/fountainswith one grant per group.The ACS hands the resulting
taler://fountain/...URI to the user’s wallet (QR code, deep link, …).The wallet stores the fountain URI, fetches the fountain info and withdraws its initial tokens (next section).
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.
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#
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_IDwith the remaining grants. The secret stays valid; the wallet learns about the reduced grants on its nextGET /fountain/infopoll 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#
POST [/instances/$INSTANCE]/private/tokenfamilies— create access group.GET [/instances/$INSTANCE]/templates/$TEMPLATE_ID— wallet fetches template details.POST [/instances/$INSTANCE]/orders/$ORDER_ID/claimandPOST [/instances/$INSTANCE]/orders/$ORDER_ID/pay— token spend/re-issue via the existing PayRequest / PaymentResponse structures (tokens,wallet_data.tokens_evs,token_sigs,pos_confirmation).Webhooks (
payevent) for usage statistics.
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 |
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_confirmationand 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_confirmationverifies 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://fountainpublished.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 overriddenkey_indexin the contract terms cleanly — the current proposal keepskey_indexpointing 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 iffctr > last_seen_ctr. Note thatotp_ctris already stored by the backend today but unused byTALER_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.