mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
@@ -0,0 +1,558 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/sonr-io/sonr/types/webauthn"
|
||||
"github.com/sonr-io/sonr/types/webauthn/webauthncbor"
|
||||
)
|
||||
|
||||
// WebAuthnStore manages WebAuthn sessions and credentials
|
||||
type WebAuthnStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*WebAuthnSession
|
||||
credentials map[string][]*WebAuthnCredential
|
||||
}
|
||||
|
||||
// WebAuthnSession holds session data for WebAuthn ceremonies
|
||||
type WebAuthnSession struct {
|
||||
Challenge string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
SessionType string // "registration" or "authentication"
|
||||
}
|
||||
|
||||
var (
|
||||
webAuthnStore = &WebAuthnStore{
|
||||
sessions: make(map[string]*WebAuthnSession),
|
||||
credentials: make(map[string][]*WebAuthnCredential),
|
||||
}
|
||||
sessionTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// BeginWebAuthnRegistration starts WebAuthn registration ceremony
|
||||
func BeginWebAuthnRegistration(c echo.Context) error {
|
||||
var req WebAuthnRegistrationRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Store the request in context for later use in FinishWebAuthnRegistration
|
||||
c.Set("webauthn_registration_request", &req)
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate challenge",
|
||||
})
|
||||
}
|
||||
|
||||
// Store session
|
||||
session := &WebAuthnSession{
|
||||
Challenge: challenge,
|
||||
Username: req.Username,
|
||||
CreatedAt: time.Now(),
|
||||
SessionType: "registration",
|
||||
}
|
||||
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.sessions[req.Username] = session
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Create registration response
|
||||
response := WebAuthnRegistrationResponse{
|
||||
Challenge: challenge,
|
||||
RP: WebAuthnRPEntity{
|
||||
ID: "localhost", // TODO: Get from config
|
||||
Name: "Sonr Identity Platform",
|
||||
},
|
||||
User: WebAuthnUserEntity{
|
||||
ID: base64.URLEncoding.EncodeToString([]byte(req.Username)),
|
||||
Name: req.Username,
|
||||
DisplayName: req.Username,
|
||||
},
|
||||
PubKeyCredParams: []WebAuthnCredParam{
|
||||
{Type: "public-key", Alg: -7}, // ES256
|
||||
{Type: "public-key", Alg: -257}, // RS256
|
||||
},
|
||||
AuthenticatorSelection: WebAuthnAuthenticatorSelection{
|
||||
AuthenticatorAttachment: "platform",
|
||||
UserVerification: "required",
|
||||
ResidentKey: "preferred",
|
||||
},
|
||||
Timeout: 60000,
|
||||
Attestation: "direct",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// FinishWebAuthnRegistration completes WebAuthn registration ceremony
|
||||
func FinishWebAuthnRegistration(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse registration response
|
||||
var regResponse map[string]any
|
||||
if err := c.Bind(®Response); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid registration response",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored session
|
||||
webAuthnStore.mu.RLock()
|
||||
session, exists := webAuthnStore.sessions[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || session.SessionType != "registration" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "No registration session found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
if time.Since(session.CreatedAt) > sessionTimeout {
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Registration session expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract credential data
|
||||
credentialID, _ := regResponse["id"].(string)
|
||||
rawID, _ := regResponse["rawId"].(string)
|
||||
response, _ := regResponse["response"].(map[string]any)
|
||||
clientDataJSON, _ := response["clientDataJSON"].(string)
|
||||
attestationObject, _ := response["attestationObject"].(string)
|
||||
|
||||
// Verify client data
|
||||
if err := verifyClientData(clientDataJSON, session.Challenge, "webauthn.create"); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("Client data verification failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract public key from attestation
|
||||
publicKey, algorithm, err := extractPublicKeyFromAttestation(attestationObject)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("Failed to extract public key: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create credential
|
||||
credential := &WebAuthnCredential{
|
||||
CredentialID: credentialID,
|
||||
RawID: rawID,
|
||||
ClientDataJSON: clientDataJSON,
|
||||
AttestationObject: attestationObject,
|
||||
Username: username,
|
||||
Origin: "localhost", // TODO: Extract from client data
|
||||
PublicKey: publicKey,
|
||||
Algorithm: algorithm,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store credential
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.credentials[username] = append(webAuthnStore.credentials[username], credential)
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Check if we should broadcast to blockchain
|
||||
broadcastReq, ok := c.Get("broadcast_to_chain").(bool)
|
||||
if !ok {
|
||||
// Check from original request stored in context
|
||||
if origReq, exists := c.Get("webauthn_registration_request").(*WebAuthnRegistrationRequest); exists {
|
||||
broadcastReq = origReq.BroadcastToChain
|
||||
}
|
||||
}
|
||||
|
||||
var broadcastResult *BroadcastResponse
|
||||
if broadcastReq {
|
||||
// Broadcast WebAuthn credential to blockchain as gasless transaction
|
||||
result, err := BroadcastWebAuthnRegistration(credential, true)
|
||||
if err != nil {
|
||||
// Log error but don't fail registration
|
||||
c.Logger().Error("Failed to broadcast WebAuthn credential:", err)
|
||||
} else {
|
||||
broadcastResult = result
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should create a vault
|
||||
autoCreateVault, ok := c.Get("auto_create_vault").(bool)
|
||||
if !ok {
|
||||
// Check from original request
|
||||
if origReq, exists := c.Get("webauthn_registration_request").(*WebAuthnRegistrationRequest); exists {
|
||||
autoCreateVault = origReq.AutoCreateVault
|
||||
}
|
||||
}
|
||||
|
||||
var vaultResult *BroadcastResponse
|
||||
if autoCreateVault {
|
||||
// Create vault for the user
|
||||
vaultConfig := map[string]any{
|
||||
"type": "standard",
|
||||
"encryption": "AES256",
|
||||
"owner": username,
|
||||
}
|
||||
|
||||
userDID := fmt.Sprintf("did:sonr:%s", username)
|
||||
result, err := BroadcastVaultCreation(userDID, vaultConfig)
|
||||
if err != nil {
|
||||
// Log error but don't fail registration
|
||||
c.Logger().Error("Failed to create vault:", err)
|
||||
} else {
|
||||
vaultResult = result
|
||||
}
|
||||
}
|
||||
|
||||
finalResponse := map[string]any{
|
||||
"success": true,
|
||||
"message": "Registration completed successfully",
|
||||
"credentialId": credentialID,
|
||||
}
|
||||
|
||||
if broadcastResult != nil {
|
||||
finalResponse["broadcast"] = broadcastResult
|
||||
}
|
||||
|
||||
if vaultResult != nil {
|
||||
finalResponse["vault"] = vaultResult
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, finalResponse)
|
||||
}
|
||||
|
||||
// BeginWebAuthnAuthentication starts WebAuthn authentication ceremony
|
||||
func BeginWebAuthnAuthentication(c echo.Context) error {
|
||||
var req WebAuthnAuthenticationRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request format",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user has credentials
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials, exists := webAuthnStore.credentials[req.Username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || len(credentials) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "No credentials found for user",
|
||||
})
|
||||
}
|
||||
|
||||
// Generate challenge
|
||||
challenge, err := generateChallenge()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "Failed to generate challenge",
|
||||
})
|
||||
}
|
||||
|
||||
// Store session
|
||||
session := &WebAuthnSession{
|
||||
Challenge: challenge,
|
||||
Username: req.Username,
|
||||
CreatedAt: time.Now(),
|
||||
SessionType: "authentication",
|
||||
}
|
||||
|
||||
webAuthnStore.mu.Lock()
|
||||
webAuthnStore.sessions[req.Username] = session
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Build allowed credentials
|
||||
allowCredentials := make([]WebAuthnAllowedCred, len(credentials))
|
||||
for i, cred := range credentials {
|
||||
allowCredentials[i] = WebAuthnAllowedCred{
|
||||
Type: "public-key",
|
||||
ID: cred.CredentialID,
|
||||
}
|
||||
}
|
||||
|
||||
// Create authentication response
|
||||
response := WebAuthnAuthenticationResponse{
|
||||
Challenge: challenge,
|
||||
Timeout: 60000,
|
||||
RPID: "localhost", // TODO: Get from config
|
||||
AllowCredentials: allowCredentials,
|
||||
UserVerification: "required",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// FinishWebAuthnAuthentication completes WebAuthn authentication ceremony
|
||||
func FinishWebAuthnAuthentication(c echo.Context) error {
|
||||
username := c.QueryParam("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse authentication response
|
||||
var authResponse map[string]any
|
||||
if err := c.Bind(&authResponse); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid authentication response",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored session
|
||||
webAuthnStore.mu.RLock()
|
||||
session, exists := webAuthnStore.sessions[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists || session.SessionType != "authentication" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "No authentication session found",
|
||||
})
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
if time.Since(session.CreatedAt) > sessionTimeout {
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Authentication session expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response data
|
||||
credentialID, _ := authResponse["id"].(string)
|
||||
response, _ := authResponse["response"].(map[string]any)
|
||||
clientDataJSON, _ := response["clientDataJSON"].(string)
|
||||
authenticatorData, _ := response["authenticatorData"].(string)
|
||||
signature, _ := response["signature"].(string)
|
||||
userHandle, _ := response["userHandle"].(string)
|
||||
|
||||
// Verify client data
|
||||
if err := verifyClientData(clientDataJSON, session.Challenge, "webauthn.get"); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("Client data verification failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Find matching credential
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials := webAuthnStore.credentials[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
var matchedCredential *WebAuthnCredential
|
||||
for _, cred := range credentials {
|
||||
if cred.CredentialID == credentialID {
|
||||
matchedCredential = cred
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedCredential == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"error": "Invalid credential",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Verify signature using the stored public key
|
||||
// This would require implementing proper WebAuthn signature verification
|
||||
|
||||
// Clean up session
|
||||
webAuthnStore.mu.Lock()
|
||||
delete(webAuthnStore.sessions, username)
|
||||
webAuthnStore.mu.Unlock()
|
||||
|
||||
// Create authenticated session
|
||||
userDID := fmt.Sprintf("did:sonr:%s", username)
|
||||
authSession := &OIDCSession{
|
||||
SessionID: generateSessionID(),
|
||||
UserDID: userDID,
|
||||
ClientID: "webauthn-client",
|
||||
Scope: "openid profile did vault",
|
||||
AccessToken: generateAccessToken(userDID),
|
||||
RefreshToken: generateRefreshToken(),
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store session for OIDC compatibility
|
||||
oidcProvider.mu.Lock()
|
||||
oidcProvider.sessions[authSession.AccessToken] = authSession
|
||||
oidcProvider.mu.Unlock()
|
||||
|
||||
// Set user context for downstream handlers
|
||||
c.Set("user_did", userDID)
|
||||
c.Set("authenticated", true)
|
||||
c.Set("auth_method", "webauthn")
|
||||
c.Set("credential_id", credentialID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "Authentication successful",
|
||||
"credentialId": credentialID,
|
||||
"accessToken": authSession.AccessToken,
|
||||
"expiresIn": 3600,
|
||||
"userDID": userDID,
|
||||
"sessionId": authSession.SessionID,
|
||||
"authenticatorData": authenticatorData,
|
||||
"signature": signature,
|
||||
"userHandle": userHandle,
|
||||
})
|
||||
}
|
||||
|
||||
// generateChallenge creates a cryptographically secure challenge
|
||||
func generateChallenge() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// verifyClientData verifies client data JSON
|
||||
func verifyClientData(clientDataJSON, expectedChallenge, expectedType string) error {
|
||||
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse client data: %w", err)
|
||||
}
|
||||
|
||||
if clientData.Challenge != expectedChallenge {
|
||||
return fmt.Errorf("challenge mismatch")
|
||||
}
|
||||
|
||||
if clientData.Type != expectedType {
|
||||
return fmt.Errorf(
|
||||
"invalid client data type: expected %s, got %s",
|
||||
expectedType,
|
||||
clientData.Type,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Verify origin from config
|
||||
expectedOrigins := []string{
|
||||
"http://localhost",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:8083",
|
||||
"http://localhost:8084",
|
||||
}
|
||||
|
||||
validOrigin := false
|
||||
for _, origin := range expectedOrigins {
|
||||
if clientData.Origin == origin {
|
||||
validOrigin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validOrigin {
|
||||
return fmt.Errorf("invalid origin: %s", clientData.Origin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPublicKeyFromAttestation extracts public key from attestation object
|
||||
func extractPublicKeyFromAttestation(attestationObjectB64 string) ([]byte, int32, error) {
|
||||
// Validate format
|
||||
if err := webauthn.ValidateAttestationObjectFormat(attestationObjectB64); err != nil {
|
||||
return nil, 0, fmt.Errorf("invalid attestation format: %w", err)
|
||||
}
|
||||
|
||||
// Decode attestation object
|
||||
attestationBytes, err := base64.RawURLEncoding.DecodeString(attestationObjectB64)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to decode attestation: %w", err)
|
||||
}
|
||||
|
||||
// Parse CBOR
|
||||
var attestationObj webauthn.AttestationObject
|
||||
if err := webauthncbor.Unmarshal(attestationBytes, &attestationObj); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal attestation: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal authenticator data
|
||||
if err := attestationObj.AuthData.Unmarshal(attestationObj.RawAuthData); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to unmarshal auth data: %w", err)
|
||||
}
|
||||
|
||||
// Check for attested credential data
|
||||
if !attestationObj.AuthData.Flags.HasAttestedCredentialData() {
|
||||
return nil, 0, fmt.Errorf("no attested credential data")
|
||||
}
|
||||
|
||||
publicKey := attestationObj.AuthData.AttData.CredentialPublicKey
|
||||
if len(publicKey) == 0 {
|
||||
return nil, 0, fmt.Errorf("no public key found")
|
||||
}
|
||||
|
||||
// Default to ES256 algorithm
|
||||
algorithm := int32(-7)
|
||||
|
||||
return publicKey, algorithm, nil
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentials retrieves credentials for a user
|
||||
func GetWebAuthnCredentials(c echo.Context) error {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Username is required",
|
||||
})
|
||||
}
|
||||
|
||||
webAuthnStore.mu.RLock()
|
||||
credentials, exists := webAuthnStore.credentials[username]
|
||||
webAuthnStore.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "No credentials found for user",
|
||||
})
|
||||
}
|
||||
|
||||
// Return sanitized credentials (without sensitive data)
|
||||
sanitized := make([]map[string]any, len(credentials))
|
||||
for i, cred := range credentials {
|
||||
sanitized[i] = map[string]any{
|
||||
"credentialId": cred.CredentialID,
|
||||
"createdAt": cred.CreatedAt,
|
||||
"algorithm": cred.Algorithm,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, sanitized)
|
||||
}
|
||||
Reference in New Issue
Block a user