UC-007: Authenticate via QR Code
Description
An external application scans the user's identity QR code (displayed in UC-006) and initiates an authentication challenge. The wallet receives the challenge via a deep link (almena://auth?challenge=...), displays a consent screen where the user can approve or reject, and if approved, signs the challenge with the Ed25519 private key and sends the signed response to the requesting application's callback URL.
Actors
- End User: Person approving or rejecting the authentication request in the wallet
- External Application: Web app, service, or another wallet that scans the QR code and initiates the challenge
- Backend API: Platform backend that creates the authentication challenge and validates the signed response
- Wallet (Frontend): Svelte application handling the deep link, consent UI, and response flow
- Wallet (Rust Backend): Tauri commands managing challenge storage, Ed25519 signing, and response construction
Preconditions
- The user has an identity created in the wallet (UC-001)
- The wallet is running (foreground or background)
- The identity QR code has been scanned by an external application
- The
tauri-plugin-deep-linkis configured to handlealmena://URLs - The Ed25519 private key is stored in the system keychain
Main Flow
- The external application scans the user's identity QR code and extracts the DID from the JSON payload
- The external application sends the DID to the backend API to initiate an authentication request
- The backend creates an authentication challenge containing:
challenge_id: unique identifiernonce: random value to prevent replaytimestamp: when the challenge was createdexpires_at: when the challenge expiresorigin: the requesting application's identifiercallback_url: where to send the signed responserequested_proof: what is being requested
- The backend encodes the challenge as base64url JSON and constructs a deep link:
almena://auth?challenge=<base64url_encoded_challenge> - The deep link is delivered to the wallet (via OS URL handler)
- The wallet's
tauri-plugin-deep-linkcaptures thealmena://URL and triggerson_open_url - The wallet calls
handle_deep_link()which parses the URL auth::parse_auth_deep_link()extracts thechallengeparameter, decodes the base64url JSON, and deserializes theAuthRequeststruct- The challenge is stored in the
PENDING_AUTH_REQUESTmutex (Rust-side global state) - The wallet frontend detects the pending request and displays the AuthConsent component showing:
- The requesting origin
- The requested action/proof
- A countdown timer showing time remaining until expiration (format:
MM:SS) - Approve and Reject buttons
- The user clicks Approve
- The wallet invokes the Rust command
approve_auth_request(did):- Retrieves the Ed25519 private key from the system keychain
- Builds the payload to sign:
{
"challenge_id": "...",
"nonce": "...",
"timestamp": "...",
"expires_at": "...",
"origin": "..."
} - Signs the payload bytes with Ed25519:
signing_key.sign(payload_bytes) - Constructs the
AuthResponse:{
"challenge_id": "...",
"did": "did:almena:...",
"signature": "<base64url_ed25519_signature>",
"signed_payload": "<base64url_payload>",
"verification_method": "did#key-1",
"timestamp": "..."
}
- The wallet POSTs the
AuthResponseto thecallback_urlfrom the original challenge - The backend verifies the signature against the DID's public key, validates nonce and timestamps, and completes the authentication
- The consent screen closes and the user returns to the wallet
Alternative Flows
AF-1: User rejects the request
- At step 11, the user clicks Reject
- The wallet calls
reject_auth_request()which clears thePENDING_AUTH_REQUEST - No response is sent to the callback URL
- The consent screen closes
AF-2: Challenge expired
- At step 10, the countdown reaches zero before the user acts
- The consent screen transitions to an "expired" state
- The approve button is disabled
- The user can only dismiss the screen
AF-3: Deep link received while wallet is locked
- At step 6, if the wallet session is locked, the user must first unlock (via password or biometrics, see UC-005)
- After unlocking, the pending auth request is displayed
AF-4: Callback URL unreachable
- At step 13, if the POST to the callback URL fails, an error is shown to the user
- The signed response was generated but could not be delivered
AF-5: Private key not found
- At step 12, if the Ed25519 private key is not found in the keychain, the operation fails with an error
Postconditions
- The authentication challenge has been signed and sent to the requesting application
- The
PENDING_AUTH_REQUESTis cleared - No persistent state changes in the wallet — the auth flow is stateless
- The external application can verify the user's identity using the signed response
Modules Involved
| Module | Role |
|---|---|
| wallet (frontend) | AuthConsent UI component, deep link detection, countdown timer, POST response to callback |
| wallet (Rust backend) | Deep link parsing (auth::parse_auth_deep_link), challenge storage (PENDING_AUTH_REQUEST mutex), Ed25519 signing (sign_challenge), response construction |
| backend | Creates authentication challenges, validates signed responses, completes authentication |
Technical Notes
- Deep link protocol:
almena://auth?challenge=<base64url>— handled bytauri-plugin-deep-link - Challenge storage: Single pending request stored in a Rust
Mutex<Option<AuthRequest>>. A new challenge overwrites any existing pending one - Ed25519 signing: The challenge payload is signed as raw bytes using the Ed25519 private key from the system keychain. The signature and payload are base64url-encoded in the response
- Verification method: The response references
did#key-1as the verification method, matching the key in the DID Document (if anchored, see UC-003) - No QR scanning in wallet: The wallet does NOT scan QR codes. It only displays them. Scanning is performed by external applications
- Stateless flow: Each authentication is independent. No sessions or tokens are persisted in the wallet after the response is sent