Skip to content

feat(app): new token issuance - #1438

Open
raphaelrobert wants to merge 8 commits into
mainfrom
raphael/new-token-issuance
Open

feat(app): new token issuance#1438
raphaelrobert wants to merge 8 commits into
mainfrom
raphael/new-token-issuance

Conversation

@raphaelrobert

Copy link
Copy Markdown
Collaborator

Token batches are now derived from a seed the user's devices share, so issuance is idempotent and every device holds the same tokens. A repeat of a request is answered from the batch the server already locked, which makes retries and rotation recovery free.
This PR also fixes issues in the old issuance protocol around key rotation.

Token issuance

  • The auth service gains an endpoint that issues one token batch per user, operation type, allowance epoch and VOPRF key.
  • The token request is derived from the token seed with HKDF, so the server can answer any repeat of it without granting a second batch.
  • The server keeps only the hash of the request it accepted first and answers a different request for the same allowance epoch with a conflict outcome.
  • A batch has to cover the full allowance, which is what keeps requests byte-identical across devices and stops a request from leaking how many tokens are left.
  • An allowance epoch is a calendar bucket, months since the Unix epoch for username operations and days for invite codes, derived by client and server from the same rule.
  • A claimed epoch within ten minutes of a boundary is accepted, so a request made around midnight is not rejected for landing on the adjacent bucket.
  • An epoch further off comes back with the epoch the server considers current, which the client adopts rather than deriving again from its own clock.
  • No allowance counter is read or written on this path, and the counter-metered endpoint stays in place for clients that predate the change.

Key rotation

  • The advertised key list marks the newest key of each operation type as the current one, and clients build new requests with it.
  • Only the current key is guaranteed to outlive the tokens minted under it, so the marker keeps a client inside the overlap window from minting tokens under the outgoing key.
  • Rotation runs under a per-operation-type advisory lock, so replicas sharing a database cannot both find a key stale and create one each.
  • Cached tokens record the key that issued them, so a rotation discards only the tokens of keys the server stopped advertising.
  • Tokens are also discarded when the key material behind a reused truncated key ID changes, because such a token fails redemption in a way that bypasses the purge recovery.
  • Issuance records are pruned once their allowance epoch can no longer be claimed.

Multi-device seed agreement

  • A token seed covers one operation type and one key, and reaches the user's other devices as an encrypted self-group message.
  • A seed is set once with first-writer-wins, and the commit order of the delivery service decides which proposal becomes the agreed one.
  • A device with siblings keeps a fresh seed as a proposal and derives no request from it until its commit is accepted.
  • A device that is alone in its self group commits the seed it generates without an agreement round.
  • Two agreed seeds can only diverge when a device joined without a snapshot, and then the lower seed wins and its holder re-broadcasts it so the other side converges.
  • A newly linked device receives the agreed seeds in its provisioning package, so it does not open an agreement round for a key whose allowance epoch its sibling already locked.
  • Seeds travel on a commit of their own rather than on the settings snapshot, which carries last-writer-wins values and would cover an in-flight proposal.
  • A terminally failed seed commit drops its proposals, so a later replenishment run proposes again.

Client replenishment

  • Replenishment refreshes the key set on every run, so a client whose cache never runs low still learns about a rotation.
  • It then fetches the batch of the current allowance epoch, or reports that the devices still have to agree on a seed.
  • The next run is scheduled in five minutes while something has to converge and in six hours once there is nothing left to fetch.
  • Quota handling and the low-token threshold are gone from the client, because a repeat request draws no new allowance.
  • A rotation reported at redemption time refreshes the keys, drops the stale tokens and re-derives the batch.

Storage

  • A server table records one issuance per user, operation type, allowance epoch, and key fingerprint.
  • Client tables hold the token seeds with their agreement state, the batches already fetched, and the key and batch position of each token.
  • Cached tokens are unique by their bytes, so re-fetching a batch inserts nothing instead of duplicating rows.
  • Tokens from the counter-metered path carry no batch tag, stay spendable, and are drained first by the existing consumption order.

@raphaelrobert
raphaelrobert marked this pull request as ready for review August 13, 2026 17:57
IssuanceConflict issuance_conflict = 2;
// The claimed allowance epoch is not current on the server.
InvalidAllowanceEpoch invalid_allowance_epoch = 3;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't these errors be part of a grpc status message?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think they are actually normal outcomes of this endpoint, that will happen in the normal multi client scenario. So, the client must handle them in any case. I think it fine to map them here, instead to grpc status codes.

));
}

let now = Utc::now();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Wouldn't it make sense to take that as input (at least in an internal helper) so we can test the exact epoch transitions?

info.extend_from_slice(key_fingerprint);
info.extend_from_slice(&allowance_epoch.to_be_bytes());

expand(token_seed, &info)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In other places we use a struct with TLS serialization for this sort of thing. But since this is all constant length, I guess this works, too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants