FEAT-IDEN-102: Teacher Passkey Enrollment
[NEW - Compliant with DOM-IDEN Authority]
| Reference Number | Version | Effective Date | Supersedes | Authority Level | Status |
|---|---|---|---|---|---|
| FEAT-IDEN-102 | 1.0 | 2026-08-09 | N/A (new) | Normative | NEW |
I. Purpose
This FEAT enrolls an optional passkey credential (WebAuthn/FIDO2) on a teacher User account as an alternative or supplementary authentication factor. Per DOM-IDEN-003 §III.B:
“Teachers MAY enroll passkey credentials as an optional second factor (in addition to required TOTP).”
This FEAT stores passkey metadata in the passkey_credentials table, allowing the teacher to authenticate using biometric or security key after initial TOTP enrollment.
Governing Authority:
- DOM-IDEN-003 §III.B (Teacher Authentication - Passkey Optional)
- DOM-IDEN-005 §VIII (Identity Binding)
- FEAT-CORE-000 (Feature Execution Constitutional Directive)
II. Execution Context
1. Required Inputs
user_id: The teacherUserenrolling passkey (from session or context).seat_id: The adminSeatin the target class (from context).class_id: The class context (from session or context).webauthn_response: The raw WebAuthn attestation response from the authenticator.- Contains:
id,rawId,response.clientDataJSON,response.attestationObject.
- Contains:
authenticator_name: Human-readable name for the passkey (e.g., “YubiKey 5”, “Windows Hello”).idempotency_key: Client-provided unique request ID for retry safety.
2. Resolved Context (MANDATORY)
Before mutation, the FEAT MUST resolve:
- The
Userrecord matchinguser_idin theuserstable. - The
Seatrecord matchingseat_idin theseatstable. - Verify that
User.id == Seat.user_id(seat is bound to this user). - Verify that
Seat.role = 'teacher'(seat is a teacher seat). - Verify that
Seat.class_id == class_id(seat is in the correct class).
III. Orchestration Logic
A. Verification Phase (Read-Only)
Step 1: Validate Teacher User State
- Query
Userrecord whereid = user_id. - Verify that
user_role = 'teacher'. - Verify that
totp_secret_encrypted IS NOT NULL(TOTP already enrolled).- Passkey enrollment requires TOTP to be set up first (per DOM-IDEN-003).
- Failure Behavior: Abort with
INVALID_USER_STATEif user is not a teacher or TOTP not enrolled.
Step 2: Validate Seat State
- Query
Seatrecord whereSeat.id = seat_id. - Verify that
Seat.user_id = user_id(seat is bound to the target user). - Verify that
Seat.role = 'teacher'(seat is a teacher seat). - Failure Behavior: Abort with
INVALID_SEAT_STATEif seat is not a teacher seat.
Step 3: Validate WebAuthn Ceremony Response (Creation)
- Verify that
webauthn_responseis valid JSON. - Extract clientDataJSON: Base64url-decode
response.clientDataJSONand parse as JSON.- Verify
clientDataJSON.type == "webauthn.create"(NOT “webauthn.get”). - Verify
clientDataJSON.challengematches the challenge sent to the client in Step 1 (exact base64url match). - Mark the challenge as consumed (used) to prevent replay in future requests.
- Verify
clientDataJSON.originmatches the expected origin (e.g., “https://classroom-economy.com ”).
- Verify
- Extract and decode attestationObject: Base64url-decode
response.attestationObject(CBOR format).- Extract
attStmt(attestation statement),authData(authenticator data), andfmt(attestation format). - Extract public key from
authData.credentialPublicKey.
- Extract
- Verify RP ID Hash: Compute
SHA256(RP_ID)and verify it matchesauthData.rpIdHash.- RP ID should be the domain name (e.g., “classroom-economy.com”).
- Extract credential data:
credential_id: Extract fromauthData.credentialId(base64url encode).public_key: Extract fromauthData.credentialPublicKey.sign_count: Extract fromauthData.signCount(for rollback detection).aaguid: Extract fromauthData.aaguid(authenticator model identifier).
- Failure Behavior: Abort with
INVALID_WEBAUTHN_RESPONSEif any check fails:- clientDataJSON.type is not “webauthn.create” →
INVALID_CEREMONY_TYPE - Challenge mismatch or invalid →
INVALID_CHALLENGE - Origin mismatch →
INVALID_ORIGIN - RP ID hash mismatch →
INVALID_RP_ID - Cannot parse CBOR or extract credential data →
INVALID_ATTESTATION_OBJECT
- clientDataJSON.type is not “webauthn.create” →
Step 4: Verify Attestation Chain (Optional, Security Enhancement)
- If attestation verification is enabled, validate attestation chain (requires root certificate management).
- Failure Behavior: Abort with
ATTESTATION_FAILEDif attestation cannot be verified (for non-FIDO2 authenticators).
Note: Passkey enrollment can proceed without attestation verification in development/testing; production should enforce it.
Step 5: Check Credential Uniqueness
- Query
passkey_credentialswherecredential_id = EXTRACTED_CREDENTIAL_ID. - Failure Behavior: Abort with
CREDENTIAL_EXISTSif this authenticator is already enrolled on another user.
B. Mutation Phase (Atomic Transaction)
All mutations in this section MUST occur within a single database transaction.
Step 1: Extract and Store Credential Metadata
From the webauthn_response, extract:
credential_id: The public credential ID (base64url encoded, fromrawId).public_key: The extracted public key (CBOR-encoded, persisted as-is or base64url).sign_count: The initial signature counter (for rollback detection in future authentications).aaguid: The authenticator’s AAGUID (for device identification).transports: Supported transports (if available; e.g., [“usb”, “internal”]).
Step 2: Validate authenticator_name
Before storing or logging authenticator_name:
- Verify length is between 3 and 128 characters. Reject with
INVALID_AUTHENTICATOR_NAMEif outside range. - Reject any value containing control characters (
\x00–\x1f,\x7f). Reject withINVALID_AUTHENTICATOR_NAME. - HTML-encode the validated value when rendering it in the management UI to prevent XSS.
Step 3: Create PasskeyCredential Record
Insert into passkey_credentials table:
user_id: The teacher user.credential_id: The public credential ID (base64url).public_key: The public key (stored encrypted or plaintext depending on security requirements).sign_count: Initial signature counter (0 or extracted value).aaguid: Authenticator AAGUID.authenticator_name: Validated user-provided name (3–128 chars, no control characters).transports: JSON array of supported transports (optional).created_at: ISO 8601 UTC timestamp.last_used_at: NULL (not yet used).
Step 4: Audit Trace
Per FEAT-CORE-000 §III.4:
- Call
DOM-OPSto emit anACT-IDEN-102audit event. - Required fields:
feat_id: “FEAT-IDEN-102”user_id: The teacheruser_idseat_id: The adminseat_idclass_id: The classroom contextidempotency_key: The provided keyauthenticator_name: The passkey name (safe to log)outcome:"PASSKEY_ENROLLED"(only possible outcome)timestamp: ISO 8601 UTC timestamp
Note: DO NOT log credential_id or public_key (security-sensitive).
IV. Invariants & Constraints
1. Passkey is Optional (MANDATORY)
Per DOM-IDEN-003 §III.B:
“Passkey credentials are optional (not required).”
A teacher can authenticate with TOTP alone; passkey is supplementary.
2. TOTP Required First (MANDATORY)
Passkey enrollment SHALL only proceed if totp_secret_encrypted IS NOT NULL. TOTP is the mandatory primary factor.
3. Credential Uniqueness (MANDATORY)
Each credential_id SHALL be unique across all users. Two users cannot enroll the same physical authenticator.
4. Immutable Metadata (MANDATORY)
Once stored, passkey metadata (credential_id, public_key, aaguid) SHALL NOT be modified. Changes require credential revocation (FEAT-IDEN-107) and re-enrollment.
5. Atomic Transaction (MANDATORY)
All mutations SHALL occur in a single transaction. If any step fails, complete rollback occurs.
6. Multiple Passkeys Allowed (MANDATORY)
A single teacher can enroll multiple passkey credentials. There is no limit on the number of passkeys per user (policy can enforce a soft limit in future versions).
7. WebAuthn Ceremony Binding (MANDATORY - SECURITY CRITICAL)
The passkey enrollment MUST verify full WebAuthn ceremony integrity to prevent spoofing:
Challenge Binding:
- Before enrollment (in route handler), generate a cryptographically random challenge (256 bits).
- Store challenge in server-side session or temporary table with TTL of 10 minutes.
- Send challenge to client in WebAuthn API call (as base64url string).
- Verify the returned challenge in
clientDataJSON.challengematches stored challenge exactly. - Mark challenge as consumed after verification (cannot be reused).
Ceremony Type Verification:
- Verify
clientDataJSON.type == "webauthn.create"(creation ceremony, not assertion). - Reject if type is “webauthn.get” or any other value.
Origin Verification:
- Verify
clientDataJSON.originmatches the server’s origin exactly (e.g., “https://classroom-economy.com ”). - This prevents phishing attacks where attacker tricks user into enrolling passkey on attacker’s domain.
RP ID Verification:
- Compute
SHA256(RP_ID)where RP_ID is the domain name (e.g., “classroom-economy.com”). - Verify
authData.rpIdHashmatches the computed hash. - This ensures the passkey is bound to the correct relying party (prevents cross-domain attacks).
Failure Handling:
- If any ceremony check fails, abort with specific error code (INVALID_CEREMONY_TYPE, INVALID_CHALLENGE, INVALID_ORIGIN, INVALID_RP_ID).
- Do NOT store the credential if ceremony validation fails.
- Log the failure for security audit.
V. Idempotency
Mechanism: The combination of idempotency_key and credential_id acts as the idempotency lock.
Behavior:
- If a retry occurs with the same
idempotency_key:- Check if
passkey_credentialsrow exists for thiscredential_idanduser_id. - If true, return success with outcome
ALREADY_ENROLLED(no duplicate state). - If false, re-attempt the full mutation.
- Check if
- Replayed requests with the same
idempotency_keySHALL NOT create duplicate passkey records.
Client Responsibility:
- Generate a stable
idempotency_key(e.g., based on session or authenticator response). - Retry on transient errors with the same key.
- Server stores key on audit log to detect and skip replays.
VI. Failure Scenarios
When the FEAT fails, the system SHALL:
- Rollback all mutations atomically.
- Emit audit event with outcome
FAILEDand error code. - Return error response to client.
- NOT store incomplete passkey metadata.
Failure Cases:
| Scenario | Error Code | HTTP Status | Message |
|---|---|---|---|
| User not a teacher | INVALID_USER_ROLE |
403 | “Only teachers can enroll passkeys.” |
| TOTP not enrolled | TOTP_NOT_ENROLLED |
409 | “TOTP must be set up first. Please enable TOTP authentication.” |
| Invalid seat | INVALID_SEAT_STATE |
409 | “Invalid seat. Please start over.” |
| Invalid WebAuthn response | INVALID_WEBAUTHN_RESPONSE |
400 | “Invalid passkey registration. Please try again with your authenticator.” |
| Attestation failed | ATTESTATION_FAILED |
400 | “Authenticator attestation verification failed. This authenticator may not be supported.” |
| Credential already exists | CREDENTIAL_EXISTS |
409 | “This authenticator is already enrolled on another account.” |
| Database error | INTERNAL_ERROR |
500 | “An error occurred during enrollment. Please try again.” |
VII. Audit Requirements
The DOM-OPS audit log MUST contain:
| Field | Type | Required | Rationale |
|---|---|---|---|
feat_id |
String | ✓ | Identifies the FEAT (always “FEAT-IDEN-102”) |
user_id |
Integer | ✓ | The teacher user |
seat_id |
Integer | ✓ | The admin seat |
class_id |
UUID | ✓ | The classroom context |
idempotency_key |
String | ✓ | Replay detection |
authenticator_name |
String | ✓ | User-provided passkey name (safe to log) |
outcome |
Enum | ✓ | Must be: PASSKEY_ENROLLED or ALREADY_ENROLLED |
timestamp |
ISO 8601 | ✓ | UTC timestamp of enrollment |
credential_id |
String | ✗ | DO NOT log (security) |
public_key |
String | ✗ | DO NOT log (security) |
error_code |
String | ⚠️ | Only if outcome is FAILED |
Outcomes:
PASSKEY_ENROLLED: Successful passkey enrollment.ALREADY_ENROLLED: Idempotent return; passkey was already enrolled.
VIII. Integration with Authentication Flow
Passkey enrollment enables optional biometric/security-key authentication:
TOTP Setup (FEAT-IDEN-101)
└─ Teacher enrolled in TOTP (required)
Passkey Setup (FEAT-IDEN-102)
└─ Teacher enrolls optional security key/biometric
Authentication (Separate FEAT)
├─ Primary: TOTP code verification (required)
└─ Secondary: Passkey assertion (optional, if enrolled)Both TOTP and passkey are stored on the same User record (totp_secret_encrypted and records in passkey_credentials table).
IX. Relationship to Passkey Revocation
FEAT-IDEN-107 (Revoke Passkey) removes individual passkey credentials:
- Teacher can revoke a specific passkey without affecting others.
- Revoking all passkeys does NOT affect TOTP (teacher can still log in with TOTP alone).
X. Dependencies
docs/FEATURE-EXECUTION/FEAT-CORE-000_FEATURE_EXECUTION_CONSTITUTIONAL_DIRECTIVE.mddocs/DOMAIN/DOM-IDEN-003_TEACHER_IDENTITY_ARCHITECTURE.mddocs/DOMAIN/DOM-IDEN-005_IDENTITY_BINDING_AND_LIFECYCLE.mddocs/DOMAIN/DOM-IDEN-006_CANONICAL_CONTEXT_RESOLUTION.md
XI. Implementation Checklist
Before code review, verify:
- User role validation ensures only teachers can enroll
- TOTP enrollment verification prevents pre-TOTP passkey setup
- WebAuthn response parsing and validation is correct
- Credential ID uniqueness is checked across all users
- Public key is stored securely (encrypted if necessary)
- Authenticator metadata is preserved (AAGUID, transports)
- Authenticator name is validated (3-128 characters, safe characters)
- Seat binding is verified before setup
- All mutations occur in a single transaction
- Audit event emitted with all required fields
- Rollback occurs on any failure
- Credential ID and public key are NOT logged
- Multiple passkeys per user are supported
- Idempotency check prevents duplicate enrollment
- Tests cover successful enrollment and idempotent retry
- Tests cover already-enrolled scenario
XII. Amendments
Revisions to this document SHALL:
- Increment the version.
- Update the effective date.
- Maintain consistency with DOM-IDEN-003 §III.B.
- Maintain consistency with FEAT-CORE-000.
- Maintain consistency with FEAT-IDEN-107 (Revoke Passkey).
This is version 1.0 of FEAT-IDEN-102 (new specification, 2026-08-09).
Last updated: 2026-08-11