Initial import of ADIF API reverse-engineering toolkit

This commit is contained in:
2025-12-16 08:37:56 +01:00
commit 60388529c1
11486 changed files with 1086536 additions and 0 deletions

375
docs/API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,375 @@
# ADIF Elcano API - Complete Documentation
Complete documentation of the ADIF API (El Cano Movil) obtained through reverse engineering of the mobile application.
**All 9/9 endpoints functional** | HMAC-SHA256 authentication | 1587 station codes
---
## Base URLs
```
BASE_URL_STATIONS = https://estaciones.api.adif.es
BASE_URL_CIRCULATION = https://circulacion.api.adif.es
BASE_URL_ELCANOWEB = https://elcanoweb.adif.es/api/
BASE_URL_AVISA = https://avisa.adif.es/avisa-ws/api/
```
## Authentication Keys
### HMAC Keys (extracted from libapi-keys.so)
```python
ACCESS_KEY = "and20210615"
SECRET_KEY = "Jthjtr946RTt"
```
### User-keys (different for each service)
```
Circulations: f4ce9fbfa9d721e39b8984805901b5df
Stations: 0d021447a2fd2ac64553674d5a0c1a6f
```
### Other Auth Tokens
```
Avisa Login: Basic YXZpc3RhX2NsaWVudF9hbmRyb2lkOjh5WzZKNyFmSjwhXypmYXE1NyNnOSohNElwa2MjWC1BTg==
Subscriptions: Basic ZGVpbW9zOmRlaW1vc3R0
Registration Token: Bearer b9034774-c6e4-4663-a1a8-74bf7102651b
```
---
## Endpoints (9/9 Working)
### Station Endpoints
#### Get All Stations
```
GET /portroyalmanager/secure/stations/allstations/reducedinfo/{token}/
Base: https://estaciones.api.adif.es
User-key: stations
Token: Initial value is "0"
```
#### Get Station Details
```
POST /portroyalmanager/secure/stations/onestation/
Base: https://estaciones.api.adif.es
User-key: stations
Body:
{
"stationCode": "10200",
"token": "0",
"detailedInfo": {
"extendedStationInfo": true,
"stationActivities": true,
"stationBanner": true,
"stationCommercialServices": true,
"stationInfo": true,
"stationServices": true,
"stationTransportServices": true
}
}
```
#### Station Observations
```
POST /portroyalmanager/secure/stationsobservations/
Base: https://estaciones.api.adif.es
User-key: stations
Body:
{
"stationCodes": ["60000", "71801"]
}
```
---
### Circulation Endpoints
#### Departures
```
POST /portroyalmanager/secure/circulationpaths/departures/traffictype/
Base: https://circulacion.api.adif.es
User-key: circulations
Body:
{
"commercialService": "BOTH",
"commercialStopType": "BOTH",
"page": {"pageNumber": 0},
"stationCode": "10200",
"trafficType": "ALL"
}
```
#### Arrivals
```
POST /portroyalmanager/secure/circulationpaths/arrivals/traffictype/
Base: https://circulacion.api.adif.es
User-key: circulations
Body: Same format as departures
```
#### Between Stations
```
POST /portroyalmanager/secure/circulationpaths/betweenstations/traffictype/
Base: https://circulacion.api.adif.es
User-key: circulations
Body:
{
"commercialService": "BOTH",
"commercialStopType": "BOTH",
"originStationCode": "10200",
"destinationStationCode": "71801",
"page": {"pageNumber": 0},
"trafficType": "ALL"
}
IMPORTANT: Send only origin/destination; do NOT add `stationCode` or explicit `null` fields.
```
#### One Train Route (OnePaths)
```
POST /portroyalmanager/secure/circulationpathdetails/onepaths/
Base: https://circulacion.api.adif.es
User-key: circulations
Body:
{
"allControlPoints": true,
"commercialNumber": "04138",
"destinationStationCode": "60000",
"launchingDate": 1733356800000,
"originStationCode": "71801"
}
Notes:
- `commercialNumber` must be real (grab from departures/arrivals)
- `launchingDate` must be milliseconds
- Omit unused fields instead of sending `null`
```
#### Multiple Routes (SeveralPaths)
```
POST /portroyalmanager/secure/circulationpathdetails/severalpaths/
Base: https://circulacion.api.adif.es
User-key: circulations
Body: Same format as onepaths (omit unused fields)
Returns 204 when there is no data for the requested trains.
```
#### Train Composition
```
POST /portroyalmanager/secure/circulationpaths/compositions/path/
Base: https://circulacion.api.adif.es
User-key: circulations
Body:
{
"commercialNumber": "03194",
"destinationStationCode": "71801",
"launchingDate": 1764889200000,
"originStationCode": "10200"
}
IMPORTANT: Do NOT include `allControlPoints: true` (requires elevated permissions, returns 401)
```
---
## Response Structures
### Departures/Arrivals Response
```json
{
"commercialPaths": [
{
"commercialPathInfo": {
"timestamp": 1764927847100,
"commercialPathKey": {
"commercialCirculationKey": {
"commercialNumber": "90399",
"launchingDate": 1764889200000
},
"originStationCode": "10620",
"destinationStationCode": "60004"
},
"trafficType": "CERCANIAS",
"opeProComPro": {
"operator": "RF",
"product": "C",
"commercialProduct": " "
}
},
"passthroughStep": {
"stopType": "NO_STOP",
"stationCode": "10200",
"departurePassthroughStepSides": {
"plannedTime": 1764927902000,
"forecastedOrAuditedDelay": 2175,
"timeType": "FORECASTED",
"plannedPlatform": "2",
"circulationState": "RUNNING"
}
}
}
]
}
```
**Important fields**:
- `commercialNumber`: Train commercial number
- `launchingDate`: Date in milliseconds
- `plannedTime`: Planned time in milliseconds
- `forecastedOrAuditedDelay`: Delay in seconds
- `circulationState`: RUNNING, PENDING_TO_CIRCULATE, etc.
### OnePaths Response (Complete Route)
```json
{
"commercialPaths": [
{
"commercialPathInfo": { /* Same as departures */ },
"passthroughSteps": [ // Array with ALL stops
{
"stopType": "COMMERCIAL",
"stationCode": "10620",
"departurePassthroughStepSides": {
"plannedTime": 1764918000000,
"forecastedOrAuditedDelay": 430,
"plannedPlatform": "1",
"circulationState": "RUNNING"
}
}
// ... more stops
]
}
]
}
```
**Key difference**:
- `departures/arrivals` -> `passthroughStep` (singular)
- `onepaths` -> `passthroughSteps` (plural, array)
---
## Status Codes
| Code | Meaning | Cause |
|------|---------|-------|
| 200 | Success | Request successful with data |
| 204 | No Content | Authentication OK, no data available |
| 400 | Bad Request | Incorrect payload |
| 401 | Unauthorized | No permissions or wrong auth |
**Note**: 204 is NOT an error. It means authentication and payload are correct.
---
## Data Types
### TrafficType
- `CERCANIAS` - Commuter trains
- `AVLDMD` - High Speed, Long and Medium Distance
- `OTHERS` - Other
- `TRAVELERS` - Passengers
- `GOODS` - Freight
- `ALL` - All types
### State (commercialService, commercialStopType)
- `YES`
- `NOT`
- `BOTH`
---
## Common Issues and Solutions
### Issue 1: NULL Fields Cause Errors
**Problem**: Including `null` fields in JSON causes 401 errors.
```json
// WRONG - causes 401
{"stationCode": null, "trafficType": "ALL"}
// CORRECT - omit the field entirely
{"trafficType": "ALL"}
```
### Issue 2: Header Order for HMAC
The canonical headers order is NOT alphabetical:
```
1. content-type
2. x-elcano-host <- host before client!
3. x-elcano-client
4. x-elcano-date
5. x-elcano-userid
```
### Issue 3: Timestamps
`launchingDate` must be in milliseconds:
```python
# Correct
timestamp = int(datetime.now().timestamp() * 1000)
# Wrong (missing 3 zeros)
timestamp = int(datetime.now().timestamp())
```
### Issue 4: allControlPoints Requires Permissions
`/compositions/path/` with `allControlPoints: true` returns 401. Don't include it.
---
## Station Codes
**Total**: 1587 stations
**File**: `station_codes.txt`
**Source**: `apk_extracted/assets/stations_all.json`
### Top Stations
```
10200 Madrid Puerta de Atocha AVLDMD
10302 Madrid Chamartin-Clara Campoamor AVLDMD
71801 Barcelona Sants AVLDMD,CERCANIAS
60000 Valencia Nord AVLDMD
11401 Sevilla Santa Justa AVLDMD
50003 Alicante Terminal AVLDMD,CERCANIAS
54007 Cordoba Central AVLDMD
79600 Zaragoza Portillo AVLDMD,CERCANIAS
```
---
## Implementation Notes
### Tools Used
- **Ghidra**: Key extraction from `libapi-keys.so`
- **JADX**: APK decompilation
- **Python 3**: Client implementation
### Key Source Files
- `apk_extracted/lib/x86_64/libapi-keys.so` - Authentication keys
- `apk_extracted/assets/stations_all.json` - Station database
- `apk_decompiled/sources/com/adif/elcanomovil/serviceNetworking/interceptors/auth/ElcanoAuth.java` - HMAC algorithm
### Main Analyzed Classes
- `TrafficCirculationPathRequest` - Request model
- `OneOrSeveralPathsRequest` - Path requests
- `ElcanoAuth` - HMAC-SHA256 implementation
- `ServicePaths` - URL and key definitions

479
docs/API_REQUEST_BODIES.md Normal file
View File

@@ -0,0 +1,479 @@
# Request Body Analysis - ADIF API
> Reverse engineering of package `com.adif.elcanomovil.serviceNetworking`
> Date: 2025-12-04
## Table of Contents
- [1. Authentication Headers](#1-authentication-headers)
- [2. Request Bodies](#2-request-bodies)
- [3. Endpoints and Base URLs](#3-endpoints-and-base-urls)
- [4. Network Configuration](#4-network-configuration)
- [5. Authentication System](#5-authentication-system)
- [6. Code References](#6-code-references)
---
## 1. Authentication Headers
### 1.1 Static Headers
**File:** `ServicePaths.java:67-76`
#### Circulations
```
User-key: f4ce9fbfa9d721e39b8984805901b5df
Content-Type: application/json;charset=utf-8
```
#### Stations
```
User-key: 0d021447a2fd2ac64553674d5a0c1a6f
Content-Type: application/json;charset=utf-8
```
#### AVISA (Login/Refresh)
```
Authorization: Basic YXZpc3RhX2NsaWVudF9hbmRyb2lkOjh5WzZKNyFmSjwhXypmYXE1NyNnOSohNElwa2MjWC1BTg==
```
**Decoded (Base64):**
```
avista_client_android:8y[6J7!fJ<_*faq57#g9*!4Ipkc#X-AN
```
### 1.2 Dynamic Headers (Generated by ElcanoAuth)
**File:** `serviceNetworking/interceptors/auth/ElcanoAuth.java`
The interceptor adds the X-Elcano headers produced by the HMAC calculation:
```
X-Elcano-Host: <host>
X-Elcano-Client: AndroidElcanoApp
X-Elcano-Date: <timestamp>
X-Elcano-UserId: <uuid>
Authorization: HMAC-SHA256 Credential=... SignedHeaders=... Signature=...
```
**Generation algorithm (summary):**
- Canonical headers (order matters): `content-type`, `x-elcano-host`, `x-elcano-client`, `x-elcano-date`, `x-elcano-userid`
- Canonical request: method + path + query + canonical headers + SHA256(payload)
- String to sign: `HMAC-SHA256\n<timestamp>\n<date>/<client>/<userId>/elcano_request\n<hash>`
- Signature key: HMAC(secretKey, date) → HMAC(result, client) → HMAC(result, "elcano_request")
---
## 2. Request Bodies
### 2.1 Circulations - Departures/Arrivals/Between Stations
**Endpoints:**
- `/portroyalmanager/secure/circulationpaths/departures/traffictype/`
- `/portroyalmanager/secure/circulationpaths/arrivals/traffictype/`
- `/portroyalmanager/secure/circulationpaths/betweenstations/traffictype/`
**Model:** `TrafficCirculationPathRequest`
**File:** `circulations/model/request/TrafficCirculationPathRequest.java:10-212`
```json
{
"commercialService": "YES|NOT|BOTH",
"commercialStopType": "YES|NOT|BOTH",
"destinationStationCode": "string or null",
"originStationCode": "string or null",
"page": {
"pageNumber": 0
},
"stationCode": "string or null",
"trafficType": "CERCANIAS|AVLDMD|OTHERS|TRAVELERS|GOODS|ALL"
}
```
#### Real Example
```json
{
"commercialService": "BOTH",
"commercialStopType": "BOTH",
"page": {
"pageNumber": 0
},
"stationCode": "60000",
"trafficType": "ALL"
}
```
#### Allowed Values
**commercialService / commercialStopType** (`CirculationPathRequest.java:65-67`):
- `YES` - Only commercial services/stops
- `NOT` - Without commercial services/stops
- `BOTH` - All types
**trafficType** (`TrafficType.java:16-21`):
- `CERCANIAS` - Commuter trains
- `AVLDMD` - High speed long/medium distance
- `OTHERS` - Other types
- `TRAVELERS` - Passengers
- `GOODS` - Freight
- `ALL` - All types
---
### 2.2 Circulations - Specific Routes
**Endpoints:**
- `/portroyalmanager/secure/circulationpathdetails/onepaths/`
- `/portroyalmanager/secure/circulationpathdetails/severalpaths/`
**Model:** `OneOrSeveralPathsRequest`
**File:** `circulations/model/request/OneOrSeveralPathsRequest.java:11-140`
```json
{
"allControlPoints": true/false/null,
"commercialNumber": "string or null",
"destinationStationCode": "string or null",
"launchingDate": 1733356800000,
"originStationCode": "string or null"
}
```
#### Real Example
```json
{
"allControlPoints": true,
"commercialNumber": "04138",
"destinationStationCode": "60000",
"launchingDate": 1733356800000,
"originStationCode": "71801"
}
```
**Important notes:**
- `launchingDate` is a **millisecond** timestamp (Long in Java)
- `allControlPoints` indicates whether to include every control point in the route
- Optional fields should be omitted rather than set to `null`
---
### 2.3 Train Compositions
**Endpoint:** `/portroyalmanager/secure/circulationpaths/compositions/path/`
**Model:** `OneOrSeveralPathsRequest` (same as routes)
**File:** `compositions/CompositionsService.java:14-18`
```json
{
"allControlPoints": true/false/null,
"commercialNumber": "string or null",
"destinationStationCode": "string or null",
"launchingDate": 1733356800000,
"originStationCode": "string or null"
}
```
---
### 2.4 Stations - Single Station Details
**Endpoint:** `/portroyalmanager/secure/stations/onestation/`
**Model:** `OneStationRequest`
**File:** `stations/model/OneStationRequest.java:9-93`
```json
{
"detailedInfo": {
"extendedStationInfo": true,
"stationActivities": true,
"stationBanner": true,
"stationCommercialServices": true,
"stationInfo": true,
"stationServices": true,
"stationTransportServices": true
},
"stationCode": "60000",
"token": "string"
}
```
**Important notes:**
- The `detailedInfo` object controls what is returned
- All boolean fields default to `true` (see `DetailedInfoDTO.java:149`)
- `token` is required
#### DetailedInfo fields
**File:** `stations/model/DetailedInfoDTO.java:10-151`
| Field | Type | Description |
|-------|------|-------------|
| `extendedStationInfo` | boolean | Extended station information |
| `stationActivities` | boolean | Station activities |
| `stationBanner` | boolean | Banner/announcements |
| `stationCommercialServices` | boolean | Commercial services |
| `stationInfo` | boolean | Basic station information |
| `stationServices` | boolean | Available services |
| `stationTransportServices` | boolean | Transport services |
---
### 2.5 Station Observations
**Endpoint:** `/portroyalmanager/secure/stationsobservations/`
**Model:** `StationObservationsRequest`
**File:** `stationObservations/model/StationObservationsRequest.java:10-53`
```json
{
"stationCodes": ["60000", "71801"]
}
```
#### Real Example
```json
{
"stationCodes": ["60000", "71801", "79600"]
}
```
**Notes:**
- Array of station codes (strings)
- Required field
- Can contain multiple codes
---
## 3. Endpoints and Base URLs
### 3.1 Base URLs
**File:** `di/NetworkModule.java:73-159`
| Service | Base URL | Authentication |
|---------|----------|----------------|
| **Circulations** | `https://circulacion.api.adif.es` | Secured (with AuthHeaderInterceptor) |
| **Stations** | `https://estaciones.api.adif.es` | Secured (with AuthHeaderInterceptor) |
| **AVISA** | `https://avisa.adif.es` | Basic (without AuthHeaderInterceptor) |
| **Elcano Web** | `https://elcanoweb.adif.es/api/` | - |
### 3.2 Full Paths - Stations
**File:** `ServicePaths.java:106-112`
```
GET /portroyalmanager/secure/stations/allstations/reducedinfo/{token}/
POST /portroyalmanager/secure/stations/onestation/
POST /portroyalmanager/secure/stationsobservations/
```
### 3.3 Full Paths - Circulations
**File:** `ServicePaths.java:41-51`
```
POST /portroyalmanager/secure/circulationpaths/departures/traffictype/
POST /portroyalmanager/secure/circulationpaths/arrivals/traffictype/
POST /portroyalmanager/secure/circulationpaths/betweenstations/traffictype/
POST /portroyalmanager/secure/circulationpathdetails/onepaths/
POST /portroyalmanager/secure/circulationpathdetails/severalpaths/
```
### 3.4 Full Paths - Compositions
**File:** `ServicePaths.java:55-61`
```
POST /portroyalmanager/secure/circulationpaths/compositions/path/
```
### 3.5 Full Paths - AVISA
**Files:** `ServicePaths.java:82-92` and `ServicePaths.java:29-37`
```
POST /avisa-ws/api/token (login)
POST /avisa-ws/api/token (refresh)
POST /avisa-ws/api/v1/client (register)
GET /avisa-ws/api/v1/station (stations)
GET /avisa-ws/api/v1/category (categories)
GET /avisa-ws/api/v1/incidence (incidences list)
GET /avisa-ws/api/v1/incidence/{id} (incidence details)
POST /avisa-ws/api/v1/incidence (create incidence)
```
---
## 4. Network Configuration
### 4.1 OkHttpClient Configuration
**File:** `di/NetworkModule.java:100-132`
#### Basic client
```kotlin
OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
```
#### Secured client (with authentication)
```kotlin
OkHttpClient.Builder()
.addInterceptor(AuthHeaderInterceptor(userId))
.certificatePinner(certificatePinner)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
```
**Timeouts:**
- Connect timeout: 60 seconds
- Read timeout: 60 seconds
### 4.2 Services using the secured client
**File:** `di/NetworkModule.java`
- `CirculationService` (line 73)
- `StationsService` (line 142)
- `StationObservationsService` (line 135)
- `CompositionsService` (line 156)
### 4.3 Services using the basic client
- `AvisaLoginService` (line 50)
- `AvisaStationsService` (line 57)
- `IncidenceService` (line 80)
- `SubscriptionsService` (line 149)
---
## 5. Authentication System
### 5.1 AuthHeaderInterceptor
**File:** `interceptors/AuthHeaderInterceptor.java:27-84`
This interceptor runs on **every** request for secured services.
#### Authentication process
1. **Generate persistent User ID**
- Uses `GeneratePersistentUserIdUseCase`
- The ID is stored and reused between sessions
2. **Build the token**
```java
ElcanoClientAuth.Builder()
.host(request.url().host())
.contentType("application/json;charset=utf-8")
.path(request.url().encodedPath())
.params(request.url().encodedQuery())
.xElcanoClient("AndroidElcanoApp")
.xElcanoUserId(userId)
.httpMethodName(request.method())
.payload(bodyJsonWithoutSpaces)
.build()
```
3. **Generate headers**
- The `ElcanoClientAuth` object generates authentication headers
- They are added automatically to the request
#### Generated headers
```
X-CanalMovil-Authentication: <calculated_token>
X-CanalMovil-deviceID: <device_id>
X-CanalMovil-pushID: <push_id>
```
### 5.2 GetKeysHelper class
**File:** `AuthHeaderInterceptor.java:44`
Provides keys for authentication:
- `getKeysHelper.a()` - First key
- `getKeysHelper.b()` - Second key
These keys are used by the signing/authentication algorithm.
### 5.3 Certificate Pinning
**File:** `di/NetworkModule.java:64-70`
The app uses **Certificate Pinning** to prevent MITM attacks:
- Expected SSL certificates are in `PinningRepository`
- Loaded asynchronously at startup
- All requests validate the server certificate
---
## 6. Code References
### 6.1 Key Files
| File | Location | Description |
|------|----------|-------------|
| `ServicePaths.java` | `serviceNetworking/` | Paths and static headers |
| `AuthHeaderInterceptor.java` | `serviceNetworking/interceptors/` | Auth header generation |
| `NetworkModule.java` | `serviceNetworking/di/` | Retrofit/OkHttp configuration |
| `CirculationService.java` | `serviceNetworking/circulations/` | Circulation API |
| `StationsService.java` | `serviceNetworking/stations/` | Stations API |
| `StationObservationsService.java` | `serviceNetworking/stationObservations/` | Observations API |
| `CompositionsService.java` | `serviceNetworking/compositions/` | Compositions API |
### 6.2 Request Models
| Model | File | Usage |
|-------|------|-------|
| `TrafficCirculationPathRequest` | `circulations/model/request/` | Departures, Arrivals, BetweenStations |
| `OneOrSeveralPathsRequest` | `circulations/model/request/` | OnePaths, SeveralPaths, Compositions |
| `OneStationRequest` | `stations/model/` | Station details |
| `DetailedInfoDTO` | `stations/model/` | Detailed info configuration |
| `StationObservationsRequest` | `stationObservations/model/` | Station observations |
### 6.3 Important Code Lines
- Static headers: `ServicePaths.java:67-76`
- Circulations user-key: `ServicePaths.java:67`
- Stations user-key: `ServicePaths.java:68`
- AVISA login token: `ServicePaths.java:70`
- Auth interceptor: `AuthHeaderInterceptor.java:38-83`
- Circulations base URL: `NetworkModule.java:76`
- Stations base URL: `NetworkModule.java:145`
- Enum TrafficType: `TrafficType.java:16-21`
- Enum State: `CirculationPathRequest.java:65-67`
---
## 7. Additional Notes
### 7.1 JSON Serialization
- **Library used:** Moshi (configured in `NetworkModule.java:87-96`)
- **Format:** JSON field names match Java property names exactly
- **Null handling:** Optional fields are **omitted** by the client; sending explicit `null` values leads to 401 responses
- **Date format:** Millisecond timestamps (Long)
### 7.2 Security Considerations
1. **Hardcoded user-keys:** API keys are in the code (easy to extract)
2. **Certificate Pinning:** Makes proxy interception harder
3. **Dynamic authentication:** X-Elcano headers require the HMAC algorithm to be reproduced
4. **AVISA token:** Base64 credentials in code (decodable)
### 7.3 Testing
Use the Python client (`adif_client.py`) or the signed test scripts in `/tests` to validate each endpoint. All endpoints are currently reproducible with the shipped keys and HMAC implementation.
---
**Last update:** 2025-12-05
**Source:** Decompiled ADIF El Cano Movil APK
**Tools:** JADX, Ghidra, manual Java code analysis

View File

@@ -0,0 +1,492 @@
# ADIF Authentication Algorithm - Complete Reverse Engineering
> **Status:** ✅ Algorithm fully decoded and keys extracted from `libapi-keys.so`
## Executive Summary
The ADIF authentication system is similar to **AWS Signature Version 4**:
- Uses **HMAC-SHA256** to sign requests
- Requires two secret keys: `accessKey` and `secretKey`
- Keys live inside the native library `libapi-keys.so` (obfuscated)
- Generates dynamic headers for every request
---
## Algorithm Source File
**Location:** `com/adif/elcanomovil/serviceNetworking/interceptors/auth/ElcanoAuth.java`
**Key lines:**
- 47-53: Authorization header calculation
- 129-172: Canonical Request preparation
- 174-183: String to Sign preparation
- 78-84: Signature calculation
- 109-111: Signature key derivation
---
## Algorithm Walkthrough
### 1. Input Parameters
```java
// From ElcanoClientAuth.Builder
String elcanoAccessKey; // Access key (from libapi-keys.so)
String elcanoSecretKey; // Secret key (from libapi-keys.so)
String host; // e.g. "circulacion.api.adif.es"
String path; // e.g. "/portroyalmanager/secure/circulationpaths/departures/traffictype/"
String params; // Query string (can be "")
String httpMethodName; // "GET" or "POST"
String payload; // JSON body (no spaces or line breaks)
String contentType; // "application/json;charset=utf-8"
String xElcanoClient; // "AndroidElcanoApp"
String xElcanoUserId; // Persistent user UUID
Date requestDate; // Current date/time
```
### 2. Date Formats
```java
// ElcanoAuth.java:195-199
public static String getTimeStamp(Date date) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
return simpleDateFormat.format(date);
}
// Example: "20251204T204637Z"
// ElcanoAuth.java:55-59
public static String getDate(Date date) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
return simpleDateFormat.format(date);
}
// Example: "20251204"
```
### 3. Prepare the Payload
```java
// ElcanoAuth.java:86-91
public String formatPayload(String str) {
if (str == null) {
str = "";
}
return str.replace("\r", "").replace("\n", "").replace(" ", "");
}
```
**Example:**
```
Input: {"page": {"pageNumber": 0}}
Output: {"page":{"pageNumber":0}}
```
### 4. Canonical Request
**File:** `ElcanoAuth.java:129-172`
**Structure:**
```
<HTTPMethod>\n
<Path>\n
<QueryString>\n
content-type:<ContentType>\n
x-elcano-host:<Host>\n
x-elcano-client:<Client>\n
x-elcano-date:<Timestamp>\n
x-elcano-userid:<UserId>\n
content-type;x-elcano-host;x-elcano-client;x-elcano-date;x-elcano-userid\n
<SHA256HashOfPayload>
```
**Real example:**
```
POST
/portroyalmanager/secure/circulationpaths/departures/traffictype/
content-type:application/json;charset=utf-8
x-elcano-host:circulacion.api.adif.es
x-elcano-client:AndroidElcanoApp
x-elcano-date:20251204T204637Z
x-elcano-userid:a1b2c3d4-e5f6-7890-abcd-ef1234567890
content-type;x-elcano-host;x-elcano-client;x-elcano-date;x-elcano-userid
<sha256_hash_of_payload_hex>
```
**Important note:** Header order is specific (not fully alphabetical) — `content-type`, `x-elcano-host`, `x-elcano-client`, `x-elcano-date`, `x-elcano-userid`.
### 5. String to Sign
**File:** `ElcanoAuth.java:174-183`
**Structure:**
```
HMAC-SHA256\n
<Timestamp>\n
<DateSimple>/<Client>/<UserId>/elcano_request\n
<SHA256HashOfCanonicalRequest>
```
**Example:**
```
HMAC-SHA256
20251204T204637Z
20251204/AndroidElcanoApp/a1b2c3d4-e5f6-7890-abcd-ef1234567890/elcano_request
<sha256_hash_of_canonical_request_hex>
```
### 6. Signature Key
**File:** `ElcanoAuth.java:109-111`
```java
public byte[] getSignatureKey(String secretKey, String date, String client) {
return hmacSha256(
hmacSha256(
hmacSha256(secretKey.getBytes(StandardCharsets.UTF_8), date),
client
),
"elcano_request"
);
}
```
**Pseudocode:**
```python
kDate = HMAC_SHA256(secretKey, date) # "20251204"
kClient = HMAC_SHA256(kDate, client) # "AndroidElcanoApp"
kSigning = HMAC_SHA256(kClient, "elcano_request")
```
### 7. Signature (Final HMAC)
**File:** `ElcanoAuth.java:78-84`
```java
public String calculateSignature(String stringToSign) {
return bytesToHex(
hmacSha256(
getSignatureKey(secretKey, dateSimple, client),
stringToSign
)
);
}
```
**Pseudocode:**
```python
signatureKey = getSignatureKey(secretKey, "20251204", "AndroidElcanoApp")
signature = HMAC_SHA256(signatureKey, string_to_sign)
signatureHex = signature.hex()
```
### 8. Authorization Header
**File:** `ElcanoAuth.java:61-63`
**Format:**
```
HMAC-SHA256 Credential=<accessKey>/<date>/<client>/<userId>/elcano_request,SignedHeaders=<signedHeaders>,Signature=<signature>
```
**Example:**
```
HMAC-SHA256 Credential=ACCESS_KEY_HERE/20251204/AndroidElcanoApp/a1b2c3d4-e5f6-7890-abcd-ef1234567890/elcano_request,SignedHeaders=content-type;x-elcano-host;x-elcano-client;x-elcano-date;x-elcano-userid,Signature=a1b2c3d4e5f6789...
```
### 9. Final Request Headers
**File:** `ElcanoAuth.java:97-107`
```http
Content-Type: application/json;charset=utf-8
X-Elcano-Host: circulacion.api.adif.es
X-Elcano-Client: AndroidElcanoApp
X-Elcano-Date: 20251204T204637Z
X-Elcano-UserId: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Authorization: HMAC-SHA256 Credential=...
```
**Note:** These replace the `X-CanalMovil-*` headers we originally expected.
---
## Helper Functions
### HMAC-SHA256
**File:** `ElcanoAuth.java:117-127`
```java
public byte[] hmacSha256(byte[] key, String data) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
}
```
### SHA-256 Hash (Hex)
**File:** `ElcanoAuth.java:185-193`
```java
public String toHex(String str) {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update(str.getBytes(StandardCharsets.UTF_8));
return String.format("%064x", new BigInteger(1, messageDigest.digest()));
}
```
### Bytes to Hex
**File:** `ElcanoAuth.java:65-76`
```java
public String bytesToHex(byte[] bytes) {
char[] hexArray = "0123456789ABCDEF".toCharArray();
char[] hexChars = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
hexChars[i * 2] = hexArray[v >>> 4];
hexChars[i * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars).toLowerCase();
}
```
---
## Secret Keys
### Location
**File:** `com/adif/commonKeys/GetKeysHelper.java`
```java
public final class GetKeysHelper {
static {
System.loadLibrary("api-keys"); // Loads libapi-keys.so
}
private final native String getAccessKeyPro();
private final native String getSecretKeyPro();
public final String a() {
return getAccessKeyPro();
}
public final String b() {
return getSecretKeyPro();
}
}
```
**Native library:**
- `lib/x86_64/libapi-keys.so` (446 KB)
- `lib/arm64-v8a/libapi-keys.so` (503 KB)
- `lib/x86/libapi-keys.so` (416 KB)
- `lib/armeabi-v7a/libapi-keys.so` (366 KB)
**JNI functions:**
```cpp
Java_com_adif_commonKeys_GetKeysHelper_getAccessKeyPro
Java_com_adif_commonKeys_GetKeysHelper_getSecretKeyPro
```
### Extracting the Keys
**Option 1: Ghidra / IDA Pro**
```bash
# Open libapi-keys.so in Ghidra
# Find the JNI functions
# Inspect the assembly to locate the strings
```
**Option 2: Frida (runtime)**
```javascript
Java.perform(function() {
var GetKeysHelper = Java.use('com.adif.commonKeys.GetKeysHelper');
console.log('[+] Access Key: ' + GetKeysHelper.f4297a.a());
console.log('[+] Secret Key: ' + GetKeysHelper.f4297a.b());
});
```
**Option 3: strings + manual analysis**
```bash
strings libapi-keys.so | grep -E "^[A-Za-z0-9+/=]{32,}$"
```
---
## Python Implementation
```python
import hashlib
import hmac
from datetime import datetime
import json
class AdifAuthenticator:
def __init__(self, access_key, secret_key):
self.access_key = access_key
self.secret_key = secret_key
def get_timestamp(self, date=None):
if date is None:
date = datetime.utcnow()
return date.strftime('%Y%m%dT%H%M%SZ')
def get_date(self, date=None):
if date is None:
date = datetime.utcnow()
return date.strftime('%Y%m%d')
def format_payload(self, payload):
if payload is None:
return ""
if isinstance(payload, dict):
payload = json.dumps(payload, separators=(',', ':'))
return payload.replace('\r', '').replace('\n', '').replace(' ', '')
def sha256_hash(self, text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def hmac_sha256(self, key, data):
if isinstance(key, str):
key = key.encode('utf-8')
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
def get_signature_key(self, date_simple, client):
k_date = self.hmac_sha256(self.secret_key, date_simple)
k_client = self.hmac_sha256(k_date, client)
k_signing = self.hmac_sha256(k_client, "elcano_request")
return k_signing
def prepare_canonical_request(self, method, path, params, payload,
content_type, host, client, timestamp, user_id):
# Format payload
formatted_payload = self.format_payload(payload)
payload_hash = self.sha256_hash(formatted_payload)
# Canonical headers (specific order, lowercase)
canonical_headers = (
f"content-type:{content_type}\n"
f"x-elcano-host:{host}\n"
f"x-elcano-client:{client}\n"
f"x-elcano-date:{timestamp}\n"
f"x-elcano-userid:{user_id}\n"
)
signed_headers = "content-type;x-elcano-host;x-elcano-client;x-elcano-date;x-elcano-userid"
canonical_request = (
f"{method}\n"
f"{path}\n"
f"{params}\n"
f"{canonical_headers}"
f"{signed_headers}\n"
f"{payload_hash}"
)
return canonical_request, signed_headers
def prepare_string_to_sign(self, timestamp, date_simple, client, user_id, canonical_request):
canonical_hash = self.sha256_hash(canonical_request)
string_to_sign = (
f"HMAC-SHA256\n"
f"{timestamp}\n"
f"{date_simple}/{client}/{user_id}/elcano_request\n"
f"{canonical_hash}"
)
return string_to_sign
def calculate_signature(self, string_to_sign, date_simple, client):
signing_key = self.get_signature_key(date_simple, client)
signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
return signature
def build_authorization_header(self, signature, date_simple, client, user_id, signed_headers):
return (
f"HMAC-SHA256 "
f"Credential={self.access_key}/{date_simple}/{client}/{user_id}/elcano_request,"
f"SignedHeaders={signed_headers},"
f"Signature={signature}"
)
def get_auth_headers(self, method, url, payload=None, user_id=None):
# Parse URL
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.netloc
path = parsed.path
params = parsed.query or ""
# Defaults
if user_id is None:
import uuid
user_id = str(uuid.uuid4())
client = "AndroidElcanoApp"
content_type = "application/json;charset=utf-8"
# Timestamps
now = datetime.utcnow()
timestamp = self.get_timestamp(now)
date_simple = self.get_date(now)
# 1. Canonical Request
canonical_request, signed_headers = self.prepare_canonical_request(
method, path, params, payload, content_type, host, client, timestamp, user_id
)
# 2. String to Sign
string_to_sign = self.prepare_string_to_sign(
timestamp, date_simple, client, user_id, canonical_request
)
# 3. Signature
signature = self.calculate_signature(string_to_sign, date_simple, client)
# 4. Authorization Header
authorization = self.build_authorization_header(
signature, date_simple, client, user_id, signed_headers
)
# Return all headers
return {
"Content-Type": content_type,
"X-Elcano-Host": host,
"X-Elcano-Client": client,
"X-Elcano-Date": timestamp,
"X-Elcano-UserId": user_id,
"Authorization": authorization
}
# USAGE:
# auth = AdifAuthenticator(access_key="ACCESS_KEY_HERE", secret_key="SECRET_KEY_HERE")
# headers = auth.get_auth_headers("POST", "https://circulacion.api.adif.es/path", payload={...})
```
---
## Next Steps
The algorithm and keys are already implemented in `adif_auth.py`. Use the Python client or the scripts in `/tests` to validate live requests.
---
## References
- **ElcanoAuth.java:** `serviceNetworking/interceptors/auth/ElcanoAuth.java`
- **ElcanoClientAuth.java:** `serviceNetworking/interceptors/auth/ElcanoClientAuth.java`
- **GetKeysHelper.java:** `commonKeys/GetKeysHelper.java`
- **libapi-keys.so:** `lib/*/libapi-keys.so`
---
**Last update:** 2025-12-05
**Status:** Algorithm and keys complete ✅

404
docs/ENDPOINTS_ANALYSIS.md Normal file
View File

@@ -0,0 +1,404 @@
# Endpoint Analysis - Final Status
**Last update**: 2025-12-05
**Project status**: ✅ Successfully completed
## 📊 Final State - 4/8 Functional Endpoints (50%)
| Endpoint | Status | Diagnosis | Solution |
|----------|--------|-----------|----------|
| `/departures/` | ✅ 200 | **WORKS** | - |
| `/arrivals/` | ✅ 200 | **WORKS** | - |
| `/stationsobservations/` | ✅ 200 | **WORKS** | - |
| `/onepaths/` | ✅ 200/204 | **WORKS** with real commercialNumber | Use data from departures/arrivals |
| `/betweenstations/` | ❌ 401 | No permissions | Keys have limited profile |
| `/onestation/` | ❌ 401 | No permissions | Keys have limited profile |
| `/severalpaths/` | ❌ 401 | No permissions | Keys have limited profile |
| `/compositions/path/` | ❌ 401 | No permissions | Keys have limited profile |
**Functional total**: 4/8 (50%)
**Validated but blocked**: 4/8 (50%)
---
## 🔍 Detailed Analysis
### ✅ Endpoints that WORK
#### 1. Departures & Arrivals
**Model**: `TrafficCirculationPathRequest`
```json
{
"commercialService": "BOTH",
"commercialStopType": "BOTH",
"page": {"pageNumber": 0},
"stationCode": "10200", // ← Only stationCode
"trafficType": "ALL"
}
```
**Fields used** (TrafficCirculationPathRequest.java):
- `commercialService` (line 11, 24)
- `commercialStopType` (line 12, 25)
- `stationCode` (line 16, 29) ← **Main field**
- `page` (line 15, 28)
- `trafficType` (line 17, 30)
**Why it works**
- HMAC authentication is correct
- Payload matches the model
- Keys have enough permissions
#### 2. StationObservations
**Model**: `StationObservationsRequest`
```json
{
"stationCodes": ["10200", "71801"]
}
```
**Why it works**
- Simple model (only an array)
- HMAC authentication is correct
- Valid stations user-key
---
### ❌ Endpoints that FAIL with 401 (Unauthorized)
#### 1. BetweenStations
**Status**: 401 Unauthorized
**Model**: `TrafficCirculationPathRequest` (same as departures)
**Payload sent**:
```json
{
"commercialService": "BOTH",
"commercialStopType": "BOTH",
"originStationCode": "10200", // ← Both codes present
"destinationStationCode": "71801", // ← Both codes present
"page": {"pageNumber": 0},
"trafficType": "ALL"
}
```
**Model fields** (TrafficCirculationPathRequest.java):
- `destinationStationCode` (line 13, nullable)
- `originStationCode` (line 14, nullable)
- `stationCode` (line 16, nullable)
**Problem hypotheses**
1. **Insufficient permissions**: Keys `and20210615`/`Jthjtr946RTt` may belong to a profile WITHOUT permission to query routes between stations.
2. **Extra server validation**: The endpoint may require:
- Authenticated user with active session
- Specific account permissions
- Different keys (pro vs non-pro)
**Evidence**
```java
// CirculationService.java:24-25
@Headers({ServicePaths.Headers.contentType, ServicePaths.Headers.apiManagerUserKeyCirculations})
@POST(ServicePaths.CirculationService.betweenStations)
Object betweenStations(@Body TrafficCirculationPathRequest trafficCirculationPathRequest, ...);
```
**Conclusion**
- ❌ Not a payload issue (same model as departures)
- ❌ Not an HMAC issue (signature is correct)
-**Permissions issue** - Extracted keys are not authorized for this endpoint
#### 2. OneStation
**Status**: 401 Unauthorized
**Model**: `OneStationRequest` with `DetailedInfoDTO`
**Payload sent**:
```json
{
"stationCode": "10200",
"detailedInfo": {
"extendedStationInfo": true,
"stationActivities": true,
"stationBanner": true,
"stationCommercialServices": true,
"stationInfo": true,
"stationServices": true,
"stationTransportServices": true
}
}
```
**Conclusion**
- ✅ Payload is correct (per OneStationRequest.java)
- ✅ HMAC authentication is correct
-**Insufficient permissions** - Endpoint needs more privileges
---
### ✅ Endpoint that WORKS with Real Data - OnePaths
#### OnePaths
**Status**: ✅ 200 OK (with real commercialNumber) / 204 No Content (no data)
**Model**: `OneOrSeveralPathsRequest`
**KEY FINDING**: The endpoint works, but requires a valid `commercialNumber`.
**Correct payload**:
```json
{
"allControlPoints": true,
"commercialNumber": "90399", // ← MUST be real
"destinationStationCode": "60004",
"launchingDate": 1764889200000,
"originStationCode": "10620"
}
```
**Successful response (200)**:
```json
{
"commercialPaths": [
{
"commercialPathInfo": { /* ... */ },
"passthroughSteps": [ // ← Array with ALL stops
{
"stopType": "COMMERCIAL",
"stationCode": "10620",
"departurePassthroughStepSides": { /* ... */ }
},
{
"stopType": "NO_STOP",
"stationCode": "C1062",
"arrivalPassthroughStepSides": { /* ... */ },
"departurePassthroughStepSides": { /* ... */ }
}
// ... more stops
]
}
]
}
```
**How to obtain a valid commercialNumber**
1. Query `/departures/` or `/arrivals/`
2. Extract `commercialNumber` from a real train
3. Use that number in `/onepaths/`
**Flow example**:
```python
# 1. Get trains
trains = get_departures("10200", "ALL")
# 2. Extract data from the first train
train = trains[0]
info = train['commercialPathInfo']
key = info['commercialPathKey']
commercial_key = key['commercialCirculationKey']
# 3. Query full route
route = get_onepaths(
commercial_number=commercial_key['commercialNumber'],
launching_date=commercial_key['launchingDate'],
origin_station_code=key['originStationCode'],
destination_station_code=key['destinationStationCode']
)
```
**Difference vs departures/arrivals**
- `departures/arrivals`: Returns `passthroughStep` (singular, only the queried station)
- `onepaths`: Returns `passthroughSteps` (plural, array with every stop)
---
### ❌ Endpoints Blocked by Permissions (401)
---
## 🎯 Final Conclusions
### ✅ Functional Endpoints (4/8 = 50%)
**COMPLETE SUCCESS**: HMAC-SHA256 authentication works perfectly.
Working endpoints confirm:
1. ✅ Extracted keys (`and20210615`/`Jthjtr946RTt`) are valid
2. ✅ Signing algorithm is correctly implemented
3. ✅ Headers are in the right order
4. ✅ Payloads are correct
**Functional endpoints**:
1. `/departures/` - Station departures
2. `/arrivals/` - Station arrivals
3. `/onepaths/` - Full train route (with real commercialNumber)
4. `/stationsobservations/` - Station observations
### ⚠️ Issues Found
#### 1. Limited Permissions (401 Unauthorized)
**Affected**: BetweenStations, OneStation, SeveralPaths, Compositions (4 endpoints)
**CONFIRMED cause**: Extracted keys belong to a "anonymous/basic" profile with limited permissions.
**Evidence**
- ✅ HMAC auth correct (other endpoints work)
- ✅ Payloads validated against decompiled source
- ✅ Specific error: "Unauthorized" (not "Bad Request")
- ✅ Same signing logic succeeds elsewhere
**Conclusion**
- Keys are basic-profile and only allow simple queries
- They do NOT allow advanced queries (between stations, details, compositions)
- **CANNOT BE FIXED** without higher-privilege keys
#### 2. OnePaths Resolved ✅
**Previous state**: ❌ 400 Bad Request
**Current state**: ✅ 200 OK
**Solution**: Use a real `commercialNumber` obtained from `/departures/` or `/arrivals/`
**Takeaways**
- Status 204 (No Content) is NOT an error
- It means: authentication OK + payload valid + no data available
- Requires commercial numbers that actually exist
---
## 📝 Recommendations
### For Endpoints Returning 401
**CANNOT BE FIXED** without:
1. Extracting keys from an authenticated user (requires real credentials)
2. Using the mobile app with a registered account and capturing keys with Frida
**Alternative**
- Document that these endpoints exist but need additional permissions
- Focus efforts on the 3 endpoints that DO work
### For Endpoints Returning 400
**POSSIBLE TO TRY** by adjusting payloads:
1. **Capture real traffic from the app**:
```bash
# With mitmproxy + Frida SSL Bypass
frida -U -f com.adif.elcanomovil -l ssl-bypass.js
mitmproxy --mode transparent
# Use the app and capture real requests
```
2. **Analyze 400 responses**:
- Look for server hints about which field fails
- Compare with Java models
3. **Systematic variations**:
- Different dates
- With/without commercialNumber
- Different boolean flag combinations
---
## 🚀 Action Plan
### High Priority ✅
1. **Document current success**
- 3 endpoints working
- Authentication validated
- Implementation ready for production
### Medium Priority 🔶
1. **Tweak payloads for OnePaths/SeveralPaths/Compositions**
- Try different timestamps
- Capture real traffic if possible
### Low Priority ❌
1. **Attempt to obtain permissions for BetweenStations/OneStation**
- Requires real account + Frida
- Out of current scope
---
## 💡 Final Explanation
### Why do some endpoints work and others don't?
**Departures/Arrivals**: ✅
- Public info
- Basic permissions
- Similar to station screens
**BetweenStations**: ❌
- Route queries
- Might need trip-planning (premium feature)
- Extra permissions
**OneStation (details)**: ❌
- Detailed infrastructure info
- Potentially sensitive/private
- Specific permissions
**OnePaths/Compositions**: ❌
- Technical circulation info
- Likely for ADIF staff
- More complex payloads
---
## ✨ Main Achievement
**🎉 FULLY FUNCTIONAL HMAC-SHA256 AUTHENTICATION**
- ✅ Keys extracted correctly
- ✅ Algorithm 100% implemented
- ✅ 3 endpoints validated and working
- ✅ Infrastructure ready to expand
**The project is a COMPLETE SUCCESS** considering that:
1. Authentication is decoded
2. We have access to useful endpoints
3. Implementation is correct
Limitations are due to **server permissions**, not our implementation.
---
**Last update**: 2025-12-04
---
## 📈 Project Summary
### Completed Achievements ✅
1. **Key extraction** - Ghidra on `libapi-keys.so`
2. **HMAC-SHA256 algorithm** - Fully implemented and validated
3. **4 functional endpoints** - 50% of the API available
4. **1587 station codes** - Extracted from `assets/stations_all.json`
5. **Python client** - Complete API client ready to use
6. **Extensive documentation** - All discoveries recorded
### Final Metrics
| Metric | Value |
|--------|-------|
| Functional endpoints | 4/8 (50%) |
| Validated endpoints | 8/8 (100%) |
| Station codes | 1587 |
| Tests created | 4 |
| Documents | 7 |
| Python LOC | ~800 |
### Project Value
With this project you can:
- ✅ Query departures and arrivals for any station
- ✅ Obtain full train routes with every stop
- ✅ Monitor delays in real time
- ✅ View station observations
- ✅ Build train information applications
---
**Completion date**: 2025-12-05
**Status**: ✅ Project successfully completed