open-passkey

An open-source library for adding passkey authentication to any app. Built on WebAuthn with hybrid post-quantum signature verification (ML-DSA-65-ES256) that works today in Go and TypeScript.

Server-side and client-side. Core protocol libraries, Go HTTP handlers, and Angular components.

View on GitHub arrow_forward Early development. Not yet recommended for production.

Hybrid Post-Quantum Support

open-passkey implements ML-DSA-65-ES256 hybrid composite signatures (draft-ietf-jose-pq-composite-sigs), combining a NIST-standardized post-quantum algorithm with classical ECDSA in a single credential. Both signature components must verify independently, if either is broken, the other still protects you.

Algorithm COSE alg Status Go TS
ML-DSA-65-ES256 (composite) -52 IETF Draft check check
ML-DSA-65 (PQ only) -49 NIST FIPS 204 check check
ES256 (ECDSA P-256) -7 Generally Available check check

How it works: During registration, the server advertises preferred algorithms in pubKeyCredParams. During authentication, the core libraries read the COSE alg field from the stored credential and dispatch to the correct verifier automatically. ES256, ML-DSA-65, or the composite ML-DSA-65-ES256 path. No application code changes needed.

Browser support note: As of early 2026, no major browser authenticator produces ML-DSA-65 or composite signatures natively. Credentials use ES256 until platform support arrives. Both cores verify all three algorithms today so that when authenticators catch up, your deployment is already protected.

Architecture

open-passkey separates concerns into two layers. The core protocol is pure WebAuthn/FIDO2 verification logic with no framework dependencies. Framework bindings are thin adapters that wire the core into specific frameworks. Adding passkey support to a new framework only requires writing an adapter, not reimplementing cryptography.

Repository Layout
open-passkey/
├── spec/vectors/        # Shared JSON test vectors
├── packages/
│   ├── core-go/         # Go core protocol
│   ├── server-go/       # Go HTTP bindings
│   ├── core-ts/         # TypeScript core protocol
│   └── angular/         # Angular components
└── tools/vecgen/        # Test vector generation

Code Examples

Server Setup (server-go)

HTTP handlers for the full WebAuthn ceremony. Works with any Go router. Defaults to hybrid ML-DSA-65-ES256 preferred.

go
import "github.com/locke-inc/open-passkey/packages/server-go"

p, _ := passkey.New(passkey.Config{
    RPID:            "example.com",
    RPDisplayName:   "My App",
    Origin:          "https://example.com",
    ChallengeStore:  passkey.NewMemoryChallengeStore(),
    CredentialStore: myDBCredentialStore,
})

mux := http.NewServeMux()
mux.HandleFunc("POST /passkey/register/begin",  p.BeginRegistration)
mux.HandleFunc("POST /passkey/register/finish", p.FinishRegistration)
mux.HandleFunc("POST /passkey/login/begin",     p.BeginAuthentication)
mux.HandleFunc("POST /passkey/login/finish",    p.FinishAuthentication)

Pluggable interfaces: ChallengeStore (single-use challenge storage; in-memory default provided) and CredentialStore (credential persistence; you implement for your DB).

Core Verification (core-go)

Lower-level API. Algorithm dispatch is automatic based on the stored COSE key.

go
import "github.com/locke-inc/open-passkey/packages/core-go/webauthn"

// Registration
result, err := webauthn.VerifyRegistration(webauthn.RegistrationInput{
    RPID:              "example.com",
    ExpectedChallenge: challengeB64URL,
    ExpectedOrigin:    "https://example.com",
    ClientDataJSON:    credential.Response.ClientDataJSON,
    AttestationObject: credential.Response.AttestationObject,
})
// result.CredentialID, result.PublicKeyCOSE -- store for future auth

// Authentication: dispatches to ES256, ML-DSA-65, or ML-DSA-65-ES256
result, err := webauthn.VerifyAuthentication(webauthn.AuthenticationInput{
    RPID:                "example.com",
    ExpectedChallenge:   challengeB64URL,
    ExpectedOrigin:      "https://example.com",
    StoredPublicKeyCOSE: storedKeyBytes,
    StoredSignCount:     storedCount,
    ClientDataJSON:      credential.Response.ClientDataJSON,
    AuthenticatorData:   credential.Response.AuthenticatorData,
    Signature:           credential.Response.Signature,
})

Algorithm Constants

go
webauthn.AlgES256                  // -7  (ECDSA P-256)
webauthn.AlgMLDSA65                // -49 (ML-DSA-65 / Dilithium3)
webauthn.AlgCompositeMLDSA65ES256  // -52 (hybrid composite)

Core Verification (core-ts)

Full parity with Go core, ie. same 16 spec vectors passing. Uses Node crypto, @noble/post-quantum for ML-DSA-65, cbor-x for CBOR.

typescript
import {
  verifyRegistration,
  verifyAuthentication,
  COSE_ALG_ES256,                    // -7
  COSE_ALG_MLDSA65,                  // -49
  COSE_ALG_COMPOSITE_MLDSA65_ES256,  // -52
} from "@open-passkey/core";

The API mirrors the Go core. Same verifyRegistration / verifyAuthentication functions, same automatic algorithm dispatch based on the stored COSE key.

Provider Setup

Headless components with content projection. You provide the UI and the library handles the WebAuthn ceremony.

typescript - app.config.ts
import { providePasskey } from "@open-passkey/angular";

export const appConfig = {
  providers: [
    provideHttpClient(),
    providePasskey({ baseUrl: "/passkey" }),
  ],
};

Components

html
<!-- Registration -->
<passkey-register [userId]="userId" [username]="username"
                  (registered)="onRegistered($event)"
                  (error)="onError($event)" #reg>
  <button (click)="reg.register()" [disabled]="reg.loading()">
    Register Passkey
  </button>
</passkey-register>

<!-- Authentication -->
<passkey-login [userId]="userId"
               (authenticated)="onAuthenticated($event)"
               (error)="onError($event)" #login>
  <button (click)="login.login()" [disabled]="login.loading()">
    Sign in with Passkey
  </button>
</passkey-login>

Service API

typescript
import { PasskeyService } from "@open-passkey/angular";

@Component({ ... })
class MyComponent {
  private passkey = inject(PasskeyService);

  register() {
    this.passkey.register(userId, username).subscribe(result => { ... });
  }
  login() {
    this.passkey.authenticate(userId).subscribe(result => { ... });
  }
}

The client passes through whatever algorithm the server and authenticator negotiate. No client-side changes needed for PQ support.

React component bindings are on the roadmap.

The core TypeScript library (@open-passkey/core) can be used directly in React projects today.

Packages

core-go

16 tests

Go core protocol. Registration + authentication ceremony verification. ES256, ML-DSA-65, ML-DSA-65-ES256 composite.

Go stdlib crypto, fxamacker/cbor, cloudflare/circl

server-go

16 tests

Go HTTP bindings. Challenge management, 4 ceremony handlers, pluggable store interfaces. Works with any Go router.

Hybrid-preferred: ML-DSA-65-ES256 first in pubKeyCredParams

core-ts

16 tests

TypeScript core. Full parity with Go utilizing the same 16 spec vectors. ES256, ML-DSA-65, and composite verification.

Node crypto, @noble/post-quantum, cbor-x

angular

28 tests

Headless Angular components + injectable service. Content projection for custom UI. Handles WebAuthn + server comms.

Passes through server/authenticator algorithm negotiation

Cross-Language Testing

Every implementation runs against the same JSON test vectors in spec/vectors/. These contain real WebAuthn payloads generated by a software authenticator, covering both happy paths and failure modes. When a bug is found in any language, a new vector is added and all implementations gain the test case automatically.

Registration

5 vectors
  • checkES256 + none attestation
  • closeRP ID mismatch
  • closeChallenge mismatch
  • closeOrigin mismatch
  • closeWrong ceremony type

Authentication

5 vectors
  • checkES256 signature
  • closeRP ID mismatch
  • closeChallenge mismatch
  • closeTampered signature
  • closeWrong ceremony type

Hybrid PQ

6 vectors
  • checkML-DSA-65-ES256 composite
  • closeRP ID mismatch
  • closeChallenge mismatch
  • closeML-DSA component tampered
  • closeECDSA component tampered
  • closeWrong ceremony type
Run tests
# Go core
cd packages/core-go && go test ./... -v

# Go server
cd packages/server-go && go test ./... -v

# TypeScript core
cd packages/core-ts && npm test

# Angular
cd packages/angular && npm test

Roadmap

check_circle Go cor: registration + authentication verification
check_circle Go HTTP server bindings: 4 handlers, pluggable stores
check_circle TypeScript core: same 16 spec vectors, full parity
check_circle Angular bindings: headless components, injectable service
check_circle Hybrid PQ: ML-DSA-65-ES256 composite in Go + TypeScript
check_circle 16 shared test vectors across 3 ceremonies
radio_button_unchecked React component bindings
radio_button_unchecked Additional attestation formats (packed, TPM, Android)
radio_button_unchecked Sign count rollback detection

Contributing

Strict TDD. To add a new test case:

  1. Add a vector to spec/vectors/ (or update tools/vecgen/main.go and regenerate)
  2. Run tests in all languages, the new vector should fail
  3. Implement the fix in each language
  4. All vectors pass = done

New language implementation: Create packages/core-{lang}/, write a test runner that loads spec/vectors/*.json, implement until all 16 vectors pass.

View on GitHub arrow_forward Locke Security Architecture Post-Quantum at Locke MIT License. Copyright 2025 Locke Identity Networks Inc.