Files
ResolveSpec/pkg/security/passkey_provider.go
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

252 lines
8.7 KiB
Go

package security
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/backends"
)
// DatabasePasskeyProvider implements PasskeyProvider on top of the lookup package
// (stored procedures on Postgres by default, direct SQL elsewhere).
type DatabasePasskeyProvider struct {
src *lookupSource
rpID string // Relying Party ID (domain)
rpName string // Relying Party display name
rpOrigin string // Expected origin for WebAuthn
timeout int64 // Timeout in milliseconds (default: 60000)
}
// DatabasePasskeyProviderOptions configures the passkey provider
type DatabasePasskeyProviderOptions struct {
// RPID is the Relying Party ID (typically your domain, e.g., "example.com")
RPID string
// RPName is the display name for your relying party
RPName string
// RPOrigin is the expected origin (e.g., "https://example.com")
RPOrigin string
// Timeout is the timeout for operations in milliseconds (default: 60000)
Timeout int64
// Lookup selects dialect, query mode and procedure/table/column names.
Lookup lookup.Config
// LookupProvider, when set, is used instead of building one from Lookup and the db.
LookupProvider *lookup.Provider
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
// If nil, reconnection is disabled.
DBFactory func() (*sql.DB, error)
}
// NewDatabasePasskeyProvider creates a new database-backed passkey provider
func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions) *DatabasePasskeyProvider {
if opts.Timeout == 0 {
opts.Timeout = 60000 // 60 seconds default
}
src := newLookupSource(db)
src.cfg = opts.Lookup
src.provider = opts.LookupProvider
src.opts = backends.Options{DBFactory: opts.DBFactory}
return &DatabasePasskeyProvider{
src: src,
rpID: opts.RPID,
rpName: opts.RPName,
rpOrigin: opts.RPOrigin,
timeout: opts.Timeout,
}
}
func (p *DatabasePasskeyProvider) store() lookup.PasskeyStore { return p.src.get().Passkey }
// BeginRegistration creates registration options for a new passkey
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
// Generate challenge
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// Get existing credentials to exclude
credentials, err := p.GetCredentials(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get existing credentials: %w", err)
}
excludeCredentials := make([]PasskeyCredentialDescriptor, 0, len(credentials))
for i := range credentials {
excludeCredentials = append(excludeCredentials, PasskeyCredentialDescriptor{
Type: "public-key",
ID: credentials[i].CredentialID,
Transports: credentials[i].Transports,
})
}
// Create user handle (persistent user ID)
userHandle := []byte(fmt.Sprintf("user_%d", userID))
return &PasskeyRegistrationOptions{
Challenge: challenge,
RelyingParty: PasskeyRelyingParty{
ID: p.rpID,
Name: p.rpName,
},
User: PasskeyUser{
ID: userHandle,
Name: username,
DisplayName: displayName,
},
PubKeyCredParams: []PasskeyCredentialParam{
{Type: "public-key", Alg: -7}, // ES256 (ECDSA with SHA-256)
{Type: "public-key", Alg: -257}, // RS256 (RSASSA-PKCS1-v1_5 with SHA-256)
},
Timeout: p.timeout,
ExcludeCredentials: excludeCredentials,
AuthenticatorSelection: &PasskeyAuthenticatorSelection{
RequireResidentKey: false,
ResidentKey: "preferred",
UserVerification: "preferred",
},
Attestation: "none",
}, nil
}
// CompleteRegistration verifies and stores a new passkey credential
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
// like github.com/go-webauthn/webauthn to properly verify attestation and parse credentials.
func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error) {
// TODO: Implement full WebAuthn verification
// 1. Verify clientDataJSON contains correct challenge and origin
// 2. Parse and verify attestationObject
// 3. Extract public key and credential ID
// 4. Verify attestation signature (if not "none")
// For now, this is a placeholder that stores the credential data
// In production, you MUST use a proper WebAuthn library
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
pubKeyB64 := base64.StdEncoding.EncodeToString(response.Response.AttestationObject)
credentialID, err := p.store().Store(ctx, lookup.PasskeyCredentialRecord{
UserID: userID,
CredentialID: credIDB64,
PublicKey: pubKeyB64,
AttestationType: "none",
Transports: response.Transports,
Name: "Passkey",
})
if err != nil {
return nil, err
}
return &PasskeyCredential{
ID: fmt.Sprintf("%d", credentialID),
UserID: userID,
CredentialID: response.RawID,
PublicKey: response.Response.AttestationObject,
AttestationType: "none",
Transports: response.Transports,
CreatedAt: time.Now(),
LastUsedAt: time.Now(),
}, nil
}
// BeginAuthentication creates authentication options for passkey login
func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error) {
// Generate challenge
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return nil, fmt.Errorf("failed to generate challenge: %w", err)
}
// If username is provided, get user's credentials
var allowCredentials []PasskeyCredentialDescriptor
if username != "" {
_, refs, err := p.store().ByUsername(ctx, username)
if err != nil {
return nil, err
}
creds := refs
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
for _, cred := range creds {
credID, err := base64.StdEncoding.DecodeString(cred.CredentialID)
if err != nil {
continue
}
allowCredentials = append(allowCredentials, PasskeyCredentialDescriptor{
Type: "public-key",
ID: credID,
Transports: cred.Transports,
})
}
}
return &PasskeyAuthenticationOptions{
Challenge: challenge,
Timeout: p.timeout,
RelyingPartyID: p.rpID,
AllowCredentials: allowCredentials,
UserVerification: "preferred",
}, nil
}
// CompleteAuthentication verifies a passkey assertion and returns the user ID
// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library
// like github.com/go-webauthn/webauthn to properly verify the assertion signature.
func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error) {
// TODO: Implement full WebAuthn verification
// 1. Verify clientDataJSON contains correct challenge and origin
// 2. Verify authenticatorData
// 3. Verify signature using stored public key
// 4. Update sign counter and check for cloning
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
// TODO: Verify signature here
// For now, we'll just update the counter as a placeholder
store := p.store()
userID, signCount, err := store.Get(ctx, credIDB64)
if err != nil {
return 0, err
}
// Update counter (in production, this should be done after successful verification)
cloneWarning, err := store.UpdateCounter(ctx, credIDB64, signCount+1)
if err != nil {
return 0, fmt.Errorf("failed to update counter: %w", err)
}
if cloneWarning {
return 0, fmt.Errorf("credential cloning detected")
}
return userID, nil
}
// GetCredentials returns all passkey credentials for a user
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
return p.store().List(ctx, userID)
}
// DeleteCredential removes a passkey credential
func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID int, credentialID string) error {
_, err := base64.StdEncoding.DecodeString(credentialID)
if err != nil {
return fmt.Errorf("invalid credential ID: %w", err)
}
return p.store().Delete(ctx, userID, credentialID)
}
// UpdateCredentialName updates the friendly name of a credential
func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error {
_, err := base64.StdEncoding.DecodeString(credentialID)
if err != nil {
return fmt.Errorf("invalid credential ID: %w", err)
}
return p.store().Rename(ctx, userID, credentialID, name)
}