Skip to main content

Overview

CCDigital uses Hyperledger Indy and ACA-Py (Aries Cloud Agent - Python) to implement verifiable credential-based authentication and user access management. The system replaces traditional password-only login with a proof-based verification flow. Architecture:
  • Issuer Agent (ACA-Py): Issues credentials to users
  • Holder Agent (ACA-Py): Stores user credentials
  • Verifier (Backend): Requests and verifies proofs
  • Indy Ledger: Credential schemas and definitions
The backend acts as a verifier during login and an issuer administrator for credential lifecycle management.

Service Components

IndyAdminClient

Package: co.edu.unbosque.ccdigital.service Purpose: HTTP client for ACA-Py Admin API Features:
  • GET/POST requests to ACA-Py endpoints
  • X-API-Key header management
  • JSON response parsing to JsonNode
  • Base URL normalization

IndyProofLoginService

Package: co.edu.unbosque.ccdigital.service Purpose: Present-proof 2.0 protocol for login Features:
  • Sends proof requests filtered by id_number
  • Polls proof exchange status
  • Extracts revealed attributes (id_type, id_number, first_name, last_name, email)
  • Resolves holder connection (fixed or auto-discovery)

UserAuthFlowService

Package: co.edu.unbosque.ccdigital.service Purpose: Complete user authentication flow (proof + second factor) Features:
  • Orchestrates start/poll/verify/resend endpoints
  • Manages session state for pending authentication
  • Handles TOTP and email OTP as second factors
  • Creates Spring Security session after verification

UserAccessGovernanceService

Package: co.edu.unbosque.ccdigital.service Purpose: Admin control of user access state with Indy sync Features:
  • Updates user access state (ENABLED/SUSPENDED/DISABLED)
  • Synchronizes state to ACA-Py connection metadata
  • Records state changes on Fabric for audit
  • Handles sync failures gracefully

Configuration

Indy/ACA-Py Properties

# Issuer (Verifier) Admin API
ccdigital.indy.issuer-admin-url=http://localhost:8021

# Holder Admin API (for monitoring)
ccdigital.indy.holder-admin-url=http://localhost:8031

# Connection to holder
ccdigital.indy.holder-connection-id=auto
ccdigital.indy.holder-label=CCDigital-Holder

# Credential definition ID
ccdigital.indy.cred-def-id=ABC123:3:CL:456:default

# Admin API Key (optional)
ccdigital.indy.admin-api-key=your-api-key

# User Access Sync
ccdigital.indy.user-access-sync-enabled=true
ccdigital.indy.user-access-sync-path=/connections/{connection_id}/metadata

# Proof polling
acapy.proof.poll-interval-ms=1500
acapy.proof.poll-timeout-ms=120000
Environment Variables:
  • INDY_ISSUER_ADMIN_URL
  • INDY_HOLDER_ADMIN_URL
  • INDY_HOLDER_CONNECTION_ID
  • INDY_HOLDER_LABEL
  • INDY_CRED_DEF_ID
  • INDY_ADMIN_API_KEY
  • INDY_USER_ACCESS_SYNC_ENABLED
  • INDY_USER_ACCESS_SYNC_PATH

IndyAdminClient Methods

Executes GET request to ACA-Py Admin API.Parameters:
  • baseUrl - Base URL (e.g., http://localhost:8021)
  • path - API path (e.g., /status)
Returns: JsonNode - Parsed JSON responseThrows: IllegalStateException if response is not valid JSONExample:
JsonNode status = indyAdminClient.get(
    "http://localhost:8021",
    "/status"
);
System.out.println("Version: " + status.path("version").asText());
Executes POST request with JSON body.Parameters:
  • baseUrl - Base URL
  • path - API path
  • body - Object to serialize to JSON
Returns: JsonNode - Parsed JSON responseThrows: IllegalStateException on serialization or JSON parse errorExample:
Map<String, Object> payload = Map.of(
    "alias", "Test Connection",
    "their_label", "Holder"
);
JsonNode response = indyAdminClient.post(
    "http://localhost:8021",
    "/connections/create-invitation",
    payload
);
String invitationUrl = response.path("invitation_url").asText();

IndyProofLoginService Methods

Initiates present-proof 2.0 request filtered by ID number.Flow:
  1. Resolves holder connection_id (fixed or auto)
  2. Builds proof request with restrictions:
    • cred_def_id = configured credential definition
    • attr::id_number::value = provided idNumber
  3. Sends POST to /present-proof-2.0/send-request
  4. Returns record with presExId
Parameters:
  • idNumber - ID number to filter holder credentials
Returns: Map<String, Object> - Record with keys:
  • presExId - Presentation exchange ID
  • pres_ex_id - Raw ACA-Py field
  • state - Initial state (usually request-sent)
Example:
Map<String, Object> record = indyProofLoginService.startLoginByIdNumber(
    "1234567890"
);
String presExId = String.valueOf(record.get("presExId"));
The proof request includes attributes: id_type, id_number, first_name, last_name, email
Polls the status of a proof exchange.Parameters:
  • presExId - Presentation exchange ID
Returns: Map<String, Object> with:
  • presExId - Exchange ID
  • state - Current state (request-sent, presentation-received, done, abandoned)
  • verified - Boolean (true if proof verified)
  • done - Boolean (true if terminal state)
  • error - Error message (if present)
States:
  • request-sent - Waiting for holder to respond
  • presentation-received - Proof received, verifying
  • done - Successfully verified
  • abandoned - Failed or rejected
Example:
Map<String, Object> status = indyProofLoginService.getProofStatus(presExId);
if (Boolean.TRUE.equals(status.get("verified"))) {
    System.out.println("Proof verified!");
}
Extracts revealed attributes from a verified proof.Requirements:
  • State must be done or presentation-received
  • verified must be true
Returns: Map<String, String> with keys:
  • id_type - ID type (e.g., “CC”)
  • id_number - ID number
  • first_name - First name
  • last_name - Last name
  • email - Email address
Throws: IllegalStateException if proof not ready or not verifiedExample:
Map<String, String> attrs = indyProofLoginService.getVerifiedResultWithAttrs(
    presExId
);
String fullName = attrs.get("first_name") + " " + attrs.get("last_name");
System.out.println("Authenticated: " + fullName);
Lists all proof exchanges for admin monitoring.Returns: List<ProofTraceEvent> with fields:
  • presExId - Exchange ID
  • state - Current state
  • verified - Verification status
  • eventAt - Timestamp (normalized to America/Bogota)
  • idType, idNumber, firstName, lastName, email - Extracted or inferred
Usage:
List<ProofTraceEvent> events = indyProofLoginService.listProofTraceEvents();
for (ProofTraceEvent event : events) {
    System.out.println(event.idNumber() + " - " + event.state());
}
Limit query to 1000 records. For production, implement pagination at the ACA-Py level.

UserAuthFlowService Methods

Starts the authentication flow.Flow:
  1. Validates email/password against database
  2. Checks user access state (ENABLED/SUSPENDED/DISABLED)
  3. Looks up id_number from linked Person record
  4. Calls IndyProofLoginService.startLoginByIdNumber()
  5. Stores expected id_number in session
  6. Returns presExId for polling
Parameters:
  • email - User email
  • password - User password
  • request - HTTP request (for session)
Returns: ResponseEntity<Map<String, Object>> with:
  • presExId - Exchange ID for polling
Or error response:
  • 401: Invalid credentials
  • 403: Account suspended/disabled
Example:
ResponseEntity<Map<String, Object>> response = userAuthFlowService.start(
    "[email protected]",
    "password123",
    request
);
if (response.getStatusCode().is2xxSuccessful()) {
    String presExId = (String) response.getBody().get("presExId");
}
Polls proof status and prepares second factor.Flow:
  1. Validates session has expected id_number
  2. Calls IndyProofLoginService.getProofStatus()
  3. If proof verified:
    • Validates credential matches expected user
    • Determines second factor method (TOTP or email)
    • Sends email OTP if needed
    • Returns otpRequired=true
  4. If proof failed: Returns done=true
Parameters:
  • presExId - Exchange ID
  • request - HTTP request (for session)
Returns: ResponseEntity<Map<String, Object>> with:
  • authenticated - Boolean
  • otpRequired - Boolean
  • otpMethod - “totp” or “email”
  • maskedEmail - Partially masked email
  • message - Instruction message
Example:
ResponseEntity<Map<String, Object>> response = userAuthFlowService.poll(
    presExId,
    request
);
Map<String, Object> body = response.getBody();
if (Boolean.TRUE.equals(body.get("otpRequired"))) {
    System.out.println("Enter OTP sent to: " + body.get("maskedEmail"));
}
Verifies second factor code and completes authentication.Flow:
  1. Retrieves pending context from session
  2. Verifies code (TOTP or email OTP)
  3. If valid:
    • Creates IndyUserPrincipal
    • Authenticates Spring Security session
    • Returns authenticated=true with redirect URL
  4. If invalid:
    • Increments failed attempts
    • After 3 attempts: Locks and sends alert
Parameters:
  • presExId - Exchange ID
  • code - OTP code
  • request - HTTP request
Returns: ResponseEntity<Map<String, Object>> with:
  • authenticated - Boolean
  • redirectUrl - Dashboard URL (if successful)
  • displayName - User’s full name
Or error:
  • 401: Invalid code
  • 403: Max attempts exceeded
Example:
ResponseEntity<Map<String, Object>> response = userAuthFlowService.verifyOtp(
    presExId,
    "123456",
    request
);
if (response.getStatusCode().is2xxSuccessful()) {
    System.out.println("Login successful!");
}
Resends email OTP or activates email fallback from TOTP.Parameters:
  • presExId - Exchange ID
  • request - HTTP request
Returns: ResponseEntity<Map<String, Object>> with:
  • ok - Boolean
  • otpMethod - “email”
  • maskedEmail - Email address
  • message - Confirmation message
Behavior:
  • If TOTP: Switches to email OTP fallback
  • If email: Resends code with cooldown validation
Example:
ResponseEntity<Map<String, Object>> response = userAuthFlowService.resendOtp(
    presExId,
    request
);

UserAccessGovernanceService Methods

Updates user access state and syncs to Indy.Flow:
  1. Validates user is end-user role
  2. Updates access_state, access_state_reason, access_state_updated_at
  3. Sets is_active based on state
  4. Syncs state to ACA-Py connection metadata (if enabled)
  5. Records audit event on Fabric
  6. Saves sync result to indy_access_synced, indy_access_sync_at, indy_access_sync_error
Parameters:
  • personId - Person ID (user primary key)
  • targetState - UserAccessState.ENABLED, SUSPENDED, or DISABLED
  • reason - Reason for state change
Returns: AccessUpdateResult with:
  • access - UserAccessView with updated state
  • indyCallAttempted - Boolean
  • indyCallSucceeded - Boolean
  • indyMessage - Result message
Throws: IllegalArgumentException if user not found or not end-userExample:
AccessUpdateResult result = userAccessGovernanceService.updateState(
    personId,
    UserAccessState.SUSPENDED,
    "Suspicious activity detected"
);
if (result.indyCallSucceeded()) {
    System.out.println("State synced to Indy: " + result.indyMessage());
} else {
    System.err.println("Indy sync failed: " + result.indyMessage());
}
State changes apply immediately in the database. Indy sync failures do NOT rollback the state change.
Retrieves current access state for a user.Parameters:
  • personId - Person ID
Returns: Optional<UserAccessView> with:
  • personId
  • email
  • fullName
  • role
  • isActive
  • accessState - “ENABLED”, “SUSPENDED”, or “DISABLED”
  • reason - State change reason
  • updatedAt - Last update timestamp
  • indySynced - Last sync success (Boolean)
  • indySyncAt - Last sync timestamp
  • indySyncError - Last sync error message
Example:
Optional<UserAccessView> view = userAccessGovernanceService.findByPersonId(
    personId
);
view.ifPresent(v -> {
    System.out.println("State: " + v.accessState());
    System.out.println("Indy synced: " + v.indySynced());
});

Present-Proof Flow Diagram

Access State Synchronization

Metadata Path Format

When user-access-sync-path contains {connection_id}:
/connections/{connection_id}/metadata
Payload Structure:
{
  "metadata": {
    "ccdigital.user_access_state.cc.1234567890": {
      "personId": 123,
      "email": "[email protected]",
      "state": "SUSPENDED",
      "reason": "Account under review",
      "updatedAt": "2024-03-07T10:30:00",
      "idType": "CC",
      "idNumber": "1234567890"
    }
  }
}

State Enum Values

  • ENABLED: User can log in normally
  • SUSPENDED: Temporary access block (can be re-enabled)
  • DISABLED: Permanent access block (sets is_active=false)

Error Handling

Connection Resolution

Fixed Mode: Use configured holder-connection-id Auto Mode: Query /connections?state=active and match by their_label Error:
IllegalStateException: No se encontró conexión ACTIVE con 
their_label='CCDigital-Holder'. Define ccdigital.indy.holder-connection-id 
o revisa la conexión en ACA-Py.

Proof Verification Errors

ErrorCauseSolution
”El proof aún no está listo”Polling before holder respondsContinue polling
”Proof no verificado”Invalid credential or proofCheck credential validity
”La credencial verificada no coincide”ID mismatchEnsure user has correct credential

Best Practices

  1. Always validate session state before accepting proof results
  2. Use email masking when displaying contact information
  3. Implement rate limiting on OTP verification (max 3 attempts)
  4. Log all Indy sync failures for admin review
  5. Handle connection auto-discovery failures gracefully
  6. Store presExId in session, not in URL parameters
  7. Clear session state after successful login or max attempts
  • Blockchain Services - Audit logging (UserAccessGovernanceService:111)
  • UserLoginOtpService - Email OTP generation and verification
  • UserTotpService - TOTP validation for second factor
  • PersonService - Person data retrieval

See Also

Build docs developers (and LLMs) love