Что это за приложение?

ValedoMotion — ПК-компонент реабилитационной системы Valedo швейцарской компании Hocoma AG (сейчас входит в DIH Group). Предназначен для терапии пациентов с хроническими болями в спине.

Как работает:

  • На тело пациента крепятся 3 беспроводных BLE-датчика (грудь, поясница, бедро)
  • Датчики передают данные о движении в реальном времени через Bluetooth-донгл
  • ПО показывает движение пациента визуально, проводит через игровые упражнения
  • Врач получает объективные метрики: амплитуду движений (ROM), прогресс терапии, отчёты

Архитектура: .NET Framework 4.5 + WPF + Prism MVVM + DevExpress 14.2 + Entity Framework 6. Два процесса на ПК: ValedoMotion (GUI) и IMUSensorAPI.ServiceHost (работа с датчиками через BLE-донгл), общаются через WCF Named Pipes.

Дополнительные модули

Модуль Назначение
ValedoShape Тренировка с биологической обратной связью (отдельная установка, регистрируется в реестре)
ValedoMPI Обмен данными с Medical Practice Integration

Где хранится лицензионный ключ?

Путь к файлу

1
C:\ProgramData\Hocoma_AG\ValedoMotion\Configuration.xml

Как образуется путь (App.xaml.cs:19-27):

  1. Читается CompanyName и ProductName из метаданных сборки: Hocoma AGHocoma_AG, ValedoMotionValedoMotion
  2. Путь: Environment.SpecialFolder.CommonApplicationData + \Hocoma_AG\ValedoMotion

Структура файла

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<ValedoConfiguration>
  <HasMotion>true</HasMotion>
  <HasShape>false</HasShape>
  <License Key="GEspqbSBJMnc4WN">
    <LicensedSensorIds>
      <LicensedSensor>00:11:22:33:44:55</LicensedSensor>
      <LicensedSensor>AA:BB:CC:DD:EE:FF</LicensedSensor>
      <LicensedSensor>11:22:33:44:55:66</LicensedSensor>
    </LicensedSensorIds>
  </License>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>...</SignedInfo>
    <SignatureValue>...</SignatureValue>
    <KeyInfo>...</KeyInfo>
  </Signature>
</ValedoConfiguration>

Примечание: хотя [XmlRoot] объявляет namespace http://www.hocoma.com/valedomotion, сериализация через Serialize(true, true) вырезает все пространства имён (XmlSerializerNamespaces.Add("", "")). Файл на диске — без xmlns у ValedoConfiguration.

Как записывается в файл

  1. Пользователь вводит ключ в поле License Manager → нажимает «Apply»
  2. LicenseManagerPresenter.SaveLicenseKeyToConfig() (LicenseManagerPresenter.cs:184):
    • Отключает все датчики
    • Отправляет ключ через WCF Named Pipes в процесс IMUSensorAPI.ServiceHost для проверки
    • При успехе сохраняет ключ и список сенсоров в SensorWhitelist
  3. SensorWhitelist.LicenseKey = keyConfigurationService.Save() (SensorWhitelist.cs:28-32)
  4. ConfigurationService.Save() (ConfigurationService.cs:103-115):
    • File.WriteAllText() — запись XML
    • xmlSigner.Sign() — подпись RSA (защита от редактирования блокнотом)

Защита от подделки

После сохранения файл подписывается XML-цифровой подписью — enveloped signature (XML DSig), RSA, при каждом сохранении генерируется новый эфемерный ключ (new RSACryptoServiceProvider()). Публичный ключ встраивается в подписанный файл через RSAKeyValue — подпись самодостаточна.

При загрузке (в конструкторе ConfigurationService) вызывается ReadConfig()ValidateXml()XMLValidator.Validate(). Если подпись отсутствует или недействительна — выбрасывается XmlException: "Missing or invalid xml signature".

Перенос с бэкапа: файл можно скопировать как есть — подпись встроена в сам XML и не зависит от тайминга/пути. Но практичнее через UI:

  1. Откройте старый Configuration.xml
  2. Скопируйте значение атрибута License Key="..."
  3. Вставьте его в интерфейс License Manager на новой установке
  4. Нажмите «Apply» — приложение само проверит ключ, определит датчики, перезапишет файл и поставит новую подпись

Как проверяется лицензионный ключ?

Коротко: да, привязан к MAC-адресам сенсоров

Ключ — ровно 15 символов (пример: GEspqbSBJMnc4WN). Кодирует частичную информацию о MAC-адресах 3 конкретных датчиков с помощью подстановочного шифра.

Если у вас новые сенсоры (другие MAC-адреса) — старый ключ не подойдёт. Ключ привязан к конкретным физическим датчикам.

Полная схема проверки

ValedoMotion не общается с датчиками напрямую. Он шлёт запросы в отдельный процесс-сервис (Hocoma.IMUSensorAPI.ServiceHost.exe), который работает на том же ПК и взаимодействует с датчиками через BLE-донгл. Общение ValedoMotion ↔ ServiceHost идёт через WCF Named Pipes (net.pipe://localhost/Hocoma/IMUSensorService/API/).

Валидация ключа происходит на ПК, в процессе IMUSensorAPI.ServiceHost, но требует наличия физических датчиков в BLE-эфире — без их MAC-адресов проверять нечего.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
ValedoMotion (GUI)          IMUSensorAPI.ServiceHost (консольный процесс, ПК)       BLE-датчики
       │                              │                                                 │
       │  1. ValidateLicenseKey(key)  │                                                 │
       │  ──── Named Pipe WCF ──────► │                                                 │
       │                              │                                                 │
       │                              │  2. Сканирует BLE-эфир через донгл               │
       │                              │     Получает MAC-адреса датчиков                 │
       │                              │◄────────────────────────────────────────────────│
       │                              │                                                 │
       │                              │  3. Вызывает нативную C++ DLL (на ПК):            │
       │                              │     validateSensors_C(mac[], key)                │
       │                              │     (Hocoma.Common.SensorValidation.dll)         │
       │                              │                                                 │
       │                              │  4. DLL декодирует ключ в 3 шаблона              │
       │                              │     Сравнивает с MAC-адресами датчиков           │
       │                              │                                                 │
       │  5. OnLicenseKeyValidated()  │                                                 │
       │  ◄──── Named Pipe WCF ────── │                                                 │
       │                              │                                                 │
       │  6. sensors.Length == 3 ?    │                                                 │
       │     ок : ошибка              │                                                 │
       │     MAC-адреса → Configuration.xml (whitelist)                                 │

Реальный алгоритм проверки (из нативной DLL)

Алгоритм реконструирован по декомпилированному коду Hocoma.Common.SensorValidation.dll (Hex-Rays, строки 452–824):

Шаг 1. Таблицы подстановки — 5 таблиц по 16 символов:

Индекс Таблица
0 627GDFMKnp5bxPuY
1 Zv4K9p5jAEWTUScB
2 Nf5ryP78Hsk3WSB4
3 HCa7xR64uhpJZKAW
4 tJTdS9rYghqpM5PN

Каждая таблица — это перестановка 16 символов, соответствующая hex-цифрам 0–f. Функция invmap(c, codeNum) (SensorValidator::invmap, строка 832):

  1. Находит позицию символа c в таблице codes[codeNum] (0–15)
  2. Возвращает hex-цифру: 0→'0', 1→'1', ..., 9→'9', 10→'a', ..., 15→'f'
  3. Если символ не найден — '-'

Шаг 2. Декодирование ключа — 15 символов ключа на 3 шаблона по 12 hex-цифр:

1
2
3
4
Ключ:         G  E  s  p  q  |  b  S  B  J  M  |  n  c  4  W  N
Позиция:      0  1  2  3  4  |  5  6  7  8  9  | 10 11 12 13 14
Таблица №:    0  1  2  3  4  |  0  1  2  3  4  |  0  1  2  3  4
Hex-цифра:    3  9  9  a  a  |  b  d  e  b  c  |  8  e  f  f  f

Каждый символ ключа ищется в таблице с номером position % 5, полученная hex-цифра записывается в один из трёх 12-символьных шаблонов (invkey[0..2]).

Шаг 3. Позиции в шаблоне — карта индексов (indices[3][5]):

Шаблон Позиции в шаблоне Смысл в MAC
invkey[0] 0, 1, 2, 3, 6 Байты 0–1 + старший полубайт байта 3
invkey[1] 0, 1, 2, 3, 7 Байты 0–1 + младший полубайт байта 3
invkey[2] 0, 1, 2, 3, 8 Байты 0–1 + старший полубайт байта 4

Незаполненные позиции (4, 5, 9, 10, 11) остаются '0' — они не участвуют в сравнении.

Шаг 4. Сравнение с датчиками — каждый из трёх шаблонов сравнивается со всеми обнаруженными MAC-адресами (из которых удалены пробелы/двоеточия). Проверяются только 5 указанных позиций. Если все 5 совпадают — датчик засчитывается.

Шаг 5. Результат: три строки с ID датчиков, прошедших проверку, разделённые /. Невалидные предваряются -.

Python: генерация и проверка ключа

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""
Реализация алгоритма лицензионного ключа ValedoMotion на Python.
Основана на реверс-инжиниринге Hocoma.Common.SensorValidation.dll (Hex-Rays).
"""

# Таблицы подстановки (codes[5][16]) — 5 перестановок 16 символов, соответствующих hex-цифрам 0..f
CODES = [
    "627GDFMKnp5bxPuY",
    "Zv4K9p5jAEWTUScB",
    "Nf5ryP78Hsk3WSB4",
    "HCa7xR64uhpJZKAW",
    "tJTdS9rYghqpM5PN",
]

# Карта позиций: какие индексы 12-символьного шаблона заполняются для каждого из 3 сенсоров
INDICES = [
    [0, 1, 2, 3, 6],   # сенсор 0: байты 0-1 + старший полубайт байта 3
    [0, 1, 2, 3, 7],   # сенсор 1: байты 0-1 + младший полубайт байта 3
    [0, 1, 2, 3, 8],   # сенсор 2: байты 0-1 + старший полубайт байта 4
]

def invmap(char, code_num):
    """
    Поиск символа в таблице CODES[code_num].
    Позиция (0..15) → hex-цифра: 0→'0'...9→'9', 10→'a'...15→'f'.
    Не найден → '-'.
    """
    table = CODES[code_num]
    for pos, c in enumerate(table):
        if c == char:
            return "0123456789abcdef"[pos]
    return "-"


def generate_license_key(sensor_macs):
    """
    Генерация 15-символьного лицензионного ключа по трём MAC-адресам датчиков.
    
    Аргументы:
        sensor_macs: список 3 строк, каждая — MAC без разделителей (12 hex-цифр), 
                     например ["AABBCCDDEEFF", "112233445566", "001122334455"]
    
    Возвращает:
        15-символьный лицензионный ключ
    """
    assert len(sensor_macs) == 3, "Нужно ровно 3 MAC-адреса"
    for mac in sensor_macs:
        assert len(mac) == 12 and all(c in "0123456789abcdefABCDEF" for c in mac), \
            f"Некорректный MAC: {mac}"
    
    key_chars = []
    for slot, mac in enumerate(sensor_macs):
        mac_lower = mac.lower()
        for col in range(5):
            # Извлекаем hex-цифру из MAC по индексу INDICES[slot][col]
            mac_pos = INDICES[slot][col]
            hex_char = mac_lower[mac_pos]
            # Преобразуем hex-цифру в позицию (0..15)
            hex_val = int(hex_char, 16)
            # Берём символ из соответствующей таблицы
            key_char = CODES[col][hex_val]
            key_chars.append(key_char)
    return "".join(key_chars)


def validate_license_key(key, discovered_macs):
    """
    Проверка лицензионного ключа против обнаруженных MAC-адресов датчиков.
    
    Аргументы:
        key: 15-символьный лицензионный ключ
        discovered_macs: список MAC-адресов без разделителей (12 hex-цифр каждый),
                         полученных через BLE-сканирование
    
    Возвращает:
        (is_valid, validated_macs) — кортеж:
        - is_valid: bool, True если найдено ровно 3 совпадения
        - validated_macs: список ID валидированных датчиков (пустые строки для несовпавших)
    """
    assert len(key) == 15, f"Ключ должен быть 15 символов, получено {len(key)}"
    
    # Очищаем MAC-адреса от пробелов, двоеточий, тире
    clean_macs = []
    for mac in discovered_macs:
        clean = mac.replace(":", "").replace("-", "").replace(" ", "").lower()
        if len(clean) == 12:
            clean_macs.append(clean)
    
    # Декодируем ключ в 3 шаблона по 12 hex-цифр
    invkeys = [list("000000000000") for _ in range(3)]
    for slot in range(3):
        for col in range(5):
            key_char = key[slot * 5 + col]
            hex_digit = invmap(key_char, col)
            invkeys[slot][INDICES[slot][col]] = hex_digit
    
    # Сравниваем каждый шаблон со всеми обнаруженными MAC
    validated = ["------------------", "------------------", "------------------"]
    for slot in range(3):
        positions = INDICES[slot]
        for mac in clean_macs:
            if all(invkeys[slot][pos] == mac[pos] for pos in positions):
                validated[slot] = mac
                break
    
    valid_count = sum(1 for v in validated if v != "------------------")
    is_valid = valid_count == 3
    return is_valid, validated


# ─── Пример использования ─────────────────────────────────────────────

if __name__ == "__main__":
    # Три MAC-адреса датчиков Valedo (вымышленные)
    sensors = ["AABBCCDDEEFF", "112233445566", "001122334455"]
    
    # Генерация ключа
    license_key = generate_license_key(sensors)
    print(f"Сгенерированный ключ: {license_key}")
    # Пример вывода: 15 символов, каждый из своей таблицы подстановки
    
    # Проверка ключа (имитация BLE-сканирования — все 3 датчика в эфире)
    ok, validated = validate_license_key(license_key, sensors)
    print(f"Все три датчика: valid={ok}, sensors={validated}")
    
    # Проверка с чужими датчиками
    ok, validated = validate_license_key(license_key, ["FFEEDDCCBBAA", "000000000000", "DEADBEEF1234"])
    print(f"Чужие датчики:  valid={ok}, sensors={validated}")
    
    # Проверка с неполным набором (только 2 из 3)
    ok, validated = validate_license_key(license_key, sensors[:2])
    print(f"Только 2 датчика: valid={ok}, sensors={validated}")

Принцип работы кода:

  • generate_license_key: для каждого из 3 слотов берёт 5 hex-цифр MAC по карте INDICES, преобразует каждую hex-цифру (0–f) в позицию (0–15), выбирает символ из таблицы CODES[col][pos]. Результат — 15-символьная строка.
  • validate_license_key: обратная операция — через invmap восстанавливает hex-цифры из символов ключа, формирует 3 шаблона по 12 hex-цифр, сравнивает каждый с обнаруженными MAC-адресами на 5 позициях. Ключ валиден только если все 3 слота нашли совпадение.
  • Ограничение: сверяются только 5 из 12 позиций MAC, поэтому ключ не содержит полного MAC-адреса — только первые 2 байта + 1 полубайт.

Ожидается ровно 3 датчика

1
2
3
// SensorService2.cs:998
bool isEqual = sensors.Length == 3;
// Если не 3 — лицензия не валидна

Примечание о двойном механизме: в кодовой базе присутствует также C# класс LicenseGenerator (Hocoma.Valedo.Licensing/LicenseGenerator.cs), использующий MD5 и 64-байтный XOR-обфусцированный ключ. Похоже, это либо устаревший механизм, либо внутренний инструмент генерации ключей, не используемый в реальной валидации. Фактическая проверка ключа происходит исключительно в нативной DLL через описанный выше подстановочный шифр.

Что проверяется при старте приложения

1
2
3
4
5
6
7
8
9
// Hocoma.Valedo.Startup/LicensePresenter.cs:39-41 — полная логика:
private bool CheckNextButtonState()
{
    return (HasMotion || HasShape) && (
        (HasShape && !HasMotion)                              // ValedoShape без Motion → ок
        || (HasMotion && !string.IsNullOrEmpty(LicenseKey)    // ValedoMotion → нужен ключ
            && SensorIds.Any())                                // и минимум 1 сенсор
        );
}

Кнопка «Далее» на стартовом экране неактивна, пока не введён и не проверен валидный ключ (если используется ValedoMotion). Если установлен только ValedoShape — ключ не требуется.


Дополнительная система: USB-флеш-ключ

Помимо основного механизма, существует вторая, независимая система — сканирование USB-накопителей (Hocoma.Framework.Licensing/LicenseService.cs):

  • Фоновый поток раз в 5 секунд проверяет все сменные диски
  • Ищет файл с заданным именем (keyFile) в корне флешки
  • Читает и расшифровывает Rijndael (AES-192, CBC, ISO10126 padding):
    • Passphrase: "HocomaSecretPassPhraseIsTheBest"
    • Salt: "HocomaSalt"
    • IV: "iHSoNVtdfg7BRXga"
    • PBKDF2 iterations: 1 (очень мало — слабая защита)
  • Если расшифрованное содержимое совпадает с privateKey — флаг isLicenseKeyEnabled = true
  • Если начинается с privateKey — остаток становится ServiceId

Эта система, видимо, использовалась для корпоративных USB-ключей лицензий (сервис-инженеры).


Итоговая таблица

Что Где / Как
Файл с ключом C:\ProgramData\Hocoma_AG\ValedoMotion\Configuration.xml
Элемент Атрибут License Key="..." внутри <License>
Привязка к железу К MAC-адресам 3 BLE-датчиков
Алгоритм Подстановочный шифр: каждый из 15 символов ключа → позиция в одной из 5 таблиц-перестановок → hex-цифра (0-f). Формируется 3 шаблона по 5 hex-цифр, сравнивается с позициями {0,1,2,3,6}, {0,1,2,3,7}, {0,1,2,3,8} MAC-адресов датчиков
Где проверка На ПК, в процессе IMUSensorAPI.ServiceHost, через WCF Named Pipes → нативная C++ DLL
Родная DLL Hocoma.Common.SensorValidation.dll — нативный C++ (Visual C++), декомпилирован Hex-Rays
————————————————————————- ——————————————————————————————————————————————————————————————————————————————————
Секретный ключ Таблицы подстановки: 5 строк по 16 символов, вшиты в DLL как константные массивы codes[5][16]. Никакого «секретного ключа» в привычном смысле — только таблицы-перестановки
Защита файла XML Digital Signature (RSA, эфемерный ключ на каждое сохранение)
Резервная система USB-флешка с AES-192 зашифрованным файлом, сканируется раз в 5 сек
Крипто флешки Rijndael, passphrase: HocomaSecretPassPhraseIsTheBest, salt: HocomaSalt, IV: iHSoNVtdfg7BRXga
Блокировка старта Кнопка «Далее» неактивна без валидного ключа и лицензированных сенсоров

Практическая инструкция: перенос лицензии на новую установку

  1. Найдите старый компьютер или бэкап, откройте:
    1
    
    C:\ProgramData\Hocoma_AG\ValedoMotion\Configuration.xml
    
  2. Найдите строку вида License Key="GEspqbSBJMnc4WN" (ровно 15 символов) — скопируйте значение ключа
  3. На новой установке запустите ValedoMotion
  4. На стартовом экране (License Manager) вставьте ключ в поле ввода
  5. Убедитесь, что BLE-донгл подключен и датчики включены
  6. Нажмите «Apply» — приложение само проверит ключ, определит датчики и сохранит конфигурацию

Важно: ключ сработает только с теми же самыми физическими датчиками (их MAC-адресами), для которых он был выдан. Если у вас новые датчики — нужен новый ключ от Hocoma/DIH.


P.S. Ошибка «Login not successful» при загрузке

Если после ввода лицензии и обнаружения датчиков (видны индикаторы заряда в заголовке) их индикаторы внезапно пропадают и появляется диалог «Error logging in» — проблема не в лицензии и не в датчиках.

Причина

При нажатии «ValedoMotion» на экране логина (LoginPresenter.cs:114) запускается таймер на 10 секунд. За это время должно произойти:

  1. Старт IMUSensorAPI.ServiceHost.exe — работа с датчиками (BLE)
  2. Старт Hocoma.ExerciseHost.Main.exe — игровой движок (GAL)
  3. ExerciseHost подключается к ValedoMotion через WebSocket (ws://localhost:46544) и WCF Named Pipe

Если ExerciseHost не запускается или не подключается за 10 секунд — таймер срабатывает:

1
2
BeforeTimeOutActions  →  StopExternalServices  →  IMUSensorAPI убит  →  индикаторы пропали
AfterTimeOutActions   →  диалог «Error logging in»

Корневая причина: ExerciseHost (GAL-процесс) не стартовал. Чаще всего — завис глобальный мьютекс Global\58e916b7-b43a-49ec-81ee-c4e8642aaf96 от предыдущего аварийного завершения. ValedoMotion проверяет мьютекс и думает, что ExerciseHost уже работает (ExerciseHostController.cs:56), поэтому не запускает его заново.

Решение

  1. Открой Диспетчер задач, найди процессы Hocoma.ExerciseHost.Main.exe — заверши их все
  2. Запусти Hocoma.ExerciseHost.Main.exe вручную из папки установки (окно будет скрыто ValedoMotion)
  3. Запусти ValedoMotion — ошибки не будет

Или просто перезагрузи компьютер — мьютекс освободится, ValedoMotion сам поднимет ExerciseHost при следующем запуске.

Но в моём случае это не помогало, и я просто добавил файл в автозагрузку.

Архитектура двух процессов

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ValedoMotion.exe                      Hocoma.ExerciseHost.Main.exe (GAL)
      │                                           │
      │  WebSocket ws://localhost:46544            │
      │  «<ExerciseHostServiceReady/>» ◄────────── │  я запустился
      │  «<ControlRunning>ValedoMotion» ──────────►│  бери управление
      │  «<Hide/>» ───────────────────────────────►│  спрячь окно
      │                                           │
      │  WCF Named Pipe                            │
      │  net.pipe://localhost/Hocoma/ExerciseHost/ │
      │  GetAvailableExercises() ─────────────────►│
      │  GetGameVersions() ───────────────────────►│
      │  StopApplication() ───────────────────────►│

Оба процесса — обычные .exe, не службы. ExerciseHost запускается ValedoMotion через Process.Start() и работает в фоне (окно скрыто командой <Hide/>).