Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions app/auth/views/fido.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import secrets
from time import time

import webauthn
from flask import (
Expand All @@ -15,6 +14,7 @@
)
from flask_login import login_user
from flask_wtf import FlaskForm
from time import time
from wtforms import HiddenField, validators, BooleanField

from app.auth.base import auth_bp
Expand Down Expand Up @@ -155,13 +155,18 @@ def fido():
webauthn_users, challenge
)
webauthn_assertion_options = webauthn_assertion_options.assertion_dict
try:
# HACK: We need to upgrade to webauthn > 1 so it can support specifying the transports
for credential in webauthn_assertion_options["allowCredentials"]:
del credential["transports"]
except KeyError:
# Should never happen but...
pass
# Inject stored transports per credential, falling back to removing the field
# if none are stored (keys registered before metadata collection).
fido_by_credential_id = {fido.credential_id: fido for fido in fidos}
for credential in webauthn_assertion_options.get("allowCredentials", []):
fido = fido_by_credential_id.get(credential.get("id"))
if fido and fido.transports:
try:
credential["transports"] = json.loads(fido.transports)
except Exception:
del credential["transports"]
else:
credential.pop("transports", None)

return render_template(
"auth/fido.html",
Expand Down
32 changes: 32 additions & 0 deletions app/dashboard/views/fido_setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import secrets
import struct
import uuid

import cbor2
import webauthn
from flask import render_template, flash, redirect, url_for, session
from flask_login import login_required, current_user
Expand All @@ -17,6 +19,31 @@
from app.user_settings import regenerate_user_alternative_id


def extract_aaguid(att_obj_b64: str) -> str | None:
"""Extract the AAGUID from the attestation object's authData (bytes 37-53)."""
try:
# webauthn.py uses _webauthn_b64_decode which handles urlsafe base64
padding = 4 - len(att_obj_b64) % 4
if padding != 4:
att_obj_b64 += "=" * padding
import base64

raw = base64.urlsafe_b64decode(att_obj_b64)
att_obj = cbor2.loads(raw)
auth_data = att_obj.get("authData", b"")
if len(auth_data) < 53:
return None
flags = struct.unpack("!B", auth_data[32:33])[0]
# AT flag (bit 6) indicates attested credential data is present
if not (flags & 0x40):
return None
aaguid_bytes = auth_data[37:53]
return str(uuid.UUID(bytes=aaguid_bytes))
except Exception as e:
LOG.w(f"Failed to extract AAGUID: {e}")
return None


class FidoTokenForm(FlaskForm):
key_name = StringField("key_name", validators=[validators.DataRequired()])
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
Expand Down Expand Up @@ -64,13 +91,18 @@ def fido_setup():
current_user.fido_uuid = fido_uuid
Session.flush()

transports = sk_assertion.get("transports")
Fido.create(
credential_id=str(fido_credential.credential_id, "utf-8"),
uuid=fido_uuid,
public_key=str(fido_credential.public_key, "utf-8"),
sign_count=fido_credential.sign_count,
name=fido_token_form.key_name.data,
user_id=current_user.id,
credential_type=sk_assertion.get("type"),
authenticator_attachment=sk_assertion.get("authenticatorAttachment"),
transports=transports if isinstance(transports, list) else None,
aaguid=extract_aaguid(sk_assertion.get("attObj", "")),
)
regenerate_user_alternative_id(current_user)
Session.commit()
Expand Down
7 changes: 7 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ class Fido(Base, ModelMixin):
sign_count = sa.Column(sa.BigInteger(), nullable=False)
name = sa.Column(sa.String(128), nullable=False, unique=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
# Credential metadata for debugging and proper authentication routing
credential_type = sa.Column(sa.String(32), nullable=True)
authenticator_attachment = sa.Column(sa.String(32), nullable=True)
transports = sa.Column(sa.JSON(), nullable=True) # JSON array, e.g. ["usb","nfc"]
aaguid = sa.Column(
sa.String(36), nullable=True
) # UUID format, identifies device model

__table_args__ = (sa.Index("ix_fido_user_id", "user_id"),)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Add credential metadata columns to fido table

Revision ID: 4a9f8c2e1b3d
Revises: 3ee37864eb67
Create Date: 2026-03-19 09:00:00.000000

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '4a9f8c2e1b3d'
down_revision = '3ee37864eb67'
branch_labels = None
depends_on = None


def upgrade():
op.add_column('fido', sa.Column('credential_type', sa.String(length=32), nullable=True))
op.add_column('fido', sa.Column('authenticator_attachment', sa.String(length=32), nullable=True))
op.add_column('fido', sa.Column('transports', sa.Text(), nullable=True))
op.add_column('fido', sa.Column('aaguid', sa.String(length=36), nullable=True))


def downgrade():
op.drop_column('fido', 'aaguid')
op.drop_column('fido', 'transports')
op.drop_column('fido', 'authenticator_attachment')
op.drop_column('fido', 'credential_type')
Loading
Loading