Skip to content

Let KeyStore own PIN storage and verification - #838

Open
lovasoa wants to merge 4 commits into
google:developfrom
lovasoa:pin-key-store-api
Open

lovasoa wants to merge 4 commits into
google:developfrom
lovasoa:pin-key-store-api

Conversation

@lovasoa

@lovasoa lovasoa commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Hi @kaczmarczyck ! The code and description below are fully written by codex, but we went over multiple iterations of it, and I quite like the final result.

I have a draft pr in fidophir that switches to this new api, and it's a nice simplification: lovasoa/fidophir#15


CTAP currently reads the configured PIN hash from Persist and passes it to KeyStore. Let software and hardware backends own PIN storage and verification through the public KeyStore interface:

fn has_pin(&mut self) -> CtapResult<bool>;
fn verify_pin_hash(&mut self, candidate: &[u8; 16]) -> CtapResult<bool>;
fn store_pin(&mut self, pin_hash: &[u8; 16], code_point_length: u8) -> CtapResult<()>;

With config_command, backends implement is_pin_longer_than and get a default has_pin helper. Other builds only require has_pin. The length comparison is strict, counts Unicode code points, and returns false when no PIN is configured. It is needed only when setting the minimum PIN length. Backends may use a guaranteed lower bound or conservatively return false for unknown positive thresholds, at the cost of unnecessary PIN changes on policy updates; exact length storage is optional.

CTAP owns PIN policy and retry accounting. It compares the proposed replacement against the just-verified candidate, invalidates old tokens before replacement, and clears forced-change state after successful storage. A backend that could not check a candidate because of temporary hardware lockout returns CTAP2_ERR_PIN_AUTH_BLOCKED; CTAP refunds that attempt. An attempted but incorrect PIN still returns false. Backends need no knowledge of CTAP retry or policy storage.

The software backend retains its existing 17-byte PIN record. Tests reuse the existing CTAP cases, extend the small storage/verification test, and add a short table test for hardware-lockout retry accounting.

@lovasoa
lovasoa force-pushed the pin-key-store-api branch 2 times, most recently from 80e5b7e to bfe68a0 Compare November 10, 2026 16:04
@lovasoa lovasoa changed the title Move the PIN lifecycle into KeyStore with explicit change authorization Let KeyStore own PIN storage and verification Nov 10, 2026
@lovasoa
lovasoa force-pushed the pin-key-store-api branch 2 times, most recently from 67004f7 to 16e5d01 Compare November 10, 2026 20:37
@lovasoa
lovasoa marked this pull request as ready for review November 10, 2026 21:41

@kaczmarczyck kaczmarczyck left a comment

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 this change is still bigger than necessary. I thought all we do is move the API functions around.

So for the remaining changes, I suspect they are useful for Fidophir somehow?

If you explain what you need, I can think of alternatives. Otherwise I'd prefer the API only version of this PR.

Comment on lines +260 to +265
fn is_pin_longer_than(&mut self, length: u8) -> CtapResult<bool> {
Ok(self
.persist()
.pin_code_point_length()?
.is_some_and(|pin_length| pin_length > length))
}

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.

Why do we need this? Can't CTAP just call persist().pin_code_point_length() like before?

What do we gain from adding this function, if the length is available? It doesn't increase security, and I don't think we really need to.

result: CtapResult<bool>,
) -> CtapResult<bool> {
if result == Err(Ctap2StatusCode::CTAP2_ERR_PIN_AUTH_BLOCKED) {
let failures = env.persist().pin_fails()?.saturating_sub(1);

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.

There is already a

storage::decr_pin_retries(env)?;

in line 225, what do we need this function for?

/// truncated for persistent storage.
fn check_and_store_new_pin<E: Env>(
/// truncated for storage.
fn check_new_pin<E: Env>(

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.

Why do we move storing out of this function? We have env, so we shouldn't need to change this?

/// a retry before this call and resets retries after successful verification.
/// Return `CTAP2_ERR_PIN_AUTH_BLOCKED` if temporary backend lockout prevented
/// checking the candidate. OpenSK then refunds the retry. An attempted but
/// incorrect PIN must return false, even if it triggers hardware lockout.

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.

This comment seems to talk about your implementation of this trait, not the trait itself. I don't think it really makes sense here?

/// CTAP checks PIN policy and, for replacement, verifies the old PIN first.
/// Backends may retain authorization from that verification for this call;
/// any retained authorization must be scoped to the current command.
/// On success, the PIN must be durable. CTAP clears the forced-change flag.

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.

CTAP clears the forced-change flag.

I think correct API documentation shouldn't talk about what happens outside of the module. Keep documentation self-contained.

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.

2 participants