Files
sonr/x/did/keeper/query_server.go
T
40eadc995e Feat/1285 es ucan formatting (#1302)
* feat: Add Enclave Usage Examples

* feat(es/ucan): Add comprehensive integration tests

- Create integration.test.ts with full UCAN token lifecycle testing
- Cover end-to-end token creation, parsing, and validation
- Test capability attenuation and delegation chains
- Validate multi-algorithm support and timestamp scenarios
- Implement error recovery and performance test scenarios

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* No commit suggestions generated

* No commit suggestions generated

* chore: Remove migrated components and add migration documentation

Removed all code and references for components that have been moved to separate repositories:

**Moved to sonr-io/hway:**
- bridge/ - HTTP service with OAuth2/OIDC/WebAuthn handlers
- cmd/hway/ - Highway service binary
- internal/migrations/ - PostgreSQL schema migrations

**Moved to sonr-io/motr:**
- cmd/motr/ - Motor worker service (WASM vault operations)
- cmd/vault/ - Vault CLI tool
- crypto/ - Comprehensive cryptographic library
- packages/ - TypeScript SDK packages (es, sdk, ui, com, pkl)
- web/auth/ - Authentication web application
- web/dash/ - Dashboard web application

**Updated Configuration:**
- Makefile: Removed build/test/release targets for moved components
- CLAUDE.md: Simplified to focus on core blockchain components
- devbox.json: Removed scripts for moved services
- docker-compose.yml: Removed hway, postgres, redis, auth, dash services
- .github/scopes.yml: Removed CI scopes for migrated components
- .goreleaser.yml: Updated release configuration

**Added Migration Documentation:**
- MIGRATE_HWAY.md: Comprehensive Highway service architecture and migration guide
- MIGRATE_MOTR.md: Comprehensive Motor/Worker/Vault architecture and migration guide

These migration documents provide complete context for setting up the new repositories including architecture diagrams, component breakdowns, API documentation, and migration checklists.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* No commit suggestions generated

* chore: Remove contracts references and documentation

Removed all references to the contracts directory that was migrated to a separate repository.

**Changes:**
- .gitignore: Removed contract-specific ignore patterns for DAO and wSNR contracts
- .gitignore: Removed hway and motr binary references (already migrated)
- .rgignore: Removed contracts, chains, and crypto directory references
- docs/reference/contracts/: Removed DAO.mdx and wSNR.mdx documentation files

This completes the cleanup of migrated components from the repository.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: add crypto library migration documentation

Added comprehensive migration documentation for the crypto library that was
moved to sonr-io/crypto repository. This documentation provides complete context
for understanding the cryptographic primitives and protocols used throughout
the Sonr ecosystem.

## Key Documentation Added

### MIGRATE_CRYPTO.md
Complete documentation of the crypto library covering:

**Core Cryptographic Primitives**
- Elliptic curve implementations (Ed25519, Secp256k1, P-256, BLS12-381, Pallas/Vesta)
- Native curve arithmetic with optimized field operations
- Pairing-friendly curves for BLS signatures

**Multi-Party Computation (MPC)**
- MPC enclave for vault key generation and management
- Threshold cryptography (TECDSA, TED25519 with FROST protocol)
- Distributed Key Generation (DKG) via Gennaro and FROST protocols
- Secret sharing schemes (Shamir, Feldman VSS, Pedersen VSS)

**Digital Signature Schemes**
- BLS signatures with aggregation support
- BBS+ signatures for selective disclosure
- Schnorr signatures (standard and Mina/NEM variants)
- ECDSA with deterministic nonce generation

**Zero-Knowledge Proofs**
- Bulletproofs for range proofs
- Inner Product Arguments (IPA)
- Batch verification support

**Advanced Cryptographic Protocols**
- Cryptographic accumulators for set membership proofs
- Paillier homomorphic encryption
- Oblivious Transfer (OT) protocols
- Verifiable Random Functions (VRF)

**Key Management & Identity**
- DID key management with multi-chain support
- Multi-algorithm public key handling
- Wallet address derivation (Bitcoin, Ethereum, Cosmos, Solana, etc.)

**UCAN Integration**
- User-Controlled Authorization Networks
- Capability delegation and attenuation
- JWT-based capability tokens
- MPC-enabled UCAN signing

**Security Utilities**
- AEAD encryption (AES-GCM, AES-SIV)
- Argon2 key derivation
- ECIES encryption
- Secure memory handling

### MIGRATE_MOTR.md Updates
Updated Motor migration documentation to clarify that the crypto library
is now a separate external dependency at github.com/sonr-io/crypto v1.0.1

## Repository Context

The crypto library has been successfully migrated to its own repository
and is published as a Go module. It serves as the foundational cryptographic
layer for:
- Sonr blockchain (snrd) - DID signatures, vault operations
- Highway service (hway) - UCAN token signing, WebAuthn
- Motor/Worker (motr) - MPC vault operations, threshold signatures

## Integration Impact

All Sonr ecosystem components now depend on the external crypto library:
```go
require github.com/sonr-io/crypto v1.0.1
```

The migration enables independent versioning and maintenance of cryptographic
primitives while maintaining security and compatibility across the ecosystem.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* No commit suggestions generated

* No commit suggestions generated

* No commit suggestions generated

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-10 11:47:18 -04:00

1136 lines
32 KiB
Go

package keeper
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/query"
"lukechampine.com/blake3"
apiv1 "github.com/sonr-io/sonr/api/did/v1"
"github.com/sonr-io/common/webauthn"
"github.com/sonr-io/sonr/x/did/types"
)
var _ types.QueryServer = Querier{}
type Querier struct {
Keeper
}
func NewQuerier(keeper Keeper) Querier {
return Querier{Keeper: keeper}
}
func (k Querier) Params(
c context.Context,
req *types.QueryParamsRequest,
) (*types.QueryParamsResponse, error) {
ctx := sdk.UnwrapSDKContext(c)
p, err := k.Keeper.Params.Get(ctx)
if err != nil {
return nil, err
}
return &types.QueryParamsResponse{Params: &p}, nil
}
// ResolveDID implements types.QueryServer.
func (k Querier) ResolveDID(
goCtx context.Context,
req *types.QueryResolveDIDRequest,
) (*types.QueryResolveDIDResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Did == "" {
return nil, errors.Wrap(types.ErrEmptyDID, "DID cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get DID document from ORM
ormDoc, err := k.OrmDB.DIDDocumentTable().Get(ctx, req.Did)
if err != nil {
return nil, errors.Wrapf(types.ErrDIDNotFound, "%s", req.Did)
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
// Get metadata
ormMetadata, err := k.OrmDB.DIDDocumentMetadataTable().Get(ctx, req.Did)
if err != nil {
// Metadata might not exist, which is ok
ormMetadata = nil
}
var metadata *types.DIDDocumentMetadata
if ormMetadata != nil {
metadata = types.DIDDocumentMetadataFromORM(ormMetadata)
}
return &types.QueryResolveDIDResponse{
DidDocument: didDoc,
DidDocumentMetadata: metadata,
}, nil
}
// GetDIDDocument implements types.QueryServer.
func (k Querier) GetDIDDocument(
goCtx context.Context,
req *types.QueryGetDIDDocumentRequest,
) (*types.QueryGetDIDDocumentResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Did == "" {
return nil, errors.Wrap(types.ErrEmptyDID, "DID cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get DID document from ORM
ormDoc, err := k.OrmDB.DIDDocumentTable().Get(ctx, req.Did)
if err != nil {
return nil, errors.Wrapf(types.ErrDIDNotFound, "%s", req.Did)
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
return &types.QueryGetDIDDocumentResponse{
DidDocument: didDoc,
}, nil
}
// ListDIDDocuments implements types.QueryServer.
func (k Querier) ListDIDDocuments(
goCtx context.Context,
req *types.QueryListDIDDocumentsRequest,
) (*types.QueryListDIDDocumentsResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Create pagination query
var pageReq *query.PageRequest
if req.Pagination != nil {
pageReq = req.Pagination
} else {
// Default pagination
pageReq = &query.PageRequest{
Limit: 100,
}
}
// List DID documents with pagination
var documents []*types.DIDDocument
pageRes := &query.PageResponse{}
// Get all documents from the table
iter, err := k.OrmDB.DIDDocumentTable().List(ctx, apiv1.DIDDocumentPrimaryKey{})
if err != nil {
return nil, errors.Wrap(err, "failed to list DID documents")
}
defer iter.Close()
// Apply pagination manually
offset := pageReq.Offset
limit := pageReq.Limit
count := uint64(0)
totalCount := uint64(0)
for iter.Next() {
totalCount++
// Skip items before offset
if count < offset {
count++
continue
}
// Stop if we've reached the limit
if uint64(len(documents)) >= limit && limit > 0 {
continue
}
ormDoc, err := iter.Value()
if err != nil {
return nil, errors.Wrap(err, "failed to get DID document from iterator")
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
documents = append(documents, didDoc)
count++
}
// Set page response
pageRes.Total = totalCount
if uint64(len(documents)) < limit || limit == 0 {
pageRes.NextKey = nil
} else {
pageRes.NextKey = []byte(documents[len(documents)-1].Id)
}
return &types.QueryListDIDDocumentsResponse{
DidDocuments: documents,
Pagination: pageRes,
}, nil
}
// GetDIDDocumentsByController implements types.QueryServer.
func (k Querier) GetDIDDocumentsByController(
goCtx context.Context,
req *types.QueryGetDIDDocumentsByControllerRequest,
) (*types.QueryGetDIDDocumentsByControllerResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Controller == "" {
return nil, errors.Wrap(
types.ErrInvalidControllerAddress,
"controller address cannot be empty",
)
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get all DID documents by controller using the index
indexKey := apiv1.DIDDocumentPrimaryControllerIndexKey{}.WithPrimaryController(
req.Controller,
)
iter, err := k.OrmDB.DIDDocumentTable().List(ctx, indexKey)
if err != nil {
return nil, errors.Wrap(err, "failed to list DID documents by controller")
}
defer iter.Close()
var documents []*types.DIDDocument
for iter.Next() {
ormDoc, err := iter.Value()
if err != nil {
return nil, errors.Wrap(err, "failed to get DID document from iterator")
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
documents = append(documents, didDoc)
}
return &types.QueryGetDIDDocumentsByControllerResponse{
DidDocuments: documents,
}, nil
}
// GetVerificationMethod implements types.QueryServer.
func (k Querier) GetVerificationMethod(
goCtx context.Context,
req *types.QueryGetVerificationMethodRequest,
) (*types.QueryGetVerificationMethodResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Did == "" {
return nil, errors.Wrap(types.ErrEmptyDID, "DID cannot be empty")
}
if req.MethodId == "" {
return nil, errors.Wrap(
types.ErrEmptyVerificationMethodID,
"verification method ID cannot be empty",
)
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get DID document from ORM
ormDoc, err := k.OrmDB.DIDDocumentTable().Get(ctx, req.Did)
if err != nil {
return nil, errors.Wrapf(types.ErrDIDNotFound, "%s", req.Did)
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
// Find the verification method
for _, vm := range didDoc.VerificationMethod {
if vm.Id == req.MethodId {
return &types.QueryGetVerificationMethodResponse{
VerificationMethod: vm,
}, nil
}
}
return nil, errors.Wrapf(types.ErrVerificationMethodNotFound, "%s", req.MethodId)
}
// GetService implements types.QueryServer.
func (k Querier) GetService(
goCtx context.Context,
req *types.QueryGetServiceRequest,
) (*types.QueryGetServiceResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Did == "" {
return nil, errors.Wrap(types.ErrEmptyDID, "DID cannot be empty")
}
if req.ServiceId == "" {
return nil, errors.Wrap(types.ErrEmptyServiceID, "service ID cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get DID document from ORM
ormDoc, err := k.OrmDB.DIDDocumentTable().Get(ctx, req.Did)
if err != nil {
return nil, errors.Wrapf(types.ErrDIDNotFound, "%s", req.Did)
}
// Convert from ORM type
didDoc := types.DIDDocumentFromORM(ormDoc)
// Find the service
for _, svc := range didDoc.Service {
if svc.Id == req.ServiceId {
return &types.QueryGetServiceResponse{
Service: svc,
}, nil
}
}
return nil, errors.Wrapf(types.ErrServiceNotFound, "%s", req.ServiceId)
}
// GetVerifiableCredential implements types.QueryServer.
func (k Querier) GetVerifiableCredential(
goCtx context.Context,
req *types.QueryGetVerifiableCredentialRequest,
) (*types.QueryGetVerifiableCredentialResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.CredentialId == "" {
return nil, errors.Wrap(types.ErrEmptyCredentialID, "credential ID cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Get credential from ORM
ormCred, err := k.OrmDB.VerifiableCredentialTable().Get(ctx, req.CredentialId)
if err != nil {
return nil, errors.Wrapf(types.ErrCredentialNotFound, "%s", req.CredentialId)
}
// Convert from ORM type
credential := types.VerifiableCredentialFromORM(ormCred)
return &types.QueryGetVerifiableCredentialResponse{
Credential: credential,
}, nil
}
// ListVerifiableCredentials implements types.QueryServer with filtering support.
func (k Querier) ListVerifiableCredentials(
goCtx context.Context,
req *types.QueryListVerifiableCredentialsRequest,
) (*types.QueryListVerifiableCredentialsResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Create pagination query
var pageReq *query.PageRequest
if req.Pagination != nil {
pageReq = req.Pagination
} else {
// Default pagination
pageReq = &query.PageRequest{
Limit: 100,
}
}
var credentials []*types.VerifiableCredential
pageRes := &query.PageResponse{}
// Determine which index to use based on filters
var iter apiv1.VerifiableCredentialIterator
var err error
if req.Issuer != "" {
// Use issuer index
indexKey := apiv1.VerifiableCredentialIssuerIndexKey{}.WithIssuer(req.Issuer)
iter, err = k.OrmDB.VerifiableCredentialTable().List(ctx, indexKey)
if err != nil {
return nil, errors.Wrap(err, "failed to list credentials by issuer")
}
} else if req.Holder != "" {
// Use subject/holder index
indexKey := apiv1.VerifiableCredentialSubjectIndexKey{}.WithSubject(req.Holder)
iter, err = k.OrmDB.VerifiableCredentialTable().List(ctx, indexKey)
if err != nil {
return nil, errors.Wrap(err, "failed to list credentials by holder")
}
} else {
// List all credentials
iter, err = k.OrmDB.VerifiableCredentialTable().
List(ctx, apiv1.VerifiableCredentialPrimaryKey{})
if err != nil {
return nil, errors.Wrap(err, "failed to list credentials")
}
}
defer iter.Close()
// Apply pagination and filters
offset := pageReq.Offset
limit := pageReq.Limit
count := uint64(0)
totalCount := uint64(0)
for iter.Next() {
ormCred, err := iter.Value()
if err != nil {
return nil, errors.Wrap(err, "failed to get credential from iterator")
}
// Convert from ORM type
credential := types.VerifiableCredentialFromORM(ormCred)
// Apply additional filters
if req.Issuer != "" && credential.Issuer != req.Issuer {
continue
}
if req.Holder != "" && credential.Subject != req.Holder {
continue
}
if !req.IncludeRevoked && credential.Revoked {
continue
}
totalCount++
// Skip items before offset
if count < offset {
count++
continue
}
// Stop if we've reached the limit
if uint64(len(credentials)) >= limit && limit > 0 {
continue
}
credentials = append(credentials, credential)
count++
}
// Set page response
pageRes.Total = totalCount
if uint64(len(credentials)) < limit || limit == 0 {
pageRes.NextKey = nil
} else if len(credentials) > 0 {
pageRes.NextKey = []byte(credentials[len(credentials)-1].Id)
}
return &types.QueryListVerifiableCredentialsResponse{
Credentials: credentials,
Pagination: pageRes,
}, nil
}
// getVaultInfoForDID retrieves vault information for a DID
func (k Querier) getVaultInfoForDID(
ctx sdk.Context,
did string,
) (vaultId string, isEncrypted bool) {
// Default values
vaultId = ""
isEncrypted = false
// Check if DWN keeper is available
if k.dwnKeeper == nil {
return vaultId, isEncrypted
}
// Query vaults associated with the DID
vaults, err := k.dwnKeeper.GetVaultsByDID(ctx, did)
if err != nil || len(vaults) == 0 {
return vaultId, isEncrypted
}
// Use the first active vault
for _, vault := range vaults {
if vault.Status == "active" {
vaultId = vault.VaultID
isEncrypted = true // Assume vault storage means encryption
return vaultId, isEncrypted
}
}
return vaultId, isEncrypted
}
// GetCredentialsByDID implements types.QueryServer - unified method for all credentials.
func (k Querier) GetCredentialsByDID(
goCtx context.Context,
req *types.QueryGetCredentialsByDIDRequest,
) (*types.QueryGetCredentialsByDIDResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
if req.Did == "" {
return nil, errors.Wrap(types.ErrEmptyDID, "DID cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
var credentialInfos []*types.CredentialInfo
// 1. Get WebAuthn credentials if requested (default: true)
if req.IncludeWebauthn || (!req.IncludeVerifiable && !req.IncludeWebauthn) {
webauthnCreds, err := k.GetWebAuthnCredentialsByDID(ctx, req.Did)
if err == nil {
for _, wc := range webauthnCreds {
info := &types.CredentialInfo{
Credential: &types.CredentialInfo_WebauthnCredential{
WebauthnCredential: wc,
},
}
// Check if stored in vault
vaultId, isEncrypted := k.getVaultInfoForDID(ctx, req.Did)
info.VaultId = vaultId
info.IsEncrypted = isEncrypted
credentialInfos = append(credentialInfos, info)
}
}
}
// 2. Get Verifiable Credentials if requested (default: true)
if req.IncludeVerifiable || (!req.IncludeVerifiable && !req.IncludeWebauthn) {
// Get credentials where DID is issuer
issuerIndex := apiv1.VerifiableCredentialIssuerIndexKey{}.WithIssuer(req.Did)
issuerIter, err := k.OrmDB.VerifiableCredentialTable().List(ctx, issuerIndex)
if err == nil {
defer issuerIter.Close()
for issuerIter.Next() {
ormCred, err := issuerIter.Value()
if err != nil {
continue
}
credential := types.VerifiableCredentialFromORM(ormCred)
// Skip revoked if not requested
if credential.Revoked && !req.IncludeRevoked {
continue
}
info := &types.CredentialInfo{
Credential: &types.CredentialInfo_VerifiableCredential{
VerifiableCredential: credential,
},
}
// Check if stored in vault
vaultId, isEncrypted := k.getVaultInfoForDID(ctx, req.Did)
info.VaultId = vaultId
info.IsEncrypted = isEncrypted
credentialInfos = append(credentialInfos, info)
}
}
// Get credentials where DID is holder
holderIndex := apiv1.VerifiableCredentialSubjectIndexKey{}.WithSubject(req.Did)
holderIter, err := k.OrmDB.VerifiableCredentialTable().List(ctx, holderIndex)
if err == nil {
defer holderIter.Close()
for holderIter.Next() {
ormCred, err := holderIter.Value()
if err != nil {
continue
}
credential := types.VerifiableCredentialFromORM(ormCred)
// Skip if already added as issuer
isDuplicate := false
for _, existing := range credentialInfos {
if vc := existing.GetVerifiableCredential(); vc != nil &&
vc.Id == credential.Id {
isDuplicate = true
break
}
}
if isDuplicate {
continue
}
// Skip revoked if not requested
if credential.Revoked && !req.IncludeRevoked {
continue
}
info := &types.CredentialInfo{
Credential: &types.CredentialInfo_VerifiableCredential{
VerifiableCredential: credential,
},
}
// Check if stored in vault
vaultId, isEncrypted := k.getVaultInfoForDID(ctx, req.Did)
info.VaultId = vaultId
info.IsEncrypted = isEncrypted
credentialInfos = append(credentialInfos, info)
}
}
}
// Apply pagination
pageRes := &query.PageResponse{}
if req.Pagination != nil {
start := int(req.Pagination.Offset)
end := start + int(req.Pagination.Limit)
if start > len(credentialInfos) {
credentialInfos = []*types.CredentialInfo{}
} else if end > len(credentialInfos) {
credentialInfos = credentialInfos[start:]
} else {
credentialInfos = credentialInfos[start:end]
pageRes.NextKey = []byte("next")
}
}
pageRes.Total = uint64(len(credentialInfos))
return &types.QueryGetCredentialsByDIDResponse{
Credentials: credentialInfos,
Pagination: pageRes,
}, nil
}
// RegisterStart implements types.QueryServer.
// TODO: Complete implementation per issue #269
func (k Querier) RegisterStart(
goCtx context.Context,
req *types.QueryRegisterStartRequest,
) (*types.QueryRegisterStartResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
// Validate assertion DID format
if req.AssertionDid == "" {
return nil, errors.Wrap(types.ErrInvalidRequest, "assertion_did cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Check that assertion DID does NOT exist (opposite of LoginStart)
existingAssertion, err := k.OrmDB.AssertionTable().Get(ctx, req.AssertionDid)
if err == nil && existingAssertion != nil {
return nil, errors.Wrapf(types.ErrDIDAlreadyExists, "assertion already exists: %s", req.AssertionDid)
}
// Generate deterministic challenge
challenge, nonce, err := k.generateWebAuthnChallenge(ctx, req.AssertionDid)
if err != nil {
return nil, errors.Wrapf(err, "failed to generate challenge")
}
// Get relying party ID from params
params, err := k.Keeper.Params.Get(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get module params")
}
// Extract user information from assertion DID
userName, displayName := k.ExtractUserInfoFromAssertionDID(req.AssertionDid)
user := map[string]string{
"id": req.AssertionDid, // Full DID as unique identifier
"name": userName, // Human-readable name (email/phone type)
"displayName": displayName, // User-friendly display name
}
// Store registration session for later validation
if err := k.storeRegistrationSession(ctx, req.AssertionDid, challenge, nonce); err != nil {
return nil, errors.Wrap(err, "failed to store registration session")
}
// Convert challenge string to bytes
challengeBytes := []byte(challenge)
// Get relying party ID from webauthn params
rpId := ""
if params.Webauthn != nil {
rpId = params.Webauthn.DefaultRpId
}
return &types.QueryRegisterStartResponse{
Challenge: challengeBytes,
RelyingPartyId: rpId,
User: user,
}, nil
}
// LoginStart implements types.QueryServer.
// TODO: Complete implementation per issue #269
func (k Querier) LoginStart(
goCtx context.Context,
req *types.QueryLoginStartRequest,
) (*types.QueryLoginStartResponse, error) {
if req == nil {
return nil, errors.Wrap(types.ErrInvalidRequest, "request cannot be nil")
}
// Validate assertion DID format
if req.AssertionDid == "" {
return nil, errors.Wrap(types.ErrInvalidRequest, "assertion_did cannot be empty")
}
ctx := sdk.UnwrapSDKContext(goCtx)
// Verify assertion DID exists (opposite of RegisterStart)
assertion, err := k.OrmDB.AssertionTable().Get(ctx, req.AssertionDid)
if err != nil {
return nil, errors.Wrapf(types.ErrAssertionNotFound, "assertion DID %s not found", req.AssertionDid)
}
// Log login attempt for security audit trail
ctx.Logger().Info("WebAuthn login start",
"assertion_did", req.AssertionDid,
"controller", assertion.Controller,
"block_height", ctx.BlockHeight(),
)
// Get controller DID from assertion
if assertion.Controller == "" {
return nil, errors.Wrapf(types.ErrInvalidAssertion, "assertion %s has no controller", req.AssertionDid)
}
// Get controller's DID document
controllerDoc, err := k.OrmDB.DIDDocumentTable().Get(ctx, assertion.Controller)
if err != nil {
return nil, errors.Wrapf(types.ErrDIDNotFound, "controller DID %s not found", assertion.Controller)
}
// Check if DID is deactivated
if controllerDoc.Deactivated {
return nil, errors.Wrapf(types.ErrDIDDeactivated, "controller DID %s is deactivated", assertion.Controller)
}
// Extract credential IDs from authentication methods (WebAuthn only)
credentialIds, err := k.ExtractWebAuthnCredentialIDs(ctx, controllerDoc)
if err != nil {
return nil, errors.Wrap(err, "failed to extract WebAuthn credential IDs")
}
if len(credentialIds) == 0 {
return nil, errors.Wrapf(types.ErrNoCredentials, "no WebAuthn credentials found for controller %s", assertion.Controller)
}
ctx.Logger().Debug("extracted WebAuthn credentials",
"controller", assertion.Controller,
"credential_count", len(credentialIds),
)
// Generate deterministic challenge
challenge, nonce, err := k.generateWebAuthnChallenge(ctx, req.AssertionDid)
if err != nil {
return nil, errors.Wrap(err, "failed to generate challenge")
}
// Get relying party ID from params
params, err := k.Keeper.Params.Get(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get module params")
}
// Store login session for later validation
if err := k.storeLoginSession(ctx, req.AssertionDid, challenge, nonce); err != nil {
return nil, errors.Wrap(err, "failed to store login session")
}
// Convert challenge string to bytes
challengeBytes := []byte(challenge)
// Get relying party ID from webauthn params
rpId := ""
if params.Webauthn != nil {
rpId = params.Webauthn.DefaultRpId
}
return &types.QueryLoginStartResponse{
CredentialIds: credentialIds,
Challenge: challengeBytes,
RelyingPartyId: rpId,
}, nil
}
// Helper methods for WebAuthn validation
// ValidateServiceOrigin validates the service origin for WebAuthn operations.
// Validation rules:
// 1. Origin format must be valid (scheme://host[:port])
// 2. HTTPS is required except for localhost/127.0.0.1
// 3. Check against x/svc module domain registry (if available)
// 4. Check against module params allowed origins list
// 5. Support wildcard subdomain matching (*.example.com)
func (k Querier) ValidateServiceOrigin(ctx sdk.Context, origin string) error {
// Validate origin format
if origin == "" {
return errors.Wrap(types.ErrInvalidRequest, "origin cannot be empty")
}
// Check for valid scheme
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
return errors.Wrapf(types.ErrInvalidRequest, "origin must start with http:// or https://, got: %s", origin)
}
// Extract domain from origin
domain := ExtractDomainFromOrigin(origin)
if domain == "" {
return errors.Wrapf(types.ErrInvalidRequest, "invalid origin format: %s", origin)
}
// Allow localhost and 127.0.0.1 with both HTTP and HTTPS
if k.IsLocalhostOrigin(domain) {
ctx.Logger().Debug("allowing localhost origin", "origin", origin)
return nil
}
// Non-localhost origins must use HTTPS
if strings.HasPrefix(origin, "http://") {
return errors.Wrapf(types.ErrInvalidRequest, "non-localhost origins must use HTTPS, got: %s", origin)
}
// Try x/svc module verification first (if keeper available)
if k.serviceKeeper != nil {
if err := k.serviceKeeper.VerifyOrigin(ctx, origin); err == nil {
ctx.Logger().Debug("origin verified via x/svc module", "origin", origin)
return nil
}
// Continue to check module params if x/svc verification fails
ctx.Logger().Debug("origin not found in x/svc registry, checking module params", "origin", origin)
}
// Check module params allowed origins list
params, err := k.Keeper.Params.Get(ctx)
if err != nil {
return errors.Wrap(err, "failed to get module params")
}
if params.Webauthn == nil || len(params.Webauthn.AllowedOrigins) == 0 {
return errors.Wrapf(types.ErrInvalidRequest,
"origin %s not registered in x/svc and no allowed origins configured in module params", origin)
}
// Check against allowed origins with wildcard support
for _, allowedOrigin := range params.Webauthn.AllowedOrigins {
if k.MatchesOrigin(origin, domain, allowedOrigin) {
ctx.Logger().Debug("origin matched in allowed origins", "origin", origin, "pattern", allowedOrigin)
return nil
}
}
return errors.Wrapf(types.ErrInvalidRequest,
"origin %s not registered in x/svc module and not in allowed origins list", origin)
}
// IsLocalhostOrigin checks if the domain is localhost or 127.0.0.1
func (k Querier) IsLocalhostOrigin(domain string) bool {
return domain == "localhost" || domain == "127.0.0.1" || domain == "[::1]"
}
// MatchesOrigin checks if an origin matches an allowed origin pattern.
// Supports wildcard subdomain matching (*.example.com matches app.example.com)
func (k Querier) MatchesOrigin(fullOrigin, domain, allowedOrigin string) bool {
// Exact match
if fullOrigin == allowedOrigin {
return true
}
// Extract domain from allowed origin for wildcard matching
allowedDomain := ExtractDomainFromOrigin(allowedOrigin)
// Check for wildcard subdomain pattern (*.example.com)
if strings.HasPrefix(allowedDomain, "*.") {
baseDomain := strings.TrimPrefix(allowedDomain, "*.")
// Match if domain ends with .baseDomain
if strings.HasSuffix(domain, "."+baseDomain) {
return true
}
// Also match the base domain itself (example.com matches *.example.com)
if domain == baseDomain {
return true
}
}
return false
}
// ExtractDomainFromOrigin extracts the domain from an origin URL
func ExtractDomainFromOrigin(origin string) string {
// Remove protocol
domain := strings.TrimPrefix(origin, "https://")
domain = strings.TrimPrefix(domain, "http://")
// Remove path if present (do this before port to handle IPv6)
if idx := strings.Index(domain, "/"); idx != -1 {
domain = domain[:idx]
}
// Handle IPv6 addresses (enclosed in brackets)
if strings.HasPrefix(domain, "[") {
// Find closing bracket
if idx := strings.Index(domain, "]"); idx != -1 {
// Return everything up to and including the closing bracket
domain = domain[:idx+1]
}
} else {
// For non-IPv6, remove port if present
if idx := strings.Index(domain, ":"); idx != -1 {
domain = domain[:idx]
}
}
return domain
}
// ExtractUserInfoFromAssertionDID extracts human-readable user information from assertion DID.
// Assertion DID format: did:sonr:email:<blake3_hash> or did:sonr:phone:<blake3_hash>
// Returns: (name, displayName) suitable for WebAuthn user object
func (k Querier) ExtractUserInfoFromAssertionDID(assertionDid string) (name string, displayName string) {
// Remove "did:" prefix
trimmed := types.TrimDIDMethodPrefix(assertionDid)
// Parse format: sonr:email:<hash> or sonr:phone:<hash>
parts := strings.Split(trimmed, ":")
if len(parts) < 3 {
// Fallback if format doesn't match expected
return assertionDid, assertionDid
}
// parts[0] = "sonr"
// parts[1] = assertion type (email, phone, etc.)
// parts[2] = blake3 hash
assertionType := parts[1]
hashValue := parts[2]
// Create human-readable name based on assertion type
switch assertionType {
case "email":
name = fmt.Sprintf("Email User")
displayName = fmt.Sprintf("Email (%s...)", hashValue[:8])
case "phone", "tel":
name = fmt.Sprintf("Phone User")
displayName = fmt.Sprintf("Phone (%s...)", hashValue[:8])
case "github":
name = fmt.Sprintf("GitHub User")
displayName = fmt.Sprintf("GitHub (%s...)", hashValue[:8])
case "google":
name = fmt.Sprintf("Google User")
displayName = fmt.Sprintf("Google (%s...)", hashValue[:8])
default:
name = fmt.Sprintf("%s User", strings.Title(assertionType))
displayName = fmt.Sprintf("%s (%s...)", strings.Title(assertionType), hashValue[:8])
}
return name, displayName
}
// ExtractWebAuthnCredentialIDs extracts WebAuthn credential IDs from a DID document's authentication methods.
// Only returns credentials with WebAuthn type verification methods.
func (k Querier) ExtractWebAuthnCredentialIDs(ctx sdk.Context, didDoc *apiv1.DIDDocument) ([]string, error) {
var credentialIds []string
// Iterate through authentication verification method references
for _, authRef := range didDoc.Authentication {
// Check if it's an embedded verification method
if authRef.EmbeddedVerificationMethod != nil {
vm := authRef.EmbeddedVerificationMethod
// Check if this is a WebAuthn verification method
if vm.WebauthnCredential != nil && vm.WebauthnCredential.CredentialId != "" {
credentialIds = append(credentialIds, vm.WebauthnCredential.CredentialId)
ctx.Logger().Debug("found WebAuthn credential",
"credential_id", vm.WebauthnCredential.CredentialId,
"verification_method_id", vm.Id,
)
}
} else if authRef.VerificationMethodId != "" {
// It's a reference - need to look up in verificationMethod array
for _, vm := range didDoc.VerificationMethod {
if vm.Id == authRef.VerificationMethodId {
// Check if this is a WebAuthn verification method
if vm.WebauthnCredential != nil && vm.WebauthnCredential.CredentialId != "" {
credentialIds = append(credentialIds, vm.WebauthnCredential.CredentialId)
ctx.Logger().Debug("found WebAuthn credential",
"credential_id", vm.WebauthnCredential.CredentialId,
"verification_method_id", vm.Id,
)
}
break
}
}
}
}
return credentialIds, nil
}
// generateWebAuthnChallenge generates a deterministic cryptographic challenge for WebAuthn ceremonies
// The challenge is a BLAKE3 hash of: assertion_did || block_height || chain_id || nonce
// Returns: base64url-encoded challenge (32 bytes), nonce, error
func (k Querier) generateWebAuthnChallenge(ctx sdk.Context, assertionDid string) (string, string, error) {
// Generate a unique nonce based on block time and assertion DID
// This ensures uniqueness even within the same block
nonce := fmt.Sprintf("%d:%s:%d",
ctx.BlockHeight(),
assertionDid,
ctx.BlockTime().UnixNano())
// Construct deterministic input for BLAKE3 hash
// Format: assertion_did || block_height || chain_id || nonce
input := fmt.Sprintf("%s||%d||%s||%s",
assertionDid,
ctx.BlockHeight(),
ctx.ChainID(),
nonce)
// Generate BLAKE3 hash (32 bytes)
hash := blake3.Sum256([]byte(input))
// Encode as base64url for WebAuthn (32 bytes meets spec requirement of 16-64 bytes)
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
return challenge, nonce, nil
}
// storeRegistrationSession stores the registration session for later validation
func (k Querier) storeRegistrationSession(
ctx sdk.Context,
assertionDid string,
challenge string,
nonce string,
) error {
// TODO: Implement session storage in state
// This will store:
// - assertion_did
// - challenge
// - nonce (for replay prevention)
// - expiration timestamp (e.g., 5 minutes from now)
// - session_type: "registration"
// For now, just log that we would store this
ctx.Logger().Debug("storing registration session",
"assertion_did", assertionDid,
"challenge_length", len(challenge),
"nonce", nonce,
)
return nil
}
// storeLoginSession stores the login session for later validation
func (k Querier) storeLoginSession(
ctx sdk.Context,
assertionDid string,
challenge string,
nonce string,
) error {
// TODO: Implement session storage in state
// This will store:
// - assertion_did
// - challenge
// - nonce (for replay prevention)
// - expiration timestamp (e.g., 5 minutes from now)
// - session_type: "login"
// For now, just log that we would store this
ctx.Logger().Debug("storing login session",
"assertion_did", assertionDid,
"challenge_length", len(challenge),
"nonce", nonce,
)
return nil
}
// createWebAuthnOptions creates WebAuthn credential creation options
func (k Querier) createWebAuthnOptions(challenge, origin, username string) []byte {
// Extract RP ID from origin
rpID := ExtractDomainFromOrigin(origin)
if rpID == "" {
rpID = "localhost"
}
// Create WebAuthn options
options := webauthn.PublicKeyCredentialCreationOptions{
Challenge: webauthn.URLEncodedBase64(challenge),
RelyingParty: webauthn.RelyingPartyEntity{
ID: rpID,
},
User: webauthn.UserEntity{
ID: []byte(username),
DisplayName: username,
},
Parameters: []webauthn.CredentialParameter{
{
Type: webauthn.PublicKeyCredentialType,
Algorithm: -7, // ES256
},
{
Type: webauthn.PublicKeyCredentialType,
Algorithm: -257, // RS256
},
},
Timeout: 60000, // 60 seconds
AuthenticatorSelection: webauthn.AuthenticatorSelection{
AuthenticatorAttachment: webauthn.Platform,
RequireResidentKey: boolPtr(false),
ResidentKey: webauthn.ResidentKeyRequirementDiscouraged,
UserVerification: webauthn.VerificationPreferred,
},
Attestation: webauthn.PreferNoAttestation,
}
// Marshal to JSON
optionsJSON, err := json.Marshal(options)
if err != nil {
k.logger.Error("Failed to marshal WebAuthn options", "error", err)
return []byte("{}")
}
return optionsJSON
}
// boolPtr returns a pointer to a bool value
func boolPtr(b bool) *bool {
return &b
}